diff --git a/.eslintignore b/.eslintignore index 6758d7f68c..4447ad6726 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,10 +1,8 @@ -app/assets/javascripts/browser-update.js app/assets/javascripts/locales/i18n.js app/assets/javascripts/ember-addons/ -app/assets/javascripts/discourse/lib/autosize.js lib/javascripts/locale/ lib/javascripts/messageformat.js -lib/highlight_js/ +lib/javascripts/messageformat-lookup.js lib/pretty_text/ plugins/**/lib/javascripts/locale public/ diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..90492aebc0 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,2 @@ +chat: +- plugins/chat/**/* diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000000..057208eda3 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,14 @@ +name: "Pull Request Labeler" +on: +- pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml index 18fa2b2f0e..7bedc1dfc9 100644 --- a/.github/workflows/licenses.yml +++ b/.github/workflows/licenses.yml @@ -52,7 +52,7 @@ jobs: - name: Get yarn cache directory id: yarn-cache-dir - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Yarn cache uses: actions/cache@v3 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 59379f06af..2522c36d4f 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -49,7 +49,7 @@ jobs: - name: Get yarn cache directory id: yarn-cache-dir - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Yarn cache uses: actions/cache@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1356bf73b..042e994984 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,8 +41,6 @@ jobs: target: plugins - build_type: frontend target: core # Handled by core_frontend_tests job (below) - - build_type: system - target: plugins # Enable once at least 1 plugin has system tests steps: - uses: actions/checkout@v3 @@ -83,7 +81,7 @@ jobs: - name: Get yarn cache directory id: yarn-cache-dir - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Yarn cache uses: actions/cache@v3 @@ -178,7 +176,7 @@ jobs: - name: Plugin System Tests if: matrix.build_type == 'system' && matrix.target == 'plugins' - run: bin/system_rspec plugins/*/spec/system + run: LOAD_PLUGINS=1 bin/system_rspec plugins/*/spec/system - name: Upload failed system test screenshots uses: actions/upload-artifact@v3 @@ -223,7 +221,7 @@ jobs: TESTEM_FIREFOX_PATH: ${{ (matrix.browser == 'Firefox Evergreen') && '/opt/firefox-evergreen/firefox' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 1 @@ -234,7 +232,7 @@ jobs: - name: Get yarn cache directory id: yarn-cache-dir - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Yarn cache uses: actions/cache@v3 diff --git a/.gitignore b/.gitignore index 11f40d38b6..25f9373afc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ !/plugins/discourse-narrative-bot !/plugins/discourse-presence !/plugins/lazy-yt/ +!/plugins/chat/ !/plugins/poll/ !/plugins/styleguide /plugins/*/auto_generated/ diff --git a/.streerc b/.streerc new file mode 100644 index 0000000000..0bc4379d46 --- /dev/null +++ b/.streerc @@ -0,0 +1,2 @@ +--print-width=100 +--plugins=plugin/trailing_comma diff --git a/Gemfile.lock b/Gemfile.lock index 84fa798d48..29506c76cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -89,7 +89,7 @@ GEM activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) - capybara (3.37.1) + capybara (3.38.0) addressable matrix mini_mime (>= 0.1.3) @@ -145,7 +145,7 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.11.0) - excon (0.93.1) + excon (0.94.0) execjs (2.8.1) exifr (1.3.10) fabrication (2.30.0) @@ -182,17 +182,17 @@ GEM image_size (>= 1.5, < 4) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - image_size (3.1.0) + image_size (3.2.0) in_threads (1.6.0) jmespath (1.6.1) - jquery-rails (4.5.0) + jquery-rails (4.5.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.6.2) json-schema (3.0.0) addressable (>= 2.8) - json_schemer (0.2.22) + json_schemer (0.2.23) ecma-re-validator (~> 0.3) hana (~> 1.3) regexp_parser (~> 2.0) @@ -239,7 +239,8 @@ GEM mini_suffix (0.3.3) ffi (~> 1.9) minitest (5.16.3) - mocha (1.16.0) + mocha (2.0.2) + ruby2_keywords (>= 0.0.5) msgpack (1.6.0) multi_json (1.15.0) multi_xml (0.6.0) @@ -306,7 +307,7 @@ GEM openssl (> 2.0, < 3.1) optimist (3.0.1) parallel (1.22.1) - parallel_tests (3.13.0) + parallel_tests (4.0.0) parallel parser (3.1.2.1) ast (~> 2.4.1) @@ -328,7 +329,7 @@ GEM rack (2.2.4) rack-mini-profiler (3.0.0) rack (>= 1.2.0) - rack-protection (3.0.2) + rack-protection (3.0.3) rack rack-test (2.0.2) rack (>= 1.3) @@ -370,7 +371,7 @@ GEM rack (>= 1.4) rexml (3.2.5) rinku (2.0.6) - rotp (6.2.0) + rotp (6.2.1) rqrcode (2.1.2) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -406,7 +407,7 @@ GEM json-schema (>= 2.2, < 4.0) railties (>= 3.1, < 7.1) rspec-core (>= 2.14) - rubocop (1.37.1) + rubocop (1.38.0) json (~> 2.3) parallel (~> 1.10) parser (>= 3.1.2.1) @@ -421,7 +422,7 @@ GEM rubocop-discourse (3.0) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.14.2) + rubocop-rspec (2.15.0) rubocop (~> 1.33) ruby-prof (1.4.3) ruby-progressbar (1.11.0) @@ -442,7 +443,7 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.5.0) + selenium-webdriver (4.6.1) childprocess (>= 0.5, < 5.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) @@ -506,7 +507,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yaml-lint (0.0.10) - zeitwerk (2.6.3) + zeitwerk (2.6.6) PLATFORMS aarch64-linux diff --git a/app/assets/javascripts/.licensee.json b/app/assets/javascripts/.licensee.json index 1287b5fde6..b3518d7a13 100644 --- a/app/assets/javascripts/.licensee.json +++ b/app/assets/javascripts/.licensee.json @@ -21,8 +21,7 @@ "line-stream": "0.0.0", "regenerator-transform": "0.10.1", "source-map": "0.1.43", - "sourcemap-validator": "1.1.1", - "xmldom": "0.1.31" + "sourcemap-validator": "1.1.1" }, "corrections": true, "ignore": [ diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index 1d6ba8ccba..f1b3138a78 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -2,7 +2,7 @@ import discourseComputed, { observes } from "discourse-common/utils/decorators"; import Component from "@ember/component"; import I18n from "I18n"; import WatchedWord from "admin/models/watched-word"; -import { equal } from "@ember/object/computed"; +import { equal, not } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; @@ -16,7 +16,7 @@ export default Component.extend({ showMessage: false, selectedTags: null, isCaseSensitive: false, - + submitDisabled: not("word"), canReplace: equal("actionKey", "replace"), canTag: equal("actionKey", "tag"), canLink: equal("actionKey", "link"), diff --git a/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js b/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js index 07372d2f10..37f5126068 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js @@ -6,11 +6,13 @@ import discourseDebounce from "discourse-common/lib/debounce"; import { observes } from "discourse-common/utils/decorators"; import { clipboardCopy } from "discourse/lib/utilities"; import { inject as service } from "@ember/service"; +import { or } from "@ember/object/computed"; export default Controller.extend({ dialog: service(), loading: false, filter: null, + showSearch: or("model.length", "filter"), _debouncedShow() { Permalink.findAll(this.filter).then((result) => { diff --git a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js index be663cadb1..34ffe45783 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js @@ -1,17 +1,27 @@ import { action } from "@ember/object"; import Controller from "@ember/controller"; -import discourseComputed from "discourse-common/utils/decorators"; +import { inject as service } from "@ember/service"; export default Controller.extend({ - @discourseComputed - adminRoutes() { + router: service(), + + get adminRoutes() { + return this.allAdminRoutes.filter((r) => this.routeExists(r.full_location)); + }, + + get brokenAdminRoutes() { + return this.allAdminRoutes.filter( + (r) => !this.routeExists(r.full_location) + ); + }, + + get allAdminRoutes() { return this.model + .filter((p) => p?.enabled) .map((p) => { - if (p.get("enabled")) { - return p.admin_route; - } + return p.admin_route; }) - .compact(); + .filter(Boolean); }, @action @@ -21,4 +31,13 @@ export default Controller.extend({ adminDetail.classList.toggle(state); }); }, + + routeExists(routeName) { + try { + this.router.urlFor(routeName); + return true; + } catch (e) { + return false; + } + }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-start-backup.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-start-backup.js index 2934f251ee..f60908b0e2 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-start-backup.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-start-backup.js @@ -1,9 +1,22 @@ import Controller, { inject as controller } from "@ember/controller"; +import discourseComputed from "discourse-common/utils/decorators"; import ModalFunctionality from "discourse/mixins/modal-functionality"; export default Controller.extend(ModalFunctionality, { adminBackupsLogs: controller(), + @discourseComputed + warningMessage() { + // this is never shown here, but we may want to show different + // messages in plugins + return ""; + }, + + @discourseComputed + yesLabel() { + return "yes_value"; + }, + actions: { startBackupWithUploads() { this.send("closeModal"); diff --git a/app/assets/javascripts/admin/addon/mixins/setting-component.js b/app/assets/javascripts/admin/addon/mixins/setting-component.js index d651d1690c..6b9bfbda10 100644 --- a/app/assets/javascripts/admin/addon/mixins/setting-component.js +++ b/app/assets/javascripts/admin/addon/mixins/setting-component.js @@ -43,6 +43,7 @@ export default Mixin.create({ validationMessage: null, isSecret: oneWay("setting.secret"), setting: null, + attributeBindings: ["setting.setting:data-setting"], @discourseComputed("buffered.value", "setting.value") dirty(bufferVal, settingVal) { @@ -136,6 +137,7 @@ export default Mixin.create({ "default_email_mailing_list_mode_frequency", "default_email_previous_replies", "default_email_in_reply_to", + "default_hide_profile_and_presence", "default_other_new_topic_duration_minutes", "default_other_auto_track_topics_after_msecs", "default_other_notification_level_when_replying", diff --git a/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs b/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs index 705ac6894d..2e30cd353d 100644 --- a/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs @@ -6,8 +6,8 @@ - + - + diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index ef56c81a54..a971c2568e 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -35,7 +35,7 @@ - + {{#if this.showMessage}} {{this.message}} diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-start-backup.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-start-backup.hbs index fe0bb228c0..c736368d44 100644 --- a/app/assets/javascripts/admin/addon/templates/modal/admin-start-backup.hbs +++ b/app/assets/javascripts/admin/addon/templates/modal/admin-start-backup.hbs @@ -1,5 +1,8 @@ - + {{#if this.warningMessage}} +
{{html-safe this.warningMessage}}
+ {{/if}} +
diff --git a/app/assets/javascripts/admin/addon/templates/permalinks.hbs b/app/assets/javascripts/admin/addon/templates/permalinks.hbs index 13d1e1d1b7..3f4cad7894 100644 --- a/app/assets/javascripts/admin/addon/templates/permalinks.hbs +++ b/app/assets/javascripts/admin/addon/templates/permalinks.hbs @@ -6,51 +6,58 @@ - {{#if this.model.length}} - - - - - - - - - {{#each this.model as |pl|}} - - - + + + {{/each}} + + + {{else}} + {{#if this.filter}} + + {{else}} + + {{/if}} + {{/if}} + diff --git a/app/assets/javascripts/admin/addon/templates/plugins-index.hbs b/app/assets/javascripts/admin/addon/templates/plugins-index.hbs index bb8cc190ec..c3728965cb 100644 --- a/app/assets/javascripts/admin/addon/templates/plugins-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/plugins-index.hbs @@ -13,7 +13,7 @@ {{#each this.model as |plugin|}} - + {{#if plugin.is_official}} {{d-icon "check-circle" diff --git a/app/assets/javascripts/admin/addon/templates/plugins.hbs b/app/assets/javascripts/admin/addon/templates/plugins.hbs index 1425cf479e..1baaaddd2d 100644 --- a/app/assets/javascripts/admin/addon/templates/plugins.hbs +++ b/app/assets/javascripts/admin/addon/templates/plugins.hbs @@ -19,5 +19,11 @@
+ {{#each this.brokenAdminRoutes as |route|}} +
+ {{i18n "admin.plugins.broken_route" name=(i18n route.label)}} +
+ {{/each}} + {{outlet}}
diff --git a/app/assets/javascripts/admin/package.json b/app/assets/javascripts/admin/package.json index 4d9622994c..de2970fa2f 100644 --- a/app/assets/javascripts/admin/package.json +++ b/app/assets/javascripts/admin/package.json @@ -34,7 +34,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/discourse-common/addon/lib/debounce.js b/app/assets/javascripts/discourse-common/addon/lib/debounce.js index 2bcd387724..37171684b1 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/debounce.js +++ b/app/assets/javascripts/discourse-common/addon/lib/debounce.js @@ -9,10 +9,18 @@ import { isTesting } from "discourse-common/config/environment"; export default function () { if (isTesting()) { + const lastArgument = arguments[arguments.length - 1]; + const hasImmediateArgument = typeof lastArgument === "boolean"; + + let args = [].slice.call(arguments, 0, hasImmediateArgument ? -2 : -1); + // Replace the time argument with 10ms - let args = [].slice.call(arguments, 0, -1); args.push(10); + if (hasImmediateArgument) { + args.push(lastArgument); + } + return debounce.apply(undefined, args); } else { return debounce(...arguments); diff --git a/app/assets/javascripts/discourse-common/addon/utils/decorators.js b/app/assets/javascripts/discourse-common/addon/utils/decorators.js index f9544d9176..1320285db1 100644 --- a/app/assets/javascripts/discourse-common/addon/utils/decorators.js +++ b/app/assets/javascripts/discourse-common/addon/utils/decorators.js @@ -88,7 +88,7 @@ export function readOnly(target, name, desc) { }; } -export function debounce(delay) { +export function debounce(delay, immediate = false) { return function (target, name, descriptor) { return { enumerable: descriptor.enumerable, @@ -97,7 +97,13 @@ export function debounce(delay) { initializer() { const originalFunction = descriptor.value; const debounced = function (...args) { - return discourseDebounce(this, originalFunction, ...args, delay); + return discourseDebounce( + this, + originalFunction, + ...args, + delay, + immediate + ); }; return debounced; diff --git a/app/assets/javascripts/discourse-common/package.json b/app/assets/javascripts/discourse-common/package.json index 1861ab8d22..680b243211 100644 --- a/app/assets/javascripts/discourse-common/package.json +++ b/app/assets/javascripts/discourse-common/package.json @@ -41,7 +41,7 @@ "ember-cli-terser": "^4.0.2", "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/discourse-hbr/package.json b/app/assets/javascripts/discourse-hbr/package.json index f1277846df..aec662621a 100644 --- a/app/assets/javascripts/discourse-hbr/package.json +++ b/app/assets/javascripts/discourse-hbr/package.json @@ -34,7 +34,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/discourse-plugins/index.js b/app/assets/javascripts/discourse-plugins/index.js index df33693b31..4b5ed201fb 100644 --- a/app/assets/javascripts/discourse-plugins/index.js +++ b/app/assets/javascripts/discourse-plugins/index.js @@ -113,18 +113,22 @@ module.exports = { directoryName, "test/javascripts" ); + const configDirectory = path.resolve(root, directoryName, "config"); const hasJs = fs.existsSync(jsDirectory); const hasAdminJs = fs.existsSync(adminJsDirectory); const hasTests = fs.existsSync(testDirectory); + const hasConfig = fs.existsSync(configDirectory); return { pluginName, directoryName, jsDirectory, adminJsDirectory, testDirectory, + configDirectory, hasJs, hasAdminJs, hasTests, + hasConfig, }; }); }, diff --git a/app/assets/javascripts/discourse-widget-hbs/package.json b/app/assets/javascripts/discourse-widget-hbs/package.json index 0290b8e32e..ada9dcfcf5 100644 --- a/app/assets/javascripts/discourse-widget-hbs/package.json +++ b/app/assets/javascripts/discourse-widget-hbs/package.json @@ -35,7 +35,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/discourse/app/components/plugin-connector.js b/app/assets/javascripts/discourse/app/components/plugin-connector.js index abf6d89f11..22f5252e05 100644 --- a/app/assets/javascripts/discourse/app/components/plugin-connector.js +++ b/app/assets/javascripts/discourse/app/components/plugin-connector.js @@ -51,7 +51,7 @@ export default Component.extend({ this.set("actions", connectorClass.actions); for (const [name, action] of Object.entries(this.actions)) { - this.set(name, action); + this.set(name, action.bind(this)); } const merged = buildArgsWithDeprecations(args, deprecatedArgs); diff --git a/app/assets/javascripts/discourse/app/components/second-factor-form.js b/app/assets/javascripts/discourse/app/components/second-factor-form.js index e208a5f314..9b23ed64ac 100644 --- a/app/assets/javascripts/discourse/app/components/second-factor-form.js +++ b/app/assets/javascripts/discourse/app/components/second-factor-form.js @@ -42,10 +42,12 @@ export default Component.extend({ } }, - @discourseComputed("backupEnabled", "secondFactorMethod") - showToggleMethodLink(backupEnabled, secondFactorMethod) { + @discourseComputed("backupEnabled", "totpEnabled", "secondFactorMethod") + showToggleMethodLink(backupEnabled, totpEnabled, secondFactorMethod) { return ( - backupEnabled && secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY + backupEnabled && + totpEnabled && + secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY ); }, diff --git a/app/assets/javascripts/discourse/app/components/sidebar/anonymous/categories-section.js b/app/assets/javascripts/discourse/app/components/sidebar/anonymous/categories-section.js index 40043a8171..b1a2d849d2 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/anonymous/categories-section.js +++ b/app/assets/javascripts/discourse/app/components/sidebar/anonymous/categories-section.js @@ -1,31 +1,32 @@ -import { inject as service } from "@ember/service"; import { canDisplayCategory } from "discourse/lib/sidebar/helpers"; import SidebarCommonCategoriesSection from "discourse/components/sidebar/common/categories-section"; +import Category from "discourse/models/category"; export default class SidebarAnonymousCategoriesSection extends SidebarCommonCategoriesSection { - @service site; + constructor() { + super(...arguments); + + if (!this.siteSettings.default_sidebar_categories) { + this.shouldSortCategoriesByDefault = false; + } + } get categories() { - let categories = this.site.categoriesList; - if (this.siteSettings.default_sidebar_categories) { - const defaultCategoryIds = this.siteSettings.default_sidebar_categories - .split("|") - .map((categoryId) => parseInt(categoryId, 10)); - - categories = categories.filter((category) => - defaultCategoryIds.includes(category.id) + return Category.findByIds( + this.siteSettings.default_sidebar_categories + .split("|") + .map((categoryId) => parseInt(categoryId, 10)) ); } else { - categories = categories - .filter( - (category) => - canDisplayCategory(category, this.siteSettings) && - !category.parent_category_id - ) + return this.site.categoriesList + .filter((category) => { + return ( + !category.parent_category_id && + canDisplayCategory(category.id, this.siteSettings) + ); + }) .slice(0, 5); } - - return categories; } } diff --git a/app/assets/javascripts/discourse/app/components/sidebar/common/categories-section.js b/app/assets/javascripts/discourse/app/components/sidebar/common/categories-section.js index b51c37c47b..68c3e74b1c 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/common/categories-section.js +++ b/app/assets/javascripts/discourse/app/components/sidebar/common/categories-section.js @@ -2,29 +2,60 @@ import Component from "@glimmer/component"; import { cached } from "@glimmer/tracking"; import { inject as service } from "@ember/service"; +import Category from "discourse/models/category"; import CategorySectionLink from "discourse/lib/sidebar/user/categories-section/category-section-link"; +import { canDisplayCategory } from "discourse/lib/sidebar/helpers"; export default class SidebarCommonCategoriesSection extends Component { @service topicTrackingState; @service siteSettings; + @service site; - // Override in child + shouldSortCategoriesByDefault = true; + + /** + * Override in child + * + * @returns {Object[]} An array of Category objects + */ get categories() {} + get sortedCategories() { + if (!this.shouldSortCategoriesByDefault) { + return this.categories; + } + + let categories = this.site.categories; + + if (!this.siteSettings.fixed_category_positions) { + categories = categories.sort((a, b) => a.name.localeCompare(b.name)); + } + + const categoryIds = this.categories.map((category) => category.id); + + return Category.sortCategories(categories).reduce( + (filteredCategories, category) => { + if ( + categoryIds.includes(category.id) && + canDisplayCategory(category.id, this.siteSettings) + ) { + filteredCategories.push(category); + } + + return filteredCategories; + }, + [] + ); + } + @cached get sectionLinks() { - return this.categories - .sort((a, b) => a.name.localeCompare(b.name)) - .reduce((links, category) => { - links.push( - new CategorySectionLink({ - category, - topicTrackingState: this.topicTrackingState, - currentUser: this.currentUser, - }) - ); - - return links; - }, []); + return this.sortedCategories.map((category) => { + return new CategorySectionLink({ + category, + topicTrackingState: this.topicTrackingState, + currentUser: this.currentUser, + }); + }); } } diff --git a/app/assets/javascripts/discourse/app/components/sidebar/common/community-section.hbs b/app/assets/javascripts/discourse/app/components/sidebar/common/community-section.hbs index 2727a2093b..8479c2324e 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/common/community-section.hbs +++ b/app/assets/javascripts/discourse/app/components/sidebar/common/community-section.hbs @@ -25,7 +25,10 @@ @model={{sectionLink.model}} @models={{sectionLink.models}} @prefixType={{sectionLink.prefixType}} - @prefixValue={{sectionLink.prefixValue}} /> + @prefixValue={{sectionLink.prefixValue}} + @suffixCSSClass={{sectionLink.suffixCSSClass}} + @suffixValue={{sectionLink.suffixValue}} + @suffixType={{sectionLink.suffixType}}/> {{/each}} diff --git a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.hbs b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.hbs index e44793bc4a..70f706a05a 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.hbs +++ b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.hbs @@ -21,7 +21,10 @@ @prefixType={{sectionLink.prefixType}} @prefixValue={{sectionLink.prefixValue}} @prefixColor={{sectionLink.prefixColor}} - @prefixElementColors={{sectionLink.prefixElementColors}} > + @prefixElementColors={{sectionLink.prefixElementColors}} + @suffixCSSClass={{sectionLink.suffixCSSClass}} + @suffixValue={{sectionLink.suffixValue}} + @suffixType={{sectionLink.suffixType}} > {{/each}} {{else}} diff --git a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js index 0877cfa82f..10c66629d6 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js +++ b/app/assets/javascripts/discourse/app/components/sidebar/user/categories-section.js @@ -1,7 +1,8 @@ import { inject as service } from "@ember/service"; import { action } from "@ember/object"; +import Category from "discourse/models/category"; +import { cached } from "@glimmer/tracking"; -import { canDisplayCategory } from "discourse/lib/sidebar/helpers"; import SidebarCommonCategoriesSection from "discourse/components/sidebar/common/categories-section"; export default class SidebarUserCategoriesSection extends SidebarCommonCategoriesSection { @@ -24,10 +25,9 @@ export default class SidebarUserCategoriesSection extends SidebarCommonCategorie this.topicTrackingState.offStateChange(this.callbackId); } + @cached get categories() { - return this.currentUser.sidebarCategories.filter((category) => { - return canDisplayCategory(category, this.siteSettings); - }); + return Category.findByIds(this.currentUser.sidebarCategoryIds); } /** diff --git a/app/assets/javascripts/discourse/app/components/sidebar/user/sections.hbs b/app/assets/javascripts/discourse/app/components/sidebar/user/sections.hbs index f3a01651bd..0a47f55149 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/user/sections.hbs +++ b/app/assets/javascripts/discourse/app/components/sidebar/user/sections.hbs @@ -24,6 +24,7 @@ {{#each customSection.links as |link|}} + @models={{sectionLink.models}} + @suffixCSSClass={{sectionLink.suffixCSSClass}} + @suffixValue={{sectionLink.suffixValue}} + @suffixType={{sectionLink.suffixType}} > {{/each}} {{else}} diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index 55ba73ad52..4248d31adf 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -238,7 +238,7 @@ const SiteHeaderComponent = MountWidget.extend( this.currentUser.on("status-changed", this, "queueRerender"); } - if (!this.siteSettings.enable_onboarding_popups) { + if (!this.siteSettings.enable_user_tips) { if ( this.currentUser && !this.get("currentUser.read_first_notification") diff --git a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js index 1d5ff71b60..e65a85aeb8 100644 --- a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js +++ b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js @@ -1,4 +1,4 @@ -import { alias, and, or } from "@ember/object/computed"; +import { alias, or } from "@ember/object/computed"; import { computed } from "@ember/object"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; @@ -54,8 +54,6 @@ export default Component.extend({ inviteDisabled: or("topic.archived", "topic.closed", "topic.deleted"), - showEditOnFooter: and("topic.isPrivateMessage", "site.can_tag_pms"), - @discourseComputed("topic.message_archived") archiveIcon: (archived) => (archived ? "envelope" : "folder"), diff --git a/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js b/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js index 192967c3ca..1b04b49145 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js +++ b/app/assets/javascripts/discourse/app/components/user-menu/messages-list.js @@ -7,8 +7,11 @@ import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item" import UserMenuMessageItem from "discourse/lib/user-menu/message-item"; import Topic from "discourse/models/topic"; import { mergeSortedLists } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; export default class UserMenuMessagesList extends UserMenuNotificationsList { + @service store; + get dismissTypes() { return this.filterByTypes; } @@ -22,7 +25,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { } get showDismiss() { - return this.#unreadMessaagesNotifications > 0; + return this.#unreadMessagesNotifications > 0; } get dismissTitle() { @@ -37,7 +40,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { return "user-menu/messages-list-empty-state"; } - get #unreadMessaagesNotifications() { + get #unreadMessagesNotifications() { const key = `grouped_unread_notifications.${this.site.notification_types.private_message}`; // we're retrieving the value with get() so that Ember tracks the property // and re-renders the UI when it changes. @@ -66,7 +69,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { ); }); - const topics = data.topics.map((t) => Topic.create(t)); + const topics = data.topics.map((t) => this.store.createRecord("topic", t)); await Topic.applyTransformations(topics); const readNotifications = await Notification.initializeNotifications( @@ -100,7 +103,7 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList { modalController.set( "confirmationMessage", I18n.t("notifications.dismiss_confirmation.body.messages", { - count: this.#unreadMessaagesNotifications, + count: this.#unreadMessagesNotifications, }) ); return modalController; diff --git a/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs b/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs index 167f727cf7..394c3ac3e3 100644 --- a/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs +++ b/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs @@ -19,7 +19,7 @@ -{{#if this.siteSettings.enable_mentions}} +{{#if @siteSettings.enable_mentions}}
  • {{d-icon "at"}} diff --git a/app/assets/javascripts/discourse/app/controllers/discovery.js b/app/assets/javascripts/discourse/app/controllers/discovery.js index ae0570b28b..9a8d58b310 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery.js @@ -2,6 +2,7 @@ import Controller, { inject as controller } from "@ember/controller"; import { alias, equal, not } from "@ember/object/computed"; import { action } from "@ember/object"; import Category from "discourse/models/category"; +import discourseComputed from "discourse-common/utils/decorators"; import DiscourseURL from "discourse/lib/url"; import { inject as service } from "@ember/service"; @@ -21,6 +22,24 @@ export default Controller.extend({ loadedAllItems: not("discoveryTopics.model.canLoadMore"), + @discourseComputed( + "router.currentRouteName", + "router.currentRoute.queryParams.f", + "site.show_welcome_topic_banner" + ) + showEditWelcomeTopicBanner( + currentRouteName, + hasParams, + showWelcomeTopicBanner + ) { + return ( + this.currentUser?.staff && + currentRouteName === "discovery.latest" && + showWelcomeTopicBanner && + !hasParams + ); + }, + @action loadingBegan() { this.set("loading", true); diff --git a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js index c1f500a80b..caa92925ba 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js @@ -24,20 +24,6 @@ const controllerOpts = { showTopicPostBadges: not("new"), redirectedReason: alias("currentUser.redirected_to_top.reason"), - @discourseComputed( - "model.filter", - "site.show_welcome_topic_banner", - "model.listParams.f" - ) - showEditWelcomeTopicBanner(filter, showWelcomeTopicBanner, hasListParams) { - return ( - this.currentUser?.staff && - filter === "latest" && - showWelcomeTopicBanner && - !hasListParams - ); - }, - expandGloballyPinned: false, expandAllPinned: false, diff --git a/app/assets/javascripts/discourse/app/controllers/login.js b/app/assets/javascripts/discourse/app/controllers/login.js index 401c85f354..482509d68a 100644 --- a/app/assets/javascripts/discourse/app/controllers/login.js +++ b/app/assets/javascripts/discourse/app/controllers/login.js @@ -223,21 +223,30 @@ export default Controller.extend(ModalFunctionality, { this.clearFlash(); if ( - (result.security_key_enabled || result.totp_enabled) && + (result.security_key_enabled || + result.totp_enabled || + result.backup_enabled) && !this.secondFactorRequired ) { + let secondFactorMethod; + if (result.security_key_enabled) { + secondFactorMethod = SECOND_FACTOR_METHODS.SECURITY_KEY; + } else if (result.totp_enabled) { + secondFactorMethod = SECOND_FACTOR_METHODS.TOTP; + } else { + secondFactorMethod = SECOND_FACTOR_METHODS.BACKUP_CODE; + } this.setProperties({ otherMethodAllowed: result.multiple_second_factor_methods, secondFactorRequired: true, showLoginButtons: false, backupEnabled: result.backup_enabled, - showSecondFactor: result.totp_enabled, + totpEnabled: result.totp_enabled, + showSecondFactor: result.totp_enabled || result.backup_enabled, showSecurityKey: result.security_key_enabled, - secondFactorMethod: result.security_key_enabled - ? SECOND_FACTOR_METHODS.SECURITY_KEY - : SECOND_FACTOR_METHODS.TOTP, securityKeyChallenge: result.challenge, securityKeyAllowedCredentialIds: result.allowed_credential_ids, + secondFactorMethod, }); // only need to focus the 2FA input for TOTP diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js index 622689240f..242ff32d0b 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js @@ -420,7 +420,7 @@ export default Controller.extend({ } }, - resetSeenPopups() { + resetSeenUserTips() { this.model.set("skip_new_user_tips", false); this.model.set("seen_popups", null); this.model.set("user_option.skip_new_user_tips", false); diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js b/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js index 9e7f5b80cb..d74cbdde0c 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js @@ -27,6 +27,7 @@ export default class extends Controller { @action save() { const initialSidebarCategoryIds = this.model.sidebarCategoryIds; + const initialSidebarListDestination = this.model.sidebar_list_destination; this.model.set( "sidebarCategoryIds", @@ -59,6 +60,9 @@ export default class extends Controller { }) .finally(() => { this.model.set("sidebar_tag_names", []); + if (initialSidebarListDestination !== this.newSidebarListDestination) { + window.location.reload(); + } }); } } diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index f682f8e68b..7e473e321c 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -564,8 +564,8 @@ export default Controller.extend(bufferedProperty("model"), { return this.get("model.details").removeAllowedGroup(group); }, - deleteTopic() { - this.deleteTopic(); + deleteTopic(opts = {}) { + this.deleteTopic(opts); }, // Archive a PM (as opposed to archiving a topic) @@ -1522,7 +1522,11 @@ export default Controller.extend(bufferedProperty("model"), { this.model.recover(); }, - deleteTopic(opts) { + deleteTopic(opts = {}) { + if (opts.force_destroy) { + return this.model.destroy(this.currentUser, opts); + } + if ( this.model.views > this.siteSettings.min_topic_views_for_delete_confirm ) { diff --git a/app/assets/javascripts/discourse/app/initializers/handle-cookies.js b/app/assets/javascripts/discourse/app/initializers/handle-cookies.js new file mode 100644 index 0000000000..4526f51055 --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/handle-cookies.js @@ -0,0 +1,18 @@ +import { extendThemeCookie } from "discourse/lib/theme-selector"; +import { extendColorSchemeCookies } from "discourse/lib/color-scheme-picker"; +import { later } from "@ember/runloop"; +import { isTesting } from "discourse-common/config/environment"; + +const DELAY = isTesting() ? 0 : 5000; + +export default { + name: "handle-cookies", + + initialize() { + // No need to block boot for this housekeeping - we can defer it a few seconds + later(() => { + extendThemeCookie(); + extendColorSchemeCookies(); + }, DELAY); + }, +}; diff --git a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js index 06027c8cf8..f80b68bde1 100644 --- a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js +++ b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js @@ -1,5 +1,6 @@ import { addComposerUploadPreProcessor } from "discourse/components/composer-editor"; import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; +import { Promise } from "rsvp"; export default { name: "register-media-optimization-upload-processor", @@ -11,10 +12,15 @@ export default { UppyMediaOptimization, ({ isMobileDevice }) => { return { - optimizeFn: (data, opts) => - container + optimizeFn: (data, opts) => { + if (container.isDestroyed || container.isDestroying) { + return Promise.resolve(); + } + + return container .lookup("service:media-optimization-worker") - .optimizeImage(data, opts), + .optimizeImage(data, opts); + }, runParallel: !isMobileDevice, }; } diff --git a/app/assets/javascripts/discourse/app/lib/color-scheme-picker.js b/app/assets/javascripts/discourse/app/lib/color-scheme-picker.js index ff3e81d328..7582323fcf 100644 --- a/app/assets/javascripts/discourse/app/lib/color-scheme-picker.js +++ b/app/assets/javascripts/discourse/app/lib/color-scheme-picker.js @@ -89,14 +89,35 @@ export function loadColorSchemeStylesheet( ); } +const COLOR_SCHEME_COOKIE_NAME = "color_scheme_id"; +const DARK_SCHEME_COOKIE_NAME = "dark_scheme_id"; +const COOKIE_EXPIRY_DAYS = 365; + export function updateColorSchemeCookie(id, options = {}) { - const cookieName = options.dark ? "dark_scheme_id" : "color_scheme_id"; + const cookieName = options.dark + ? DARK_SCHEME_COOKIE_NAME + : COLOR_SCHEME_COOKIE_NAME; if (id) { cookie(cookieName, id, { path: "/", - expires: 9999, + expires: COOKIE_EXPIRY_DAYS, }); } else { - removeCookie(cookieName, { path: "/", expires: 1 }); + removeCookie(cookieName, { path: "/" }); + } +} + +export function extendColorSchemeCookies() { + for (const cookieName of [ + COLOR_SCHEME_COOKIE_NAME, + DARK_SCHEME_COOKIE_NAME, + ]) { + const currentValue = cookie(cookieName); + if (currentValue) { + cookie(cookieName, currentValue, { + path: "/", + expires: COOKIE_EXPIRY_DAYS, + }); + } } } diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 3fa4fcf197..fc20f67150 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -225,7 +225,15 @@ class PluginApi { if (canModify(klass, "member", resolverName, changes)) { delete changes.pluginId; - klass.class.reopen(changes); + + if (klass.class.reopen) { + klass.class.reopen(changes); + } else { + Object.defineProperties( + klass.class.prototype || klass.class, + Object.getOwnPropertyDescriptors(changes) + ); + } } return klass; diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-section-link.js index 5de827a551..750c83fcd5 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-section-link.js @@ -9,6 +9,13 @@ export default class BaseCustomSidebarSectionLink { this._notImplemented(); } + /** + * @returns {string} The classnames of the section link. + */ + get classNames() { + return ""; + } + /** * @returns {string} The Ember route which the section link should link to. */ diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/everything-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/everything-section-link.js index 197c2f9669..08953f85c2 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/everything-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/everything-section-link.js @@ -7,6 +7,8 @@ import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sideb export default class EverythingSectionLink extends BaseSectionLink { @tracked totalUnread = 0; @tracked totalNew = 0; + @tracked hideCount = + this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; constructor() { super(...arguments); @@ -50,6 +52,9 @@ export default class EverythingSectionLink extends BaseSectionLink { } get badgeText() { + if (this.hideCount) { + return; + } if (this.totalUnread > 0) { return I18n.t("sidebar.unread_count", { count: this.totalUnread, @@ -78,4 +83,18 @@ export default class EverythingSectionLink extends BaseSectionLink { get prefixValue() { return "layer-group"; } + + get suffixCSSClass() { + return "unread"; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + if (this.hideCount && (this.totalUnread || this.totalNew)) { + return "circle"; + } + } } diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/helpers.js b/app/assets/javascripts/discourse/app/lib/sidebar/helpers.js index d7dd1e6014..e6401c442f 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/helpers.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/helpers.js @@ -1,7 +1,9 @@ -export function canDisplayCategory(category, siteSettings) { +import Category from "discourse/models/category"; + +export function canDisplayCategory(categoryId, siteSettings) { if (siteSettings.allow_uncategorized_topics) { return true; } - return !category.isUncategorizedCategory; + return !Category.isUncategorized(categoryId); } diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js index 5057dc399a..e855089d0a 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/categories-section/category-section-link.js @@ -9,6 +9,8 @@ import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sideb export default class CategorySectionLink { @tracked totalUnread = 0; @tracked totalNew = 0; + @tracked hideCount = + this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; constructor({ category, topicTrackingState, currentUser }) { this.category = category; @@ -69,6 +71,9 @@ export default class CategorySectionLink { } get badgeText() { + if (this.hideCount) { + return; + } if (this.totalUnread > 0) { return I18n.t("sidebar.unread_count", { count: this.totalUnread, @@ -91,4 +96,18 @@ export default class CategorySectionLink { } return "discovery.category"; } + + get suffixCSSClass() { + return "unread"; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + if (this.hideCount && (this.totalUnread || this.totalNew)) { + return "circle"; + } + } } diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/my-posts-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/my-posts-section-link.js index 9c11a9722b..01544b54d9 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/my-posts-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/my-posts-section-link.js @@ -2,11 +2,14 @@ import I18n from "I18n"; import { tracked } from "@glimmer/tracking"; import BaseSectionLink from "discourse/lib/sidebar/base-community-section-link"; +import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sidebar"; const USER_DRAFTS_CHANGED_EVENT = "user-drafts:changed"; export default class MyPostsSectionLink extends BaseSectionLink { @tracked draftCount = this.currentUser.draft_count; + @tracked hideCount = + this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; constructor() { super(...arguments); @@ -52,7 +55,7 @@ export default class MyPostsSectionLink extends BaseSectionLink { } get badgeText() { - if (this._hasDraft) { + if (this._hasDraft && !this.hideCount) { return I18n.t("sidebar.sections.community.links.my_posts.draft_count", { count: this.draftCount, }); @@ -66,4 +69,18 @@ export default class MyPostsSectionLink extends BaseSectionLink { get prefixValue() { return "user"; } + + get suffixCSSClass() { + return "unread"; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + if (this._hasDraft && this.hideCount) { + return "circle"; + } + } } diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/tracked-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/tracked-section-link.js index 87d7d49f0d..8eaeb47129 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/tracked-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/tracked-section-link.js @@ -8,6 +8,8 @@ import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sideb export default class TrackedSectionLink extends BaseSectionLink { @tracked totalUnread = 0; @tracked totalNew = 0; + @tracked hideCount = + this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; constructor() { super(...arguments); @@ -51,6 +53,9 @@ export default class TrackedSectionLink extends BaseSectionLink { } get badgeText() { + if (this.hideCount) { + return; + } if (this.totalUnread > 0) { return I18n.t("sidebar.unread_count", { count: this.totalUnread, @@ -79,4 +84,18 @@ export default class TrackedSectionLink extends BaseSectionLink { get prefixValue() { return "bell"; } + + get suffixCSSClass() { + return "unread"; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + if (this.hideCount && (this.totalUnread || this.totalNew)) { + return "circle"; + } + } } diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/tags-section/tag-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/tags-section/tag-section-link.js index 1d46de97fd..3d3c4121ba 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/user/tags-section/tag-section-link.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/tags-section/tag-section-link.js @@ -9,6 +9,8 @@ import { UNREAD_LIST_DESTINATION } from "discourse/controllers/preferences/sideb export default class TagSectionLink extends BaseTagSectionLink { @tracked totalUnread = 0; @tracked totalNew = 0; + @tracked hideCount = + this.currentUser?.sidebarListDestination !== UNREAD_LIST_DESTINATION; constructor({ topicTrackingState, currentUser }) { super(...arguments); @@ -51,6 +53,9 @@ export default class TagSectionLink extends BaseTagSectionLink { } get badgeText() { + if (this.hideCount) { + return; + } if (this.totalUnread > 0) { return I18n.t("sidebar.unread_count", { count: this.totalUnread, @@ -61,4 +66,18 @@ export default class TagSectionLink extends BaseTagSectionLink { }); } } + + get suffixCSSClass() { + return "unread"; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + if (this.hideCount && (this.totalUnread || this.totalNew)) { + return "circle"; + } + } } diff --git a/app/assets/javascripts/discourse/app/lib/theme-selector.js b/app/assets/javascripts/discourse/app/lib/theme-selector.js index 7d6ca354cc..58d4d41fdc 100644 --- a/app/assets/javascripts/discourse/app/lib/theme-selector.js +++ b/app/assets/javascripts/discourse/app/lib/theme-selector.js @@ -3,6 +3,8 @@ import I18n from "I18n"; import deprecated from "discourse-common/lib/deprecated"; const keySelector = "meta[name=discourse_theme_id]"; +const COOKIE_NAME = "theme_ids"; +const COOKIE_EXPIRY_DAYS = 365; export function currentThemeKey() { // eslint-disable-next-line no-console @@ -35,12 +37,22 @@ export function currentThemeId() { export function setLocalTheme(ids, themeSeq) { ids = ids.reject((id) => !id); if (ids && ids.length > 0) { - cookie("theme_ids", `${ids.join(",")}|${themeSeq}`, { + cookie(COOKIE_NAME, `${ids.join(",")}|${themeSeq}`, { path: "/", - expires: 9999, + expires: COOKIE_EXPIRY_DAYS, }); } else { - removeCookie("theme_ids", { path: "/", expires: 1 }); + removeCookie(COOKIE_NAME, { path: "/" }); + } +} + +export function extendThemeCookie() { + const currentValue = cookie(COOKIE_NAME); + if (currentValue) { + cookie(COOKIE_NAME, currentValue, { + path: "/", + expires: COOKIE_EXPIRY_DAYS, + }); } } diff --git a/app/assets/javascripts/discourse/app/lib/url.js b/app/assets/javascripts/discourse/app/lib/url.js index 1d979f4c56..622911c417 100644 --- a/app/assets/javascripts/discourse/app/lib/url.js +++ b/app/assets/javascripts/discourse/app/lib/url.js @@ -117,15 +117,6 @@ const DiscourseURL = EmberObject.extend({ if (!holder) { selector = holderId; - - if ( - document.getElementsByClassName( - `topic-post-visited-line post-${postNumber - 1}` - )?.length === 1 - ) { - selector = ".small-action.topic-post-visited"; - } - holder = document.querySelector(selector); } diff --git a/app/assets/javascripts/discourse/app/lib/popup.js b/app/assets/javascripts/discourse/app/lib/user-tips.js similarity index 75% rename from app/assets/javascripts/discourse/app/lib/popup.js rename to app/assets/javascripts/discourse/app/lib/user-tips.js index bbd110309e..3eefac3bde 100644 --- a/app/assets/javascripts/discourse/app/lib/popup.js +++ b/app/assets/javascripts/discourse/app/lib/user-tips.js @@ -6,8 +6,8 @@ import tippy from "tippy.js"; const instances = {}; const queue = []; -export function showPopup(options) { - hidePopup(options.id); +export function showUserTip(options) { + hideUserTip(options.id); if (!options.reference) { return; @@ -23,7 +23,7 @@ export function showPopup(options) { showOnCreate: true, hideOnClick: false, trigger: "manual", - theme: "d-onboarding", + theme: "user-tips", // It must be interactive to make buttons work. interactive: true, @@ -40,17 +40,15 @@ export function showPopup(options) { allowHTML: true, content: ` -
    -
    ${escape(options.titleText)}
    -
    ${escape( - options.contentText - )}
    -
    +
    +
    ${escape(options.titleText)}
    +
    ${escape(options.contentText)}
    +
    `, @@ -73,12 +71,12 @@ export function showPopup(options) { }); } -export function hidePopup(popupId) { - const instance = instances[popupId]; +export function hideUserTip(userTipId) { + const instance = instances[userTipId]; if (instance && !instance.state.isDestroyed) { instance.destroy(); } - delete instances[popupId]; + delete instances[userTipId]; } function addToQueue(options) { @@ -92,9 +90,9 @@ function addToQueue(options) { queue.push(options); } -export function showNextPopup() { +export function showNextUserTip() { const options = queue.shift(); if (options) { - showPopup(options); + showUserTip(options); } } diff --git a/app/assets/javascripts/discourse/app/mixins/card-contents-base.js b/app/assets/javascripts/discourse/app/mixins/card-contents-base.js index 2489a28f7a..74f173791d 100644 --- a/app/assets/javascripts/discourse/app/mixins/card-contents-base.js +++ b/app/assets/javascripts/discourse/app/mixins/card-contents-base.js @@ -280,10 +280,12 @@ export default Mixin.create({ // note: we DO NOT use afterRender here cause _positionCard may // run afterwards, if we allowed this to happen the usercard // may be offscreen and we may scroll all the way to it on focus - discourseLater(() => { - const firstLink = this.element.querySelector("a"); - firstLink && firstLink.focus(); - }, 350); + if (event.pointerId === -1) { + discourseLater(() => { + const firstLink = this.element.querySelector("a"); + firstLink && firstLink.focus(); + }, 350); + } } }); }, diff --git a/app/assets/javascripts/discourse/app/models/category.js b/app/assets/javascripts/discourse/app/models/category.js index 2453ffc140..47232142ec 100644 --- a/app/assets/javascripts/discourse/app/models/category.js +++ b/app/assets/javascripts/discourse/app/models/category.js @@ -337,13 +337,35 @@ const Category = RestModel.extend({ @discourseComputed("id") isUncategorizedCategory(id) { - return id === Site.currentProp("uncategorized_category_id"); + return Category.isUncategorized(id); }, }); let _uncategorized; Category.reopenClass({ + // Sort subcategories directly under parents + sortCategories(categories) { + const children = new Map(); + + categories.forEach((category) => { + const parentId = parseInt(category.parent_category_id, 10) || -1; + const group = children.get(parentId) || []; + group.pushObject(category); + + children.set(parentId, group); + }); + + const reduce = (values) => + values.flatMap((c) => [c, reduce(children.get(c.id) || [])]).flat(); + + return reduce(children.get(-1)); + }, + + isUncategorized(categoryId) { + return categoryId === Site.currentProp("uncategorized_category_id"); + }, + slugEncoded() { let siteSettings = getOwner(this).lookup("service:site-settings"); return siteSettings.slug_generation_method === "encoded"; diff --git a/app/assets/javascripts/discourse/app/models/site.js b/app/assets/javascripts/discourse/app/models/site.js index 8e9d9c8647..6b1f1619e5 100644 --- a/app/assets/javascripts/discourse/app/models/site.js +++ b/app/assets/javascripts/discourse/app/models/site.js @@ -1,6 +1,7 @@ import EmberObject, { get } from "@ember/object"; import { alias, sort } from "@ember/object/computed"; import Archetype from "discourse/models/archetype"; +import Category from "discourse/models/category"; import PostActionType from "discourse/models/post-action-type"; import PreloadStore from "discourse/lib/preload-store"; import RestModel from "discourse/models/rest"; @@ -59,27 +60,14 @@ const Site = RestModel.extend({ // Sort subcategories under parents @discourseComputed("categoriesByCount", "categories.[]") sortedCategories(categories) { - const children = new Map(); - - categories.forEach((category) => { - const parentId = parseInt(category.parent_category_id, 10) || -1; - const group = children.get(parentId) || []; - group.pushObject(category); - - children.set(parentId, group); - }); - - const reduce = (values) => - values.flatMap((c) => [c, reduce(children.get(c.id) || [])]).flat(); - - return reduce(children.get(-1)); + return Category.sortCategories(categories); }, // Returns it in the correct order, by setting @discourseComputed("categories.[]") - categoriesList() { + categoriesList(categories) { return this.siteSettings.fixed_category_positions - ? this.categories + ? categories : this.sortedCategories; }, @@ -158,7 +146,7 @@ Site.reopenClass(Singleton, { if (result.categories) { let subcatMap = {}; - result.categoriesById = {}; + result.categoriesById = new Map(); result.categories = result.categories.map((c) => { if (c.parent_category_id) { subcatMap[c.parent_category_id] = diff --git a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js index 003710decb..4fa65a43b5 100644 --- a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js +++ b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js @@ -207,7 +207,7 @@ const TopicTrackingState = EmberObject.extend({ } } - if (filterTag && !data.payload.tags.includes(filterTag)) { + if (filterTag && !data.payload.tags?.includes(filterTag)) { return; } diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 1776e9f5d1..32aa5beceb 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -270,15 +270,19 @@ const Topic = RestModel.extend({ return customUrl; } - if (highestPostNumber <= lastReadPostNumber) { - if (this.get("category.navigate_to_first_post_after_read")) { - return this.urlForPostNumber(1); - } else { - return this.urlForPostNumber(lastReadPostNumber + 1); - } - } else { - return this.urlForPostNumber(lastReadPostNumber + 1); + if ( + lastReadPostNumber >= highestPostNumber && + this.get("category.navigate_to_first_post_after_read") + ) { + return this.urlForPostNumber(1); } + + let postNumber = lastReadPostNumber + 1; + if (postNumber > highestPostNumber) { + postNumber = highestPostNumber; + } + + return this.urlForPostNumber(postNumber); }, @discourseComputed("highest_post_number", "url") diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 6be7b0398f..89ae1f2fbd 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -43,7 +43,11 @@ import Evented from "@ember/object/evented"; import { cancel } from "@ember/runloop"; import discourseLater from "discourse-common/lib/later"; import { isTesting } from "discourse-common/config/environment"; -import { hidePopup, showNextPopup, showPopup } from "discourse/lib/popup"; +import { + hideUserTip, + showNextUserTip, + showUserTip, +} from "discourse/lib/user-tips"; export const SECOND_FACTOR_METHODS = { TOTP: 1, @@ -338,18 +342,6 @@ const User = RestModel.extend({ }, sidebarTagNames: mapBy("sidebarTags", "name"), - - @discourseComputed("sidebar_category_ids.[]") - sidebarCategories(sidebarCategoryIds) { - if (!sidebarCategoryIds || sidebarCategoryIds.length === 0) { - return []; - } - - return Site.current().categoriesList.filter((category) => - sidebarCategoryIds.includes(category.id) - ); - }, - sidebarListDestination: readOnly("sidebar_list_destination"), changeUsername(new_username) { @@ -1102,57 +1094,68 @@ const User = RestModel.extend({ return [...trackedTags, ...watchedTags, ...watchingFirstPostTags]; }, - showPopup(options) { - const popupTypes = Site.currentProp("onboarding_popup_types"); - if (!popupTypes[options.id]) { + showUserTip(options) { + const userTips = Site.currentProp("user_tips"); + if (!userTips || this.skip_new_user_tips) { + return; + } + + if (!userTips[options.id]) { // eslint-disable-next-line no-console - console.warn("Cannot display popup with type =", options.id); + console.warn("Cannot show user tip with type =", options.id); return; } - const seenPopups = this.seen_popups || []; - if (seenPopups.includes(popupTypes[options.id])) { + const seenUserTips = this.seen_popups || []; + if ( + seenUserTips.includes(-1) || + seenUserTips.includes(userTips[options.id]) + ) { return; } - showPopup({ + showUserTip({ ...options, - onDismiss: () => this.hidePopupForever(options.id), - onDismissAll: () => this.hidePopupForever(), + onDismiss: () => this.hideUserTipForever(options.id), + onDismissAll: () => this.hideUserTipForever(), }); }, - hidePopupForever(popupId) { - // Empty popupId means all popups. - const popupTypes = Site.currentProp("onboarding_popup_types"); - if (popupId && !popupTypes[popupId]) { - // eslint-disable-next-line no-console - console.warn("Cannot hide popup with type =", popupId); + hideUserTipForever(userTipId) { + const userTips = Site.currentProp("user_tips"); + if (!userTips || this.skip_new_user_tips) { return; } - // Hide any shown popups. - let seenPopups = this.seen_popups || []; - if (popupId) { - hidePopup(popupId); - if (!seenPopups.includes(popupTypes[popupId])) { - seenPopups.push(popupTypes[popupId]); - } - } else { - Object.keys(popupTypes).forEach(hidePopup); - seenPopups = Object.values(popupTypes); + // Empty userTipId means all user tips. + if (userTipId && !userTips[userTipId]) { + // eslint-disable-next-line no-console + console.warn("Cannot hide user tip with type =", userTipId); + return; } - // Show next popup in queue. - showNextPopup(); + // Hide any shown user tips. + let seenUserTips = this.seen_popups || []; + if (userTipId) { + hideUserTip(userTipId); + if (!seenUserTips.includes(userTips[userTipId])) { + seenUserTips.push(userTips[userTipId]); + } + } else { + Object.keys(userTips).forEach(hideUserTip); + seenUserTips = [-1]; + } - // Save seen popups on the server. + // Show next user tip in queue. + showNextUserTip(); + + // Save seen user tips on the server. if (!this.user_option) { this.set("user_option", {}); } - this.set("seen_popups", seenPopups); - this.set("user_option.seen_popups", seenPopups); - if (popupId) { + this.set("seen_popups", seenUserTips); + this.set("user_option.seen_popups", seenUserTips); + if (userTipId) { return this.save(["seen_popups"]); } else { this.set("skip_new_user_tips", true); diff --git a/app/assets/javascripts/discourse/app/routes/preferences-sidebar.js b/app/assets/javascripts/discourse/app/routes/preferences-sidebar.js index 7dbfb121eb..a82ac9a581 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences-sidebar.js +++ b/app/assets/javascripts/discourse/app/routes/preferences-sidebar.js @@ -1,4 +1,5 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; +import Category from "discourse/models/category"; export default RestrictedUserRoute.extend({ showFooter: true, @@ -6,7 +7,7 @@ export default RestrictedUserRoute.extend({ setupController(controller, user) { const props = { model: user, - selectedSidebarCategories: user.sidebarCategories, + selectedSidebarCategories: Category.findByIds(user.sidebarCategoryIds), }; if (this.siteSettings.tagging_enabled) { diff --git a/app/assets/javascripts/discourse/app/templates/bulk-tag.hbs b/app/assets/javascripts/discourse/app/templates/bulk-tag.hbs index d619aa8394..750e476f9a 100644 --- a/app/assets/javascripts/discourse/app/templates/bulk-tag.hbs +++ b/app/assets/javascripts/discourse/app/templates/bulk-tag.hbs @@ -2,4 +2,4 @@

    - + diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs index c8a02c6e56..2f9f51a47f 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs @@ -10,8 +10,7 @@ {{#unless this.showPositionInput}}
    - {{i18n "category.position_disabled"}} - {{i18n "category.position_disabled_click"}} + {{html-safe (i18n "category.position_disabled" url=(get-url "/admin/site_settings/category/all_results?filter=fixed_category_positions"))}}
    {{/unless}} diff --git a/app/assets/javascripts/discourse/app/templates/discovery.hbs b/app/assets/javascripts/discourse/app/templates/discovery.hbs index bcc1ce644e..5a51b5b4c9 100644 --- a/app/assets/javascripts/discourse/app/templates/discovery.hbs +++ b/app/assets/javascripts/discourse/app/templates/discovery.hbs @@ -18,6 +18,10 @@ +{{#if this.showEditWelcomeTopicBanner}} + +{{/if}} +
    diff --git a/app/assets/javascripts/discourse/app/templates/discovery/topics.hbs b/app/assets/javascripts/discourse/app/templates/discovery/topics.hbs index 3b6018bae9..6d934e9ad0 100644 --- a/app/assets/javascripts/discourse/app/templates/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/app/templates/discovery/topics.hbs @@ -2,10 +2,6 @@
    {{this.redirectedReason}}
    {{/if}} -{{#if this.showEditWelcomeTopicBanner}} - -{{/if}} - {{#if this.model.sharedDrafts}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/login.hbs b/app/assets/javascripts/discourse/app/templates/modal/login.hbs index d7465134af..39de9f023e 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/login.hbs @@ -26,7 +26,7 @@
    {{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}
    - + {{#if this.showSecurityKey}} diff --git a/app/assets/javascripts/discourse/app/templates/preferences/interface.hbs b/app/assets/javascripts/discourse/app/templates/preferences/interface.hbs index 91a27edf36..de0b66a35d 100644 --- a/app/assets/javascripts/discourse/app/templates/preferences/interface.hbs +++ b/app/assets/javascripts/discourse/app/templates/preferences/interface.hbs @@ -117,8 +117,8 @@
    - {{#if this.site.onboarding_popup_types}} - {{i18n "user.reset_seen_popups"}} + {{#if this.site.user_tips}} + {{i18n "user.reset_seen_user_tips"}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/tag/show.hbs b/app/assets/javascripts/discourse/app/templates/tag/show.hbs index 5d39fd087a..8df5024ab1 100644 --- a/app/assets/javascripts/discourse/app/templates/tag/show.hbs +++ b/app/assets/javascripts/discourse/app/templates/tag/show.hbs @@ -9,7 +9,7 @@
    diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 79c8195dd6..daba9abda3 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -13,7 +13,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click"; import { logSearchLinkClick } from "discourse/lib/search"; import RenderGlimmer from "discourse/widgets/render-glimmer"; import { hbs } from "ember-cli-htmlbars"; -import { hidePopup } from "discourse/lib/popup"; +import { hideUserTip } from "discourse/lib/user-tips"; let _extraHeaderIcons = []; @@ -88,7 +88,7 @@ createWidget("header-notifications", { const count = unread + reviewables; if (count > 0) { if (this._shouldHighlightAvatar()) { - if (this.siteSettings.enable_onboarding_popups) { + if (this.siteSettings.enable_user_tips) { contents.push(h("span.ring")); } else { this._addAvatarHighlight(contents); @@ -124,7 +124,7 @@ createWidget("header-notifications", { const unreadHighPriority = user.unread_high_priority_notifications; if (!!unreadHighPriority) { if (this._shouldHighlightAvatar()) { - if (this.siteSettings.enable_onboarding_popups) { + if (this.siteSettings.enable_user_tips) { contents.push(h("span.ring")); } else { this._addAvatarHighlight(contents); @@ -198,17 +198,17 @@ createWidget("header-notifications", { didRenderWidget() { if ( !this.currentUser || - !this.siteSettings.enable_onboarding_popups || + !this.siteSettings.enable_user_tips || !this._shouldHighlightAvatar() ) { return; } - this.currentUser.showPopup({ + this.currentUser.showUserTip({ id: "first_notification", - titleText: I18n.t("popup.first_notification.title"), - contentText: I18n.t("popup.first_notification.content"), + titleText: I18n.t("user_tips.first_notification.title"), + contentText: I18n.t("user_tips.first_notification.content"), reference: document .querySelector(".badge-notification") @@ -219,11 +219,11 @@ createWidget("header-notifications", { }, destroy() { - hidePopup("first_notification"); + hideUserTip("first_notification"); }, willRerenderWidget() { - hidePopup("first_notification"); + hideUserTip("first_notification"); }, }); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js index c2c546af63..56012ab317 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js @@ -367,7 +367,7 @@ export default createWidget("search-menu", { return; } - if (e.which === 65 /* a */) { + if (e.key === "A") { if (document.activeElement?.classList.contains("search-link")) { if (document.querySelector("#reply-control.open")) { // add a link and focus composer @@ -388,8 +388,8 @@ export default createWidget("search-menu", { } } - const up = e.which === 38; - const down = e.which === 40; + const up = e.key === "ArrowUp"; + const down = e.key === "ArrowDown"; if (up || down) { let focused = document.activeElement.closest(".search-menu") ? document.activeElement @@ -443,7 +443,7 @@ export default createWidget("search-menu", { } const searchInput = document.querySelector("#search-term"); - if (e.which === 13 && e.target === searchInput) { + if (e.key === "Enter" && e.target === searchInput) { const recentEnterHit = this.state._lastEnterTimestamp && Date.now() - this.state._lastEnterTimestamp < SECOND_ENTER_MAX_DELAY; @@ -463,7 +463,7 @@ export default createWidget("search-menu", { this.state._lastEnterTimestamp = Date.now(); } - if (e.target === searchInput && e.which === 8 /* backspace */) { + if (e.target === searchInput && e.key === "Backspace") { if (!searchInput.value) { this.clearTopicContext(); this.clearPMInboxContext(); diff --git a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js index 3326b683c5..8d4566757e 100644 --- a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js +++ b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js @@ -9,7 +9,7 @@ import discourseLater from "discourse-common/lib/later"; import { relativeAge } from "discourse/lib/formatter"; import renderTags from "discourse/lib/render-tags"; import renderTopicFeaturedLink from "discourse/lib/render-topic-featured-link"; -import { hidePopup } from "discourse/lib/popup"; +import { hideUserTip } from "discourse/lib/user-tips"; const SCROLLER_HEIGHT = 50; const LAST_READ_HEIGHT = 20; @@ -601,15 +601,15 @@ export default createWidget("topic-timeline", { }, didRenderWidget() { - if (!this.currentUser || !this.siteSettings.enable_onboarding_popups) { + if (!this.currentUser || !this.siteSettings.enable_user_tips) { return; } - this.currentUser.showPopup({ + this.currentUser.showUserTip({ id: "topic_timeline", - titleText: I18n.t("popup.topic_timeline.title"), - contentText: I18n.t("popup.topic_timeline.content"), + titleText: I18n.t("user_tips.topic_timeline.title"), + contentText: I18n.t("user_tips.topic_timeline.content"), reference: document.querySelector("div.timeline-scrollarea-wrapper"), @@ -618,10 +618,10 @@ export default createWidget("topic-timeline", { }, destroy() { - hidePopup("topic_timeline"); + hideUserTip("topic_timeline"); }, willRerenderWidget() { - hidePopup("topic_timeline"); + hideUserTip("topic_timeline"); }, }); diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 8c3d2f4528..dea7c1acce 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -6,6 +6,7 @@ const mergeTrees = require("broccoli-merge-trees"); const concat = require("broccoli-concat"); const prettyTextEngine = require("./lib/pretty-text-engine"); const { createI18nTree } = require("./lib/translation-plugin"); +const { parsePluginClientSettings } = require("./lib/site-settings-plugin"); const discourseScss = require("./lib/discourse-scss"); const generateScriptsTree = require("./lib/scripts"); const funnel = require("broccoli-funnel"); @@ -57,6 +58,13 @@ module.exports = function (defaults) { autoImport: { forbidEval: true, insertScriptsAt: "ember-auto-import-scripts", + webpack: { + // Workarounds for https://github.com/ef4/ember-auto-import/issues/519 and https://github.com/ef4/ember-auto-import/issues/478 + devtool: isProduction ? false : "source-map", // Sourcemaps contain reference to the ephemeral broccoli cache dir, which changes on every deploy + optimization: { + moduleIds: "size", // Consistent module references https://github.com/ef4/ember-auto-import/issues/478#issuecomment-1000526638 + }, + }, }, fingerprint: { // Handled by Rails asset pipeline @@ -161,6 +169,7 @@ module.exports = function (defaults) { return mergeTrees([ createI18nTree(discourseRoot, vendorJs), + parsePluginClientSettings(discourseRoot, vendorJs, app), app.toTree(), funnel(`${discourseRoot}/public/javascripts`, { destDir: "javascripts" }), funnel(`${vendorJs}/highlightjs`, { diff --git a/app/assets/javascripts/discourse/lib/dialog-holder/yarn.lock b/app/assets/javascripts/discourse/lib/dialog-holder/yarn.lock index d81a3899f1..5a8cb1a0f8 100644 --- a/app/assets/javascripts/discourse/lib/dialog-holder/yarn.lock +++ b/app/assets/javascripts/discourse/lib/dialog-holder/yarn.lock @@ -2651,9 +2651,9 @@ loader-runner@^4.2.0: integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== loader-utils@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129" - integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== + version "2.0.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.3.tgz#d4b15b8504c63d1fc3f2ade52d41bc8459d6ede1" + integrity sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" diff --git a/app/assets/javascripts/discourse/lib/site-settings-plugin.js b/app/assets/javascripts/discourse/lib/site-settings-plugin.js new file mode 100644 index 0000000000..c576eee53d --- /dev/null +++ b/app/assets/javascripts/discourse/lib/site-settings-plugin.js @@ -0,0 +1,96 @@ +const Plugin = require("broccoli-plugin"); +const Yaml = require("js-yaml"); +const fs = require("fs"); +const concat = require("broccoli-concat"); +const mergeTrees = require("broccoli-merge-trees"); +const deepmerge = require("deepmerge"); +const glob = require("glob"); +const { shouldLoadPluginTestJs } = require("discourse/lib/plugin-js"); + +let built = false; + +class SiteSettingsPlugin extends Plugin { + constructor(inputNodes, inputFile, options) { + super(inputNodes, { + ...options, + persistentOutput: true, + }); + } + + build() { + if (built) { + return; + } + + let parsed = {}; + + this.inputPaths.forEach((path) => { + let inputFile; + if (path.includes("plugins")) { + inputFile = "settings.yml"; + } else { + inputFile = "site_settings.yml"; + } + const file = path + "/" + inputFile; + let yaml; + try { + yaml = fs.readFileSync(file, { encoding: "UTF-8" }); + } catch (err) { + // the plugin does not have a config file, go to the next file + return; + } + const loaded = Yaml.load(yaml, { json: true }); + parsed = deepmerge(parsed, loaded); + }); + + let clientSettings = {}; + // eslint-disable-next-line no-unused-vars + for (const [category, settings] of Object.entries(parsed)) { + for (const [setting, details] of Object.entries(settings)) { + if (details.client) { + clientSettings[setting] = details.default; + } + } + } + const contents = `var CLIENT_SITE_SETTINGS_WITH_DEFAULTS = ${JSON.stringify( + clientSettings + )}`; + + fs.writeFileSync(`${this.outputPath}/` + "settings_out.js", contents); + built = true; + } +} + +module.exports = function siteSettingsPlugin(...params) { + return new SiteSettingsPlugin(...params); +}; + +module.exports.parsePluginClientSettings = function ( + discourseRoot, + vendorJs, + app +) { + let settings = [discourseRoot + "/config"]; + + if (shouldLoadPluginTestJs()) { + const pluginInfos = app.project + .findAddonByName("discourse-plugins") + .pluginInfos(); + pluginInfos.forEach(({ hasConfig, configDirectory }) => { + if (hasConfig) { + settings = settings.concat(glob.sync(configDirectory)); + } + }); + } + + const loadedSettings = new SiteSettingsPlugin(settings, "site_settings.yml"); + + return concat(mergeTrees([loadedSettings]), { + inputFiles: [], + headerFiles: [], + footerFiles: [], + outputFile: `assets/test-site-settings.js`, + }); +}; + +module.exports.SiteSettingsPlugin = SiteSettingsPlugin; diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 9eb859ce6e..bfdcb27de2 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -16,8 +16,8 @@ "test": "ember test" }, "dependencies": { - "@babel/core": "^7.19.6", - "@babel/standalone": "^7.20.0", + "@babel/core": "^7.20.2", + "@babel/standalone": "^7.20.4", "@discourse/itsatrap": "^2.0.10", "@discourse/backburner.js": "^2.7.1-0", "@ember/jquery": "^2.0.0", @@ -66,7 +66,7 @@ "ember-on-resize-modifier": "^1.1.0", "ember-qunit": "^5.1.5", "ember-rfc176-data": "^0.3.17", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-test-selectors": "^6.0.0", "eslint": "^8.26.0", "eslint-plugin-qunit": "^6.2.0", @@ -77,14 +77,14 @@ "markdown-it": "^13.0.1", "message-bus-client": "^4.2.0", "messageformat": "0.1.5", - "node-fetch": "^2.6.6", + "node-fetch": "^2.6.7", "pretender": "^3.4.7", "pretty-text": "^1.0.0", "qunit": "^2.19.3", "qunit-dom": "^2.0.0", - "sass": "^1.55.0", + "sass": "^1.56.0", "select-kit": "^1.0.0", - "sinon": "^14.0.1", + "sinon": "^14.0.2", "tippy.js": "^6.3.7", "virtual-dom": "^2.1.1", "webpack": "^5.74.0", diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-permalink-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-permalink-test.js new file mode 100644 index 0000000000..bf9fb447fd --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-permalink-test.js @@ -0,0 +1,56 @@ +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("Admin - Permalinks", function (needs) { + const startingData = [ + { + id: 38, + url: "c/feature/announcements", + topic_id: null, + topic_title: null, + topic_url: null, + post_id: null, + post_url: null, + post_number: null, + post_topic_title: null, + category_id: 67, + category_name: "announcements", + category_url: "/c/announcements/67", + external_url: null, + tag_id: null, + tag_name: null, + tag_url: null, + }, + ]; + + needs.user(); + needs.pretender((server, helper) => { + server.get("/admin/permalinks.json", (response) => { + const result = + response.queryParams.filter !== "feature" ? [] : startingData; + return helper.response(200, result); + }); + }); + + test("search permalinks with result", async function (assert) { + await visit("/admin/customize/permalinks"); + await fillIn(".permalink-search input", "feature"); + assert.ok( + exists(".permalink-results span[title='c/feature/announcements']"), + "permalink is found after search" + ); + }); + + test("search permalinks without results", async function (assert) { + await visit("/admin/customize/permalinks"); + await fillIn(".permalink-search input", "garboogle"); + + assert.ok( + exists(".permalink-results__no-result"), + "no results message shown" + ); + + assert.ok(exists(".permalink-search"), "search input still visible"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-plugins-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-plugins-test.js new file mode 100644 index 0000000000..61ca9fafc3 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-plugins-test.js @@ -0,0 +1,51 @@ +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("Admin - Plugins", function (needs) { + needs.user(); + + needs.pretender((server, helper) => { + server.get("/admin/plugins", () => + helper.response({ + plugins: [ + { + id: "some-test-plugin", + name: "some-test-plugin", + about: "Plugin description", + version: "0.1", + url: "https://example.com", + admin_route: { + location: "testlocation", + label: "test.plugin.label", + full_location: "adminPlugins.testlocation", + }, + enabled: true, + enabled_setting: "testplugin_enabled", + has_settings: true, + is_official: true, + }, + ], + }) + ); + }); + + test("shows plugin list", async function (assert) { + await visit("/admin/plugins"); + const table = query("table.admin-plugins"); + assert.strictEqual( + table.querySelector("tr .plugin-name .name").innerText, + "some-test-plugin", + "displays the plugin in the table" + ); + + assert.true( + exists(".admin-plugins .admin-detail .alert-error"), + "displays an error for unknown routes" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js index 37cb1c8797..532e79904b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js @@ -75,11 +75,21 @@ acceptance("Admin - Watched Words", function (needs) { test("add case-sensitive words", async function (assert) { await visit("/admin/customize/watched_words/action/block"); - + const submitButton = query(".watched-word-form button"); + assert.strictEqual( + submitButton.disabled, + true, + "Add button is disabled by default" + ); await click(".show-words-checkbox"); await fillIn(".watched-word-form input", "Discourse"); await click(".case-sensitivity-checkbox"); - await click(".watched-word-form button"); + assert.strictEqual( + submitButton.disabled, + false, + "Add button should no longer be disabled after input is filled" + ); + await click(submitButton); assert .dom(".watched-words-list .watched-word") @@ -87,7 +97,7 @@ acceptance("Admin - Watched Words", function (needs) { await fillIn(".watched-word-form input", "discourse"); await click(".case-sensitivity-checkbox"); - await click(".watched-word-form button"); + await click(submitButton); assert .dom(".watched-words-list .watched-word") diff --git a/app/assets/javascripts/discourse/tests/acceptance/bootstrap-mode-notice-test.js b/app/assets/javascripts/discourse/tests/acceptance/bootstrap-mode-notice-test.js index 9d8812ccf3..de3c793294 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bootstrap-mode-notice-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bootstrap-mode-notice-test.js @@ -1,10 +1,9 @@ import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import { click, currentURL, settled, visit } from "@ember/test-helpers"; -import { set } from "@ember/object"; acceptance("Bootstrap Mode Notice", function (needs) { - needs.user(); + needs.user({ admin: true }); needs.site({ wizard_required: true }); needs.settings({ bootstrap_mode_enabled: true, @@ -36,8 +35,8 @@ acceptance("Bootstrap Mode Notice", function (needs) { "it transitions to the wizard page" ); + this.siteSettings.bootstrap_mode_enabled = false; await visit("/"); - set(this.siteSettings, "bootstrap_mode_enabled", false); await settled(); assert.ok( !exists(".bootstrap-mode-notice"), diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js index 2f1359d4d3..72a984ef66 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js @@ -24,7 +24,7 @@ acceptance("Composer Actions", function (needs) { }); needs.settings({ prioritize_username_in_ux: true, - display_name_on_post: false, + display_name_on_posts: false, enable_whispers: true, }); needs.site({ can_tag_topics: true }); @@ -489,7 +489,7 @@ acceptance("Prioritize Username", function (needs) { needs.user(); needs.settings({ prioritize_username_in_ux: true, - display_name_on_post: false, + display_name_on_posts: false, }); test("Reply to post use username", async function (assert) { @@ -517,7 +517,7 @@ acceptance("Prioritize Full Name", function (needs) { needs.user(); needs.settings({ prioritize_username_in_ux: false, - display_name_on_post: true, + display_name_on_posts: true, }); test("Reply to post use full name", async function (assert) { @@ -555,7 +555,7 @@ acceptance("Prioritizing Name fall back", function (needs) { needs.user(); needs.settings({ prioritize_username_in_ux: false, - display_name_on_post: true, + display_name_on_posts: true, }); test("Quotes fall back to username if name is not present", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-editor-mentions-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-editor-mentions-test.js index 6329a25769..29528b624a 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-editor-mentions-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-editor-mentions-test.js @@ -18,7 +18,7 @@ acceptance("Composer - editor mentions", function (needs) { }; needs.user(); - needs.settings({ enable_mentions: true }); + needs.settings({ enable_mentions: true, allow_uncategorized_topics: true }); needs.hooks.afterEach(() => { if (clock) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js index 4c730d62f5..75103a3c9b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js @@ -11,7 +11,7 @@ import { test } from "qunit"; acceptance("Composer - Image Preview", function (needs) { needs.user(); - needs.settings({ enable_whispers: true }); + needs.settings({ enable_whispers: true, allow_uncategorized_topics: true }); needs.site({ can_tag_topics: true }); needs.pretender((server, helper) => { server.post("/uploads/lookup-urls", () => { diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-tags-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-tags-test.js index fcea997085..07a290a7d5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-tags-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-tags-test.js @@ -18,6 +18,7 @@ acceptance("Composer - Tags", function (needs) { }); }); needs.site({ can_tag_topics: true }); + needs.settings({ allow_uncategorized_topics: true }); test("staff bypass tag validation rule", async function (assert) { await visit("/"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index fe74e72aa7..a86afe5d08 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -302,7 +302,7 @@ acceptance("Composer", function (needs) { await visit("/"); await click("#create-topic"); await fillIn("#reply-title", "This title doesn't matter"); - await fillIn(".d-editor-input", "custom message"); + await fillIn(".d-editor-input", "custom message that is a good length"); await click("#reply-control button.create"); assert.strictEqual( @@ -1107,6 +1107,7 @@ acceptance("Composer - Customizations", function (needs) { acceptance("Composer - Focus Open and Closed", function (needs) { needs.user(); + needs.settings({ allow_uncategorized_topics: true }); test("Focusing a composer which is not open with create topic", async function (assert) { await visit("/t/internationalization-localization/280"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js index 2f62ab4cad..8e4a3b5c82 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js @@ -81,6 +81,7 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) { needs.settings({ simultaneous_uploads: 2, enable_rich_text_paste: true, + allow_uncategorized_topics: true, }); needs.hooks.afterEach(() => { uploadNumber = 1; @@ -487,6 +488,7 @@ acceptance("Uppy Composer Attachment - Upload Error", function (needs) { }); needs.settings({ simultaneous_uploads: 2, + allow_uncategorized_topics: true, }); test("should show an error message for the failed upload", async function (assert) { @@ -527,6 +529,7 @@ acceptance("Uppy Composer Attachment - Upload Handler", function (needs) { needs.pretender(pretender); needs.settings({ simultaneous_uploads: 2, + allow_uncategorized_topics: true, }); needs.hooks.beforeEach(() => { withPluginApi("0.8.14", (api) => { diff --git a/app/assets/javascripts/discourse/tests/acceptance/emoji-test.js b/app/assets/javascripts/discourse/tests/acceptance/emoji-test.js index 9aabd553ea..1b4aeb8e75 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/emoji-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/emoji-test.js @@ -21,7 +21,7 @@ acceptance("Emoji", function (needs) { assert.strictEqual( normalizeHtml(query(".d-editor-preview").innerHTML.trim()), normalizeHtml( - `

    this is an emoji :blonde_woman:

    ` + `

    this is an emoji :blonde_woman:

    ` ) ); }); @@ -36,7 +36,7 @@ acceptance("Emoji", function (needs) { assert.strictEqual( normalizeHtml(query(".d-editor-preview").innerHTML.trim()), normalizeHtml( - `

    this is an emoji :blonde_woman:t5:

    ` + `

    this is an emoji :blonde_woman:t5:

    ` ) ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js index 502be77b6d..abbde3f603 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js @@ -94,7 +94,7 @@ acceptance("Group Requests", function (needs) { query(".group-members tr:first-child td:nth-child(1)") .innerText.trim() .replace(/\s+/g, " "), - "Robin Ward eviltrout" + "eviltrout Robin Ward" ); assert.strictEqual( query(".group-members tr:first-child td:nth-child(3)").innerText.trim(), diff --git a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js index 982f854be3..e3f0360760 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js @@ -123,7 +123,7 @@ acceptance("Invite accept", function (needs) { "submit is disabled because password is not filled" ); - await fillIn("#new-account-password", "top$ecret"); + await fillIn("#new-account-password", "top$ecretzz"); assert.notOk( exists(".invites-show .btn-primary:disabled"), "submit is enabled" diff --git a/app/assets/javascripts/discourse/tests/acceptance/personal-message-test.js b/app/assets/javascripts/discourse/tests/acceptance/personal-message-test.js index c75513b786..82867e7220 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/personal-message-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/personal-message-test.js @@ -1,8 +1,4 @@ -import { - acceptance, - exists, - query, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; import { test } from "qunit"; import { visit } from "@ember/test-helpers"; @@ -10,15 +6,6 @@ import { visit } from "@ember/test-helpers"; acceptance("Personal Message", function (needs) { needs.user(); - test("footer edit button", async function (assert) { - await visit("/t/pm-for-testing/12"); - - assert.ok( - !exists(".edit-message"), - "does not show edit first post button on footer by default" - ); - }); - test("suggested messages", async function (assert) { await visit("/t/pm-for-testing/12"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js index 19e6c5d401..712a07fa13 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js @@ -18,7 +18,7 @@ acceptance("Plugin Outlet - Connector Class", function (needs) { extraConnectorClass("user-profile-primary/hello", { actions: { sayHello() { - this.set("hello", "hello!"); + this.set("hello", `${this.hello || ""}hello!`); }, }, }); @@ -53,6 +53,7 @@ acceptance("Plugin Outlet - Connector Class", function (needs) { `${PREFIX}/user-profile-primary/hello` ] = hbs`{{model.username}} + {{hello}}`; Ember.TEMPLATES[ `${PREFIX}/user-profile-primary/hi` @@ -87,6 +88,12 @@ acceptance("Plugin Outlet - Connector Class", function (needs) { "hello!", "actions delegate properly" ); + await click(".say-hello-using-this"); + assert.strictEqual( + query(".hello-result").innerText, + "hello!hello!", + "actions are made available on `this` and are bound correctly" + ); await click(".say-hi"); assert.strictEqual( diff --git a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js index 27f91bf1e3..38891cc8e9 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js @@ -128,6 +128,9 @@ acceptance("User Preferences", function (needs) { await categorySelector.fillInFilter("faq"); await savePreferences(); + this.siteSettings.tagging_enabled = false; + await visit("/"); + await visit("/u/eviltrout/preferences"); assert.ok( !exists(".preferences-nav .nav-tags a"), "tags tab isn't there when tags are disabled" diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index 751623da21..321d6cf107 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -4,7 +4,13 @@ import { exists, query, } from "discourse/tests/helpers/qunit-helpers"; -import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; +import { + click, + fillIn, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; import I18n from "I18n"; import searchFixtures from "discourse/tests/fixtures/search-fixtures"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -333,7 +339,10 @@ acceptance("Search - Anonymous", function (needs) { acceptance("Search - Authenticated", function (needs) { needs.user(); - needs.settings({ log_search_queries: true }); + needs.settings({ + log_search_queries: true, + allow_uncategorized_topics: true, + }); needs.pretender((server, helper) => { server.get("/search/query", (request) => { @@ -476,6 +485,7 @@ acceptance("Search - Authenticated", function (needs) { "href" ); await triggerKeyEvent(".search-menu", "keydown", "A"); + await settled(); assert.strictEqual( query("#reply-control textarea").value, diff --git a/app/assets/javascripts/discourse/tests/acceptance/second-factor-auth-test.js b/app/assets/javascripts/discourse/tests/acceptance/second-factor-auth-test.js index 32b57a7e6d..0d8c4cc0c6 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/second-factor-auth-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/second-factor-auth-test.js @@ -44,6 +44,12 @@ const RESPONSES = { security_keys_enabled: true, allowed_methods: [BACKUP_CODE], }, + ok010010: { + totp_enabled: false, + backup_enabled: true, + security_keys_enabled: false, + allowed_methods: [BACKUP_CODE], + }, }; Object.keys(RESPONSES).forEach((k) => { @@ -178,6 +184,14 @@ acceptance("Second Factor Auth Page", function (needs) { !exists(".toggle-second-factor-method"), "no alternative methods are shown if only 1 method is allowed" ); + + // only backup codes + await visit("/session/2fa?nonce=ok010010"); + assert.ok(exists("form.backup-code-token"), "backup code form is shown"); + assert.ok( + !exists(".toggle-second-factor-method"), + "no alternative methods are shown if only 1 method is allowed" + ); }); test("switching 2FA methods", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-anonymous-categories-section-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-anonymous-categories-section-test.js index ef1a442676..9ec6b59826 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-anonymous-categories-section-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-anonymous-categories-section-test.js @@ -7,24 +7,51 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import Site from "discourse/models/site"; -acceptance("Sidebar - Anonymous Categories Section", function (needs) { +acceptance("Sidebar - Anonymous - Categories Section", function (needs) { needs.settings({ enable_experimental_sidebar_hamburger: true, enable_sidebar: true, }); - test("category section links", async function (assert) { + test("category section links ordered by category's topic count when default_sidebar_categories has not been configured and site setting to fix categories positions is disabled", async function (assert) { + this.siteSettings.fixed_category_positions = false; + await visit("/"); - const categories = queryAll( + const categorySectionLinks = queryAll( ".sidebar-section-categories .sidebar-section-link-wrapper" ); - assert.strictEqual(categories.length, 6); - assert.strictEqual(categories[0].textContent.trim(), "bug"); - assert.strictEqual(categories[1].textContent.trim(), "dev"); - assert.strictEqual(categories[2].textContent.trim(), "feature"); - assert.strictEqual(categories[3].textContent.trim(), "support"); - assert.strictEqual(categories[4].textContent.trim(), "ux"); + + const sidebarCategories = Site.current() + .categories.filter((category) => !category.parent_category_id) + .sort((a, b) => b.topic_count - a.topic_count); + + assert.strictEqual(categorySectionLinks.length, 6); + + assert.strictEqual( + categorySectionLinks[0].textContent.trim(), + sidebarCategories[0].name + ); + + assert.strictEqual( + categorySectionLinks[1].textContent.trim(), + sidebarCategories[1].name + ); + + assert.strictEqual( + categorySectionLinks[2].textContent.trim(), + sidebarCategories[2].name + ); + + assert.strictEqual( + categorySectionLinks[3].textContent.trim(), + sidebarCategories[3].name + ); + + assert.strictEqual( + categorySectionLinks[4].textContent.trim(), + sidebarCategories[4].name + ); assert.ok( exists("a.sidebar-section-link-all-categories"), @@ -32,8 +59,54 @@ acceptance("Sidebar - Anonymous Categories Section", function (needs) { ); }); - test("category section links in sidebar when default_sidebar_categories site setting has been configured", async function (assert) { - this.siteSettings.default_sidebar_categories = "3|13|1"; + test("category section links ordered by default category's position when default_sidebar_categories has not been configured and site setting to fix categories positions is enabled", async function (assert) { + this.siteSettings.fixed_category_positions = true; + + await visit("/"); + + const categories = queryAll( + ".sidebar-section-categories .sidebar-section-link-wrapper" + ); + + const siteCategories = Site.current().categories; + + assert.strictEqual(categories.length, 6); + + assert.strictEqual( + categories[0].textContent.trim(), + siteCategories[0].name + ); + + assert.strictEqual( + categories[1].textContent.trim(), + siteCategories[1].name + ); + + assert.strictEqual( + categories[2].textContent.trim(), + siteCategories[3].name + ); + + assert.strictEqual( + categories[3].textContent.trim(), + siteCategories[4].name + ); + + assert.strictEqual( + categories[4].textContent.trim(), + siteCategories[5].name + ); + + assert.ok( + exists("a.sidebar-section-link-all-categories"), + "all categories link is visible" + ); + }); + + test("category section links in sidebar when default_sidebar_categories site setting has been configured and site setting to fix category position is enabled", async function (assert) { + this.siteSettings.fixed_category_positions = true; + this.siteSettings.default_sidebar_categories = "1|3|13"; + await visit("/"); const categories = queryAll( @@ -41,9 +114,9 @@ acceptance("Sidebar - Anonymous Categories Section", function (needs) { ); assert.strictEqual(categories.length, 4); - assert.strictEqual(categories[0].textContent.trim(), "blog"); - assert.strictEqual(categories[1].textContent.trim(), "bug"); - assert.strictEqual(categories[2].textContent.trim(), "meta"); + assert.strictEqual(categories[0].textContent.trim(), "meta"); + assert.strictEqual(categories[1].textContent.trim(), "blog"); + assert.strictEqual(categories[2].textContent.trim(), "bug"); assert.ok( exists("a.sidebar-section-link-all-categories"), @@ -56,7 +129,7 @@ acceptance("Sidebar - Anonymous Categories Section", function (needs) { this.siteSettings.fixed_category_positions = true; const site = Site.current(); - const firstCategory = Site.current().categories.find((category) => { + const firstCategory = site.categories.find((category) => { return !category.parent_category_id; }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js index 553e4daaea..c6c79373a4 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-plugin-api-test.js @@ -69,6 +69,10 @@ acceptance("Sidebar - Plugin API", function (needs) { return "random-channel"; } + get classNames() { + return "my-class-name"; + } + get route() { return "topic"; } @@ -243,6 +247,11 @@ acceptance("Sidebar - Plugin API", function (needs) { "displays first link with correct text" ); + assert.ok( + exists(".sidebar-section-link.my-class-name"), + "sets the custom class name for the section link" + ); + assert.strictEqual( links[0].title, "random channel title", diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-categories-section-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-categories-section-test.js index 1c4fe5086f..3b4a14cd4c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-categories-section-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-categories-section-test.js @@ -69,6 +69,7 @@ acceptance("Sidebar - Logged on user - Categories Section", function (needs) { enable_experimental_sidebar_hamburger: true, enable_sidebar: true, suppress_uncategorized_badge: false, + allow_uncategorized_topics: true, }); needs.pretender((server, helper) => { @@ -193,12 +194,45 @@ acceptance("Sidebar - Logged on user - Categories Section", function (needs) { ); }); - test("category section links are sorted by category name alphabetically", async function (assert) { - const { category1, category2, category3 } = setupUserSidebarCategories(); + test("category section links are ordered by category name with child category sorted after parent when site setting to fix category's position is disabled", async function (assert) { + this.siteSettings.fixed_category_positions = false; - category3.set("name", "aBC"); - category2.set("name", "abc"); - category1.set("name", "efg"); + const site = Site.current(); + const siteCategories = site.categories; + + siteCategories[0].parent_category_id = -1001; + siteCategories[0].id = -1000; + siteCategories[0].name = "Parent B Child A"; + + siteCategories[1].parent_category_id = null; + siteCategories[1].id = -1001; + siteCategories[1].name = "Parent B"; + + siteCategories[2].parent_category_id = null; + siteCategories[2].id = -1002; + siteCategories[2].name = "Parent A"; + + siteCategories[3].parent_category_id = -1001; + siteCategories[3].id = -1003; + siteCategories[3].name = "Parent B Child B"; + + siteCategories[4].parent_category_id = -1002; + siteCategories[4].id = -1004; + siteCategories[4].name = "Parent A Child A"; + + siteCategories[5].parent_category_id = -1000; + siteCategories[5].id = -1005; + siteCategories[5].name = "Parent B Child A Child A"; + + site.categoriesById.clear(); + + siteCategories.forEach((category) => { + site.categoriesById[category.id] = category; + }); + + updateCurrentUser({ + sidebar_category_ids: [-1005, -1004, -1003, -1002, -1000], + }); await visit("/"); @@ -212,7 +246,139 @@ acceptance("Sidebar - Logged on user - Categories Section", function (needs) { assert.deepEqual( categoryNames, - ["abc", "aBC", "efg", "Sub Category"], + [ + "Parent A", + "Parent A Child A", + "Parent B Child A", + "Parent B Child A Child A", + "Parent B Child B", + ], + "category section links are displayed in the right order" + ); + }); + + test("category section links are ordered by default order of site categories with child category sorted after parent category when site setting to fix category's position is enabled", async function (assert) { + this.siteSettings.fixed_category_positions = true; + + const site = Site.current(); + const siteCategories = site.categories; + + siteCategories[0].parent_category_id = -1001; + siteCategories[0].id = -1000; + siteCategories[0].name = "Parent A Child A"; + + siteCategories[1].parent_category_id = null; + siteCategories[1].id = -1001; + siteCategories[1].name = "Parent A"; + + siteCategories[2].parent_category_id = null; + siteCategories[2].id = -1002; + siteCategories[2].name = "Parent B"; + + siteCategories[3].parent_category_id = -1001; + siteCategories[3].id = -1003; + siteCategories[3].name = "Parent A Child B"; + + siteCategories[4].parent_category_id = -1002; + siteCategories[4].id = -1004; + siteCategories[4].name = "Parent B Child A"; + + siteCategories[5].parent_category_id = -1000; + siteCategories[5].id = -1005; + siteCategories[5].name = "Parent A Child A Child A"; + + site.categoriesById.clear(); + + siteCategories.forEach((category) => { + site.categoriesById[category.id] = category; + }); + + updateCurrentUser({ + sidebar_category_ids: [-1005, -1004, -1003, -1002, -1000], + }); + + await visit("/"); + + const categorySectionLinks = queryAll( + ".sidebar-section-categories .sidebar-section-link:not(.sidebar-section-link-all-categories)" + ); + + const categoryNames = [...categorySectionLinks].map((categorySectionLink) => + categorySectionLink.textContent.trim() + ); + + assert.deepEqual( + categoryNames, + [ + "Parent A Child A", + "Parent A Child A Child A", + "Parent A Child B", + "Parent B", + "Parent B Child A", + ], + "category section links are displayed in the right order" + ); + }); + + test("category section links are ordered by position when site setting to fix category's position is enabled", async function (assert) { + this.siteSettings.fixed_category_positions = true; + + const site = Site.current(); + const siteCategories = site.categories; + + siteCategories[0].parent_category_id = -1001; + siteCategories[0].id = -1000; + siteCategories[0].name = "Parent A Child A"; + + siteCategories[1].parent_category_id = null; + siteCategories[1].id = -1001; + siteCategories[1].name = "Parent A"; + + siteCategories[2].parent_category_id = null; + siteCategories[2].id = -1002; + siteCategories[2].name = "Parent B"; + + siteCategories[3].parent_category_id = -1001; + siteCategories[3].id = -1003; + siteCategories[3].name = "Parent A Child B"; + + siteCategories[4].parent_category_id = -1002; + siteCategories[4].id = -1004; + siteCategories[4].name = "Parent B Child A"; + + siteCategories[5].parent_category_id = -1000; + siteCategories[5].id = -1005; + siteCategories[5].name = "Parent A Child A Child A"; + + site.categoriesById.clear(); + + siteCategories.forEach((category) => { + site.categoriesById[category.id] = category; + }); + + updateCurrentUser({ + sidebar_category_ids: [-1005, -1004, -1003, -1002, -1000], + }); + + await visit("/"); + + const categorySectionLinks = queryAll( + ".sidebar-section-categories .sidebar-section-link:not(.sidebar-section-link-all-categories)" + ); + + const categoryNames = [...categorySectionLinks].map((categorySectionLink) => + categorySectionLink.textContent.trim() + ); + + assert.deepEqual( + categoryNames, + [ + "Parent A Child A", + "Parent A Child A Child A", + "Parent A Child B", + "Parent B", + "Parent B Child A", + ], "category section links are displayed in the right order" ); }); @@ -527,9 +693,85 @@ acceptance("Sidebar - Logged on user - Categories Section", function (needs) { ); }); + test("show suffix indicator for unread and new content on categories link", async function (assert) { + const { category1 } = setupUserSidebarCategories(); + + updateCurrentUser({ + sidebar_list_destination: "default", + }); + + this.container.lookup("service:topic-tracking-state").loadStates([ + { + topic_id: 1, + highest_post_number: 1, + last_read_post_number: null, + created_at: "2022-05-11T03:09:31.959Z", + category_id: category1.id, + notification_level: null, + created_in_new_period: true, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + { + topic_id: 2, + highest_post_number: 12, + last_read_post_number: 11, + created_at: "2020-02-09T09:40:02.672Z", + category_id: category1.id, + notification_level: 2, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + ]); + + await visit("/"); + + assert.ok( + exists( + `.sidebar-section-link-${category1.slug} .sidebar-section-link-suffix` + ), + "shows suffix indicator for unread content on categories link" + ); + + await publishToMessageBus("/unread", { + topic_id: 2, + message_type: "read", + payload: { + last_read_post_number: 12, + highest_post_number: 12, + }, + }); + + assert.ok( + exists( + `.sidebar-section-link-${category1.slug} .sidebar-section-link-suffix` + ), + "shows suffix indicator for new topics on categories link" + ); + + await publishToMessageBus("/unread", { + topic_id: 1, + message_type: "read", + payload: { + last_read_post_number: 1, + highest_post_number: 1, + }, + }); + + assert.ok( + !exists( + `.sidebar-section-link-${category1.slug} .sidebar-section-link-suffix` + ), + "hides suffix indicator when there's no new/unread content on category link" + ); + }); + test("new and unread count for categories link", async function (assert) { const { category1, category2 } = setupUserSidebarCategories(); + updateCurrentUser({ + sidebar_list_destination: "unread_new", + }); + this.container.lookup("service:topic-tracking-state").loadStates([ { topic_id: 1, diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js index dbfdc5ebfd..26f110febc 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js @@ -749,7 +749,79 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) { ); }); + test("show suffix indicator for unread and new content on everything link", async function (assert) { + updateCurrentUser({ + sidebar_list_destination: "default", + }); + + this.container.lookup("service:topic-tracking-state").loadStates([ + { + topic_id: 1, + highest_post_number: 1, + last_read_post_number: null, + created_at: "2022-05-11T03:09:31.959Z", + category_id: 1, + notification_level: null, + created_in_new_period: true, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + { + topic_id: 2, + highest_post_number: 12, + last_read_post_number: 11, + created_at: "2020-02-09T09:40:02.672Z", + category_id: 2, + notification_level: 2, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + ]); + + await visit("/"); + + assert.ok( + exists(".sidebar-section-link-everything .sidebar-section-link-suffix"), + "shows suffix indicator for unread posts on everything link" + ); + + // simulate reading topic 2 + await publishToMessageBus("/unread", { + topic_id: 2, + message_type: "read", + payload: { + last_read_post_number: 12, + highest_post_number: 12, + notification_level: 2, + }, + }); + + assert.ok( + exists(".sidebar-section-link-everything .sidebar-section-link-suffix"), + "shows suffix indicator for new topics on categories link" + ); + + // simulate reading topic 1 + await publishToMessageBus("/unread", { + topic_id: 1, + message_type: "read", + payload: { + last_read_post_number: 1, + highest_post_number: 1, + notification_level: 2, + }, + }); + + assert.ok( + !exists(".sidebar-section-link-everything .sidebar-section-link-suffix"), + "it removes the suffix indicator when all topics are read" + ); + }); + test("new and unread count for everything link", async function (assert) { + updateCurrentUser({ + sidebar_list_destination: "unread_new", + }); + this.container.lookup("service:topic-tracking-state").loadStates([ { topic_id: 1, @@ -1002,6 +1074,10 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) { const category = categories.find((c) => c.id === 1001); category.set("notification_level", NotificationLevels.TRACKING); + updateCurrentUser({ + sidebar_list_destination: "unread_new", + }); + this.container.lookup("service:topic-tracking-state").loadStates([ { topic_id: 1, @@ -1140,6 +1216,76 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) { ); }); + test("show suffix indicator for new content on tracked link", async function (assert) { + const categories = Site.current().categories; + + // Category id 1001 has two subcategories + const category = categories.find((c) => c.id === 1001); + category.set("notification_level", NotificationLevels.TRACKING); + + updateCurrentUser({ + sidebar_list_destination: "default", + }); + + this.container.lookup("service:topic-tracking-state").loadStates([ + { + topic_id: 1, + highest_post_number: 1, + last_read_post_number: null, + created_at: "2022-05-11T03:09:31.959Z", + category_id: category.id, + notification_level: NotificationLevels.TRACKING, + created_in_new_period: true, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + { + topic_id: 2, + highest_post_number: 12, + last_read_post_number: 11, + created_at: "2020-02-09T09:40:02.672Z", + category_id: category.subcategories[0].id, + notification_level: NotificationLevels.TRACKING, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + }, + ]); + + await visit("/"); + + assert.ok( + exists(".sidebar-section-link-tracked .sidebar-section-link-suffix") + ); + + // simulate reading topic id 2 + await publishToMessageBus("/unread", { + topic_id: 2, + message_type: "read", + payload: { + last_read_post_number: 12, + highest_post_number: 12, + }, + }); + + assert.ok( + exists(".sidebar-section-link-tracked .sidebar-section-link-suffix") + ); + + // simulate reading topic id 1 + await publishToMessageBus("/unread", { + topic_id: 1, + message_type: "read", + payload: { + last_read_post_number: 1, + highest_post_number: 1, + }, + }); + + assert.ok( + !exists(".sidebar-section-link-tracked .sidebar-section-link-suffix"), + "it removes the suffix indicator if there are no tracked new topics" + ); + }); + test("adding section link via plugin API with Object", async function (assert) { withPluginApi("1.2.0", (api) => { api.addCommunitySectionLink({ diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-tags-section-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-tags-section-test.js index 2e6da6ed49..7b80114b9c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-tags-section-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-tags-section-test.js @@ -413,7 +413,98 @@ acceptance("Sidebar - Logged on user - Tags section", function (needs) { ); }); + test("show suffix indicator for new content on tag section links", async function (assert) { + updateCurrentUser({ + sidebar_list_destination: "default", + }); + + this.container.lookup("service:topic-tracking-state").loadStates([ + { + topic_id: 1, + highest_post_number: 1, + last_read_post_number: null, + created_at: "2022-05-11T03:09:31.959Z", + category_id: 1, + notification_level: null, + created_in_new_period: true, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + tags: ["tag1"], + }, + { + topic_id: 2, + highest_post_number: 12, + last_read_post_number: 11, + created_at: "2020-02-09T09:40:02.672Z", + category_id: 2, + notification_level: 2, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + tags: ["tag1"], + }, + { + topic_id: 3, + highest_post_number: 15, + last_read_post_number: 14, + created_at: "2021-06-14T12:41:02.477Z", + category_id: 3, + notification_level: 2, + created_in_new_period: false, + treat_as_new_topic_start_date: "2022-05-09T03:17:34.286Z", + tags: ["tag2"], + }, + ]); + + await visit("/"); + + assert.ok( + exists(`.sidebar-section-link-tag1 .sidebar-section-link-suffix`), + "shows suffix indicator for new content on tag1 link" + ); + + assert.ok( + exists(`.sidebar-section-link-tag2 .sidebar-section-link-suffix`), + "shows suffix indicator for new content on tag2 link" + ); + + assert.ok( + !exists(`.sidebar-section-link-tag3 .sidebar-section-link-suffix`), + "hides suffix indicator when there's no new content on tag3 link" + ); + + await publishToMessageBus("/unread", { + topic_id: 2, + message_type: "read", + payload: { + last_read_post_number: 12, + highest_post_number: 12, + }, + }); + + assert.ok( + exists(`.sidebar-section-link-tag1 .sidebar-section-link-suffix`), + "shows suffix indicator for new topic on tag1 link" + ); + + await publishToMessageBus("/unread", { + topic_id: 1, + message_type: "read", + payload: { + last_read_post_number: 1, + highest_post_number: 1, + }, + }); + + assert.ok( + !exists(`.sidebar-section-link-tag1 .sidebar-section-link-suffix`), + "hides suffix indicator for tag1 section link" + ); + }); + test("new and unread count for tag section links", async function (assert) { + updateCurrentUser({ + sidebar_list_destination: "unread_new", + }); + this.container.lookup("service:topic-tracking-state").loadStates([ { topic_id: 1, diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-discovery-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-discovery-test.js index 5106f49861..5e0a1ed5e4 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-discovery-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-discovery-test.js @@ -30,7 +30,7 @@ acceptance("Topic Discovery", function (needs) { assert.strictEqual( query("a[data-user-card=eviltrout] img.avatar").getAttribute("title"), - "Evil Trout - Most Posts", + "eviltrout - Most Posts", "it shows user's full name in avatar title" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js index ad6f4a6082..39d77bdd8b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js @@ -134,7 +134,7 @@ acceptance("Topic - Edit timer", function (needs) { await timerType.expand(); await timerType.selectRowByValue("publish_to_category"); - assert.strictEqual(categoryChooser.header().label(), "uncategorized"); + assert.strictEqual(categoryChooser.header().label(), "category…"); assert.strictEqual(categoryChooser.header().value(), null); await categoryChooser.expand(); @@ -174,7 +174,7 @@ acceptance("Topic - Edit timer", function (needs) { await timerType.expand(); await timerType.selectRowByValue("publish_to_category"); - assert.strictEqual(categoryChooser.header().label(), "uncategorized"); + assert.strictEqual(categoryChooser.header().label(), "category…"); assert.strictEqual(categoryChooser.header().value(), null); await categoryChooser.expand(); @@ -218,7 +218,7 @@ acceptance("Topic - Edit timer", function (needs) { await timerType.expand(); await timerType.selectRowByValue("publish_to_category"); - assert.strictEqual(categoryChooser.header().label(), "uncategorized"); + assert.strictEqual(categoryChooser.header().label(), "category…"); assert.strictEqual(categoryChooser.header().value(), null); await categoryChooser.expand(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js index 2859e2c911..241d99d1a3 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js @@ -20,6 +20,19 @@ acceptance("Topic - Quote button - logged in", function (needs) { share_quote_buttons: "twitter|email", }); + needs.pretender((server, helper) => { + server.get("/inline-onebox", () => + helper.response({ + "inline-oneboxes": [ + { + url: "http://www.example.com/57350945", + title: "This is a great title", + }, + ], + }) + ); + }); + chromeTest( "Does not show the quote share buttons by default", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index 2f91b78a6b..45a40f095d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -211,6 +211,7 @@ acceptance("Topic", function (needs) { }); test("Deleting a topic", async function (assert) { + this.siteSettings.min_topic_views_for_delete_confirm = 10000; await visit("/t/internationalization-localization/280"); await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".widget-button.delete"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js index fef99da02c..36422c742c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js @@ -58,7 +58,7 @@ acceptance("User Drafts", function (needs) { query(".user-stream-item:nth-child(3) .excerpt").innerHTML.trim() ), normalizeHtml( - `here goes a reply to a PM :slight_smile:` + `here goes a reply to a PM :slight_smile:` ), "shows the excerpt" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js index c20de176fd..444956104f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js @@ -144,18 +144,18 @@ acceptance("User Preferences - Interface", function (needs) { document.querySelector("meta[name='discourse_theme_id']").remove(); }); - test("shows reset seen onboarding popups button", async function (assert) { + test("shows reset seen user tips popups button", async function (assert) { let site = Site.current(); - site.set("onboarding_popup_types", { first_notification: 1 }); + site.set("user_tips", { first_notification: 1 }); await visit("/u/eviltrout/preferences/interface"); assert.ok( - exists(".pref-reset-seen-popups"), - "has reset seen popups button" + exists(".pref-reset-seen-user-tips"), + "has reset seen user tips button" ); - await click(".pref-reset-seen-popups"); + await click(".pref-reset-seen-user-tips"); assert.deepEqual(lastUserData, { seen_popups: "", diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-test.js index e54642d5fe..c84da617f8 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-test.js @@ -120,14 +120,16 @@ acceptance( await visit("/u/eviltrout"); assert.strictEqual( query(".user-profile-names .username").textContent.trim(), - "eviltrout", + `eviltrout + Robin Ward is an admin`, "eviltrout profile is shown" ); await visit("/u/e.il.rout"); assert.strictEqual( query(".user-profile-names .username").textContent.trim(), - "e.il.rout", + `e.il.rout + Robin Ward is an admin`, "e.il.rout profile is shown" ); }); diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 0cf90293f9..c406d94065 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -612,7 +612,22 @@ export function applyDefaultHandlers(pretender) { pretender.post("/posts", function (request) { const data = parsePostData(request.requestBody); - if (data.raw === "custom message") { + if (data.title === "this title triggers an error") { + return response(422, { errors: ["That title has already been taken"] }); + } + + if (data.raw === "enqueue this content please") { + return response(200, { + success: true, + action: "enqueued", + pending_post: { + id: 1234, + raw: data.raw, + }, + }); + } + + if (data.raw === "custom message that is a good length") { return response(200, { success: true, action: "custom", diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 081431fe29..8ba016714d 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -127,11 +127,10 @@ export function withFrozenTime(timeString, timezone, callback) { let _pretenderCallbacks = {}; -export function resetSite(siteSettings, extras = {}) { +export function resetSite(extras = {}) { const siteAttrs = { ...siteFixtures["site.json"].site, ...extras, - siteSettings, }; PreloadStore.store("site", cloneJSON(siteAttrs)); @@ -311,9 +310,10 @@ export function acceptance(name, optionsOrCallback) { if (settingChanges) { mergeSettings(settingChanges); } + this.siteSettings = currentSettings(); - resetSite(currentSettings(), siteChanges); + resetSite(siteChanges); this.container = getOwner(this); diff --git a/app/assets/javascripts/discourse/tests/helpers/site-settings.js b/app/assets/javascripts/discourse/tests/helpers/site-settings.js index f822ec1365..9f64e064ca 100644 --- a/app/assets/javascripts/discourse/tests/helpers/site-settings.js +++ b/app/assets/javascripts/discourse/tests/helpers/site-settings.js @@ -1,111 +1,27 @@ -const ORIGINAL_SETTINGS = { +const CLIENT_SETTING_TEST_OVERRIDES = { title: "QUnit Discourse Tests", site_logo_url: "/assets/logo.png", site_logo_url: "/assets/logo.png", site_logo_small_url: "/assets/logo-single.png", site_mobile_logo_url: "", site_favicon_url: "/images/discourse-logo-sketch-small.png", - allow_user_locale: false, - suggested_topics: 7, - ga_universal_tracking_code: "", - ga_universal_domain_name: "auto", - top_menu: "latest|new|unread|categories|top", - post_menu: "like|share|flag|edit|bookmark|delete|admin|reply", - post_menu_hidden_items: "flag|bookmark|edit|delete|admin", - share_links: "twitter|facebook|email", - allow_username_in_share_links: true, - category_colors: - "BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|27AA5B|B3B5B4|E45735", - enable_mobile_theme: true, - relative_date_duration: 14, - fixed_category_positions: false, - enable_badges: true, - invite_only: false, - login_required: false, - must_approve_users: false, - enable_local_logins: true, - allow_new_registrations: true, - enable_google_logins: true, - enable_google_oauth2_logins: false, enable_twitter_logins: true, enable_facebook_logins: true, enable_github_logins: true, - enable_discourse_connect: false, - min_username_length: 3, - max_username_length: 20, - min_password_length: 8, - enable_names: true, - invites_shown: 30, - delete_user_max_post_age: 60, - delete_all_posts_max: 15, - min_post_length: 20, - min_personal_message_post_length: 10, - max_post_length: 32000, - min_topic_title_length: 15, - max_topic_title_length: 255, - min_personal_message_title_length: 2, - allow_uncategorized_topics: true, - min_title_similar_length: 10, - edit_history_visible_to_public: true, - delete_removed_posts_after: 24, - traditional_markdown_linebreaks: false, - suppress_reply_directly_below: true, - suppress_reply_directly_above: true, - newuser_max_embedded_media: 0, - newuser_max_attachments: 0, - display_name_on_posts: true, - short_progress_text_threshold: 10000, - default_code_lang: "auto", - autohighlight_all_code: false, - email_in: false, - authorized_extensions: ".jpg|.jpeg|.png|.gif|.svg|.txt|.ico|.yml", - authorized_extensions_for_staff: "", - max_image_width: 690, - max_image_height: 500, - allow_profile_backgrounds: true, - allow_uploaded_avatars: "0", - tl1_requires_read_posts: 30, - polling_interval: 3000, + authorized_extensions: "jpg|jpeg|png|gif|heic|heif|webp|svg|txt|ico|yml", anon_polling_interval: 30000, flush_timings_secs: 5, - enable_user_directory: true, - tos_url: "", - privacy_policy_url: "", - tos_accept_required: false, - faq_url: "", - allow_restore: false, - maximum_backups: 5, - version_checks: true, - suppress_uncategorized_badge: true, - min_search_term_length: 3, - topic_views_heat_low: 1000, - topic_views_heat_medium: 2000, - topic_views_heat_high: 5000, - global_notice: "", - show_create_topics_notice: true, - available_locales: - "cs|da|de|en|es|fr|he|id|it|ja|ko|nb_NO|nl|pl_PL|pt|pt_BR|ru|sv|uk|zh_CN|zh_TW", - highlighted_languages: - "apache|bash|cs|cpp|css|coffeescript|diff|xml|http|ini|json|java|javascript|makefile|markdown|nginx|objectivec|ruby|perl|php|python|sql|handlebars", - enable_emoji: true, - enable_emoji_shortcuts: true, - emoji_set: "google_classic", - enable_emoji_shortcuts: true, - enable_inline_emoji_translation: false, - desktop_category_page_style: "categories_and_latest_topics", - enable_mentions: true, - enable_personal_messages: true, - personal_message_enabled_groups: "11", // TL1 group - unicode_usernames: false, - secure_uploads: false, - external_emoji_url: "", - remove_muted_tags_from_latest: "always", - enable_group_directory: true, - default_sidebar_categories: "", - default_sidebar_tags: "", }; -let siteSettings = Object.assign({}, ORIGINAL_SETTINGS); +// Note, CLIENT_SITE_SETTINGS_WITH_DEFAULTS is generated by the site-settings-plugin, +// writing to test-site-settings.js via the ember-cli-build pipeline. +const ORIGINAL_CLIENT_SITE_SETTINGS = Object.assign( + {}, + // eslint-disable-next-line no-undef + CLIENT_SITE_SETTINGS_WITH_DEFAULTS, + CLIENT_SETTING_TEST_OVERRIDES +); +let siteSettings = Object.assign({}, ORIGINAL_CLIENT_SITE_SETTINGS); export function currentSettings() { return siteSettings; @@ -135,7 +51,8 @@ export function mergeSettings(other) { export function resetSettings() { for (let p in siteSettings) { if (siteSettings.hasOwnProperty(p)) { - let v = ORIGINAL_SETTINGS[p]; + // eslint-disable-next-line no-undef + let v = ORIGINAL_CLIENT_SITE_SETTINGS[p]; typeof v !== "undefined" ? setValue(p, v) : delete siteSettings[p]; } } diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html index c286633609..d1276bb7c8 100644 --- a/app/assets/javascripts/discourse/tests/index.html +++ b/app/assets/javascripts/discourse/tests/index.html @@ -37,6 +37,7 @@ + {{content-for "body"}} {{content-for "test-body"}} diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/pinned-options-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/pinned-options-test.js index 0d436f578c..f952008a6a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/pinned-options-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/pinned-options-test.js @@ -1,29 +1,27 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { render } from "@ember/test-helpers"; -import Topic from "discourse/models/topic"; import { hbs } from "ember-cli-htmlbars"; import selectKit from "discourse/tests/helpers/select-kit-helper"; - -const buildTopic = function (pinned = true) { - return Topic.create({ - id: 1234, - title: "Qunit Test Topic", - deleted_at: new Date(), - pinned, - }); -}; +import { getOwner } from "discourse-common/lib/get-owner"; module("Integration | Component | select-kit/pinned-options", function (hooks) { setupRenderingTest(hooks); - hooks.beforeEach(function () { - this.set("subject", selectKit()); - }); - test("unpinning", async function (assert) { this.siteSettings.automatically_unpin_topics = false; - this.set("topic", buildTopic()); + this.set("subject", selectKit()); + + const store = getOwner(this).lookup("service:store"); + this.set( + "topic", + store.createRecord("topic", { + id: 1234, + title: "Qunit Test Topic", + deleted_at: new Date(), + pinned: true, + }) + ); await render( hbs`` @@ -39,7 +37,17 @@ module("Integration | Component | select-kit/pinned-options", function (hooks) { test("pinning", async function (assert) { this.siteSettings.automatically_unpin_topics = false; - this.set("topic", buildTopic(false)); + this.set("subject", selectKit()); + const store = getOwner(this).lookup("service:store"); + this.set( + "topic", + store.createRecord("topic", { + id: 1234, + title: "Qunit Test Topic", + deleted_at: new Date(), + pinned: false, + }) + ); await render( hbs`` diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js index 5174fd3ceb..cc8a13006d 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js @@ -4,6 +4,7 @@ import { render } from "@ember/test-helpers"; import I18n from "I18n"; import { hbs } from "ember-cli-htmlbars"; import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { query } from "discourse/tests/helpers/qunit-helpers"; const DEFAULT_CONTENT = [ { id: 1, name: "foo" }, @@ -391,4 +392,23 @@ module("Integration | Component | select-kit/single-select", function (hooks) { }) ); }); + + test("options.verticalOffset", async function (assert) { + setDefaultState(this, { verticalOffset: -50 }); + await render(hbs` + + `); + await this.subject.expand(); + const header = query(".select-kit-header").getBoundingClientRect(); + const body = query(".select-kit-body").getBoundingClientRect(); + + assert.ok(header.bottom > body.top, "it correctly offsets the body"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/topic-notifications-button-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/topic-notifications-button-test.js index 1a507c33ec..d605269d55 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/topic-notifications-button-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/topic-notifications-button-test.js @@ -2,15 +2,14 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { render } from "@ember/test-helpers"; import I18n from "I18n"; -import Topic from "discourse/models/topic"; import { query } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { getOwner } from "discourse-common/lib/get-owner"; -const buildTopic = function (opts) { - return Topic.create({ +function buildTopic(opts) { + return this.store.createRecord("topic", { id: 4563, - }).updateFromJson({ title: "Qunit Test Topic", details: { notification_level: opts.level, @@ -20,7 +19,7 @@ const buildTopic = function (opts) { category_id: opts.category_id || null, tags: opts.tags || [], }); -}; +} const originalTranslation = I18n.translations.en.js.topic.notifications.tracking_pm.title; @@ -30,13 +29,17 @@ module( function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.store = getOwner(this).lookup("service:store"); + }); + hooks.afterEach(function () { I18n.translations.en.js.topic.notifications.tracking_pm.title = originalTranslation; }); test("the header has a localized title", async function (assert) { - this.set("topic", buildTopic({ level: 1 })); + this.set("topic", buildTopic.call(this, { level: 1 })); await render(hbs` `); - this.set("topic", buildTopic({ level: 3, reason: 999 })); + this.set("topic", buildTopic.call(this, { level: 3, reason: 999 })); assert.strictEqual( query(".topic-notifications-button .text").innerText, @@ -117,7 +123,10 @@ module( test("notification reason text - user tracking category", async function (assert) { this.currentUser.set("tracked_category_ids", [88]); - this.set("topic", buildTopic({ level: 2, reason: 8, category_id: 88 })); + this.set( + "topic", + buildTopic.call(this, { level: 2, reason: 8, category_id: 88 }) + ); await render(hbs` el.querySelector(".desc").textContent.trim()); @@ -34,7 +22,18 @@ module( setupRenderingTest(hooks); test("regular topic notification level descriptions", async function (assert) { - this.set("topic", buildTopic("regular")); + const store = getOwner(this).lookup("service:store"); + this.set( + "topic", + store.createRecord("topic", { + id: 4563, + title: "Qunit Test Topic", + archetype: "regular", + details: { + notification_level: 1, + }, + }) + ); await render(hbs` el.innerText), this.site .get("categoriesByCount") + .reject((c) => c.id === this.site.uncategorized_category_id) .slice(0, 8) .map((c) => c.name) ); @@ -103,7 +104,7 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) { test("top categories - allow_uncategorized_topics", async function (assert) { this.owner.unregister("service:current-user"); - this.siteSettings.allow_uncategorized_topics = false; + this.siteSettings.allow_uncategorized_topics = true; this.siteSettings.header_dropdown_category_count = 8; await render(hbs``); @@ -113,7 +114,6 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) { [...queryAll(".category-link .category-name")].map((el) => el.innerText), this.site .get("categoriesByCount") - .filter((c) => c.name !== "uncategorized") .slice(0, 8) .map((c) => c.name) ); @@ -122,7 +122,10 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) { test("top categories", async function (assert) { this.siteSettings.header_dropdown_category_count = 8; maxCategoriesToDisplay = this.siteSettings.header_dropdown_category_count; - categoriesByCount = this.site.get("categoriesByCount").slice(); + categoriesByCount = this.site + .get("categoriesByCount") + .reject((c) => c.id === this.site.uncategorized_category_id) + .slice(); categoriesByCount.every((c) => { if (!topCategoryIds.includes(c.id)) { if (mutedCategoryIds.length === 0) { diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-stream-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-stream-test.js index 81f9c54307..06273cde3b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-stream-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-stream-test.js @@ -3,15 +3,11 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { render } from "@ember/test-helpers"; import { count } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; -import Post from "discourse/models/post"; -import Topic from "discourse/models/topic"; +import { getOwner } from "discourse-common/lib/get-owner"; function postStreamTest(name, attrs) { test(name, async function (assert) { - const site = this.container.lookup("service:site"); - let posts = attrs.posts.call(this); - posts.forEach((p) => p.set("site", site)); - this.set("posts", posts); + this.set("posts", attrs.posts.call(this)); await render( hbs`` @@ -26,11 +22,13 @@ module("Integration | Component | Widget | post-stream", function (hooks) { postStreamTest("basics", { posts() { - const site = this.container.lookup("service:site"); - const topic = Topic.create(); + const site = getOwner(this).lookup("service:site"); + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic"); topic.set("details.created_by", { id: 123 }); + return [ - Post.create({ + store.createRecord("post", { topic, id: 1, post_number: 1, @@ -38,27 +36,32 @@ module("Integration | Component | Widget | post-stream", function (hooks) { primary_group_name: "trout", avatar_template: "/images/avatar.png", }), - Post.create({ + store.createRecord("post", { topic, id: 2, post_number: 2, post_type: site.get("post_types.moderator_action"), }), - Post.create({ topic, id: 3, post_number: 3, hidden: true }), - Post.create({ + store.createRecord("post", { + topic, + id: 3, + post_number: 3, + hidden: true, + }), + store.createRecord("post", { topic, id: 4, post_number: 4, post_type: site.get("post_types.whisper"), }), - Post.create({ + store.createRecord("post", { topic, id: 5, post_number: 5, wiki: true, via_email: true, }), - Post.create({ + store.createRecord("post", { topic, id: 6, post_number: 6, @@ -126,10 +129,12 @@ module("Integration | Component | Widget | post-stream", function (hooks) { postStreamTest("deleted posts", { posts() { - const topic = Topic.create(); + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic"); topic.set("details.created_by", { id: 123 }); + return [ - Post.create({ + store.createRecord("post", { topic, id: 1, post_number: 1, diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/poster-name-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/poster-name-test.js index a162b36093..1c1f04d777 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/poster-name-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/poster-name-test.js @@ -23,7 +23,6 @@ module("Integration | Component | Widget | poster-name", function (hooks) { assert.ok(exists("span.username")); assert.ok(exists('a[data-user-card="eviltrout"]')); assert.strictEqual(query(".username a").innerText, "eviltrout"); - assert.strictEqual(query(".full-name a").innerText, "Robin Ward"); assert.strictEqual(query(".user-title").innerText, "Trout Master"); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-admin-menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-admin-menu-test.js index a5af0938f6..83de882403 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-admin-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-admin-menu-test.js @@ -4,7 +4,7 @@ import { render } from "@ember/test-helpers"; import { exists } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; import Category from "discourse/models/category"; -import Topic from "discourse/models/topic"; +import { getOwner } from "discourse-common/lib/get-owner"; const createArgs = (topic) => { return { @@ -36,8 +36,13 @@ module( moderator: true, id: 123, }); - const topic = Topic.create({ user_id: this.currentUser.id }); + + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic", { + user_id: this.currentUser.id, + }); topic.set("category_id", Category.create({ read_restricted: true }).id); + this.siteSettings.allow_featured_topic_on_user_profiles = true; this.set("args", createArgs(topic)); @@ -54,8 +59,13 @@ module( moderator: false, id: 123, }); - const topic = Topic.create({ user_id: this.currentUser.id }); + + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic", { + user_id: this.currentUser.id, + }); topic.set("category_id", Category.create({ read_restricted: true }).id); + this.siteSettings.allow_featured_topic_on_user_profiles = true; this.set("args", createArgs(topic)); diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index e5de47408f..d95039c060 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -313,7 +313,7 @@ export default function setupTests(config) { }); PreloadStore.reset(); - resetSite(settings); + resetSite(); sinon.stub(ScrollingDOMMethods, "screenNotFull"); sinon.stub(ScrollingDOMMethods, "bindOnScroll"); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js index 16fb852d77..03284dd4d3 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js @@ -47,8 +47,8 @@ module("Unit | Controller | create-account", function (hooks) { controller.set("authProvider", ""); controller.set("accountEmail", "pork@chops.com"); - controller.set("accountUsername", "porkchops"); - controller.set("prefilledUsername", "porkchops"); + controller.set("accountUsername", "porkchops123"); + controller.set("prefilledUsername", "porkchops123"); controller.set("accountPassword", "b4fcdae11f9167"); assert.strictEqual( @@ -79,7 +79,10 @@ module("Unit | Controller | create-account", function (hooks) { testInvalidPassword("", null); testInvalidPassword("x", I18n.t("user.password.too_short")); - testInvalidPassword("porkchops", I18n.t("user.password.same_as_username")); + testInvalidPassword( + "porkchops123", + I18n.t("user.password.same_as_username") + ); testInvalidPassword( "pork@chops.com", I18n.t("user.password.same_as_email") diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js index 803f6586e8..940bc252d0 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js @@ -4,12 +4,13 @@ import { settled } from "@ember/test-helpers"; import pretender, { response } from "discourse/tests/helpers/create-pretender"; import EmberObject from "@ember/object"; import { Placeholder } from "discourse/lib/posts-with-placeholders"; -import Topic from "discourse/models/topic"; import User from "discourse/models/user"; import { next } from "@ember/runloop"; +import { getOwner } from "discourse-common/lib/get-owner"; +import sinon from "sinon"; function topicWithStream(streamDetails) { - let topic = Topic.create(); + const topic = this.store.createRecord("topic"); topic.postStream.setProperties(streamDetails); return topic; } @@ -17,9 +18,13 @@ function topicWithStream(streamDetails) { module("Unit | Controller | topic", function (hooks) { setupTest(hooks); + hooks.beforeEach(function () { + this.store = getOwner(this).lookup("service:store"); + }); + test("editTopic", function (assert) { - const controller = this.owner.lookup("controller:topic"); - const model = Topic.create(); + const controller = getOwner(this).lookup("controller:topic"); + const model = this.store.createRecord("topic"); controller.setProperties({ model }); assert.notOk(controller.editingTopic, "we are not editing by default"); @@ -50,15 +55,15 @@ module("Unit | Controller | topic", function (hooks) { }); test("deleteTopic", function (assert) { - const model = Topic.create(); + const model = this.store.createRecord("topic"); let destroyed = false; let modalDisplayed = false; model.destroy = async () => (destroyed = true); - const siteSettings = this.owner.lookup("service:site-settings"); + const siteSettings = getOwner(this).lookup("service:site-settings"); siteSettings.min_topic_views_for_delete_confirm = 5; - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model, deleteTopicModal: () => (modalDisplayed = true), @@ -74,10 +79,31 @@ module("Unit | Controller | topic", function (hooks) { assert.ok(destroyed, "destroy not popular topic"); }); - test("toggleMultiSelect", async function (assert) { - const model = Topic.create(); + test("deleteTopic permanentDelete", function (assert) { + const opts = { force_destroy: true }; + const model = this.store.createRecord("topic"); + const siteSettings = this.owner.lookup("service:site-settings"); + siteSettings.min_topic_views_for_delete_confirm = 5; + const controller = this.owner.lookup("controller:topic"); controller.setProperties({ model }); + model.set("views", 100); + + const stub = sinon.stub(model, "destroy"); + controller.send("deleteTopic", { force_destroy: true }); + + assert.deepEqual( + stub.getCall(0).args[1], + opts, + "does not show delete confirm permanently deleting, passes opts to model action" + // permanent delete happens after first delete, no need to show modal again + ); + }); + + test("toggleMultiSelect", async function (assert) { + const model = this.store.createRecord("topic"); + const controller = getOwner(this).lookup("controller:topic"); + controller.setProperties({ model }); assert.notOk( controller.multiSelect, @@ -118,8 +144,10 @@ module("Unit | Controller | topic", function (hooks) { }); test("selectedPosts", function (assert) { - const model = topicWithStream({ posts: [{ id: 1 }, { id: 2 }, { id: 3 }] }); - const controller = this.owner.lookup("controller:topic"); + const model = topicWithStream.call(this, { + posts: [{ id: 1 }, { id: 2 }, { id: 3 }], + }); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); controller.set("selectedPostIds", [1, 2, 42]); @@ -136,8 +164,8 @@ module("Unit | Controller | topic", function (hooks) { }); test("selectedAllPosts", function (assert) { - const model = topicWithStream({ stream: [1, 2, 3] }); - const controller = this.owner.lookup("controller:topic"); + const model = topicWithStream.call(this, { stream: [1, 2, 3] }); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); controller.set("selectedPostIds", [1, 2]); @@ -163,7 +191,7 @@ module("Unit | Controller | topic", function (hooks) { }); test("selectedPostsUsername", function (assert) { - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, username: "gary" }, { id: 2, username: "gary" }, @@ -171,7 +199,7 @@ module("Unit | Controller | topic", function (hooks) { ], stream: [1, 2, 3], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); assert.strictEqual( @@ -210,13 +238,13 @@ module("Unit | Controller | topic", function (hooks) { }); test("showSelectedPostsAtBottom", function (assert) { - const model = Topic.create({ posts_count: 3 }); - const controller = this.owner.lookup("controller:topic"); + const model = this.store.createRecord("topic", { posts_count: 3 }); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); assert.notOk(controller.showSelectedPostsAtBottom, "false on desktop"); - const site = this.owner.lookup("service:site"); + const site = getOwner(this).lookup("service:site"); site.set("mobileView", true); assert.notOk( @@ -233,7 +261,7 @@ module("Unit | Controller | topic", function (hooks) { test("canDeleteSelected", function (assert) { const currentUser = User.create({ admin: false }); - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, can_delete: false }, { id: 2, can_delete: true }, @@ -242,7 +270,7 @@ module("Unit | Controller | topic", function (hooks) { stream: [1, 2, 3], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model, currentUser, @@ -279,7 +307,7 @@ module("Unit | Controller | topic", function (hooks) { }); test("Can split/merge topic", function (assert) { - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, post_number: 1, post_type: 1 }, { id: 2, post_number: 2, post_type: 4 }, @@ -289,7 +317,7 @@ module("Unit | Controller | topic", function (hooks) { }); model.set("details.can_move_posts", false); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); assert.notOk( @@ -326,7 +354,7 @@ module("Unit | Controller | topic", function (hooks) { test("canChangeOwner", function (assert) { const currentUser = User.create({ admin: false }); - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, username: "gary" }, { id: 2, username: "lili" }, @@ -335,7 +363,7 @@ module("Unit | Controller | topic", function (hooks) { }); model.set("currentUser", currentUser); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model, currentUser }); assert.notOk(controller.canChangeOwner, "false when no posts are selected"); @@ -358,7 +386,7 @@ module("Unit | Controller | topic", function (hooks) { test("modCanChangeOwner", function (assert) { const currentUser = User.create({ moderator: false }); - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, username: "gary" }, { id: 2, username: "lili" }, @@ -367,10 +395,10 @@ module("Unit | Controller | topic", function (hooks) { }); model.set("currentUser", currentUser); - const siteSettings = this.owner.lookup("service:site-settings"); + const siteSettings = getOwner(this).lookup("service:site-settings"); siteSettings.moderators_change_post_ownership = true; - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model, currentUser }); assert.notOk(controller.canChangeOwner, "false when no posts are selected"); @@ -392,7 +420,7 @@ module("Unit | Controller | topic", function (hooks) { }); test("canMergePosts", function (assert) { - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [ { id: 1, username: "gary", can_delete: true }, { id: 2, username: "lili", can_delete: true }, @@ -402,7 +430,7 @@ module("Unit | Controller | topic", function (hooks) { stream: [1, 2, 3], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); assert.notOk(controller.canMergePosts, "false when no posts are selected"); @@ -433,8 +461,8 @@ module("Unit | Controller | topic", function (hooks) { }); test("Select/deselect all", function (assert) { - const controller = this.owner.lookup("controller:topic"); - const model = topicWithStream({ stream: [1, 2, 3] }); + const controller = getOwner(this).lookup("controller:topic"); + const model = topicWithStream.call(this, { stream: [1, 2, 3] }); controller.setProperties({ model }); assert.strictEqual( @@ -459,7 +487,7 @@ module("Unit | Controller | topic", function (hooks) { }); test("togglePostSelection", function (assert) { - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); assert.strictEqual( controller.selectedPostIds[0], @@ -483,10 +511,10 @@ module("Unit | Controller | topic", function (hooks) { }); test("selectBelow", function (assert) { - const site = this.owner.lookup("service:site"); + const site = getOwner(this).lookup("service:site"); site.set("post_types", { small_action: 3, whisper: 4 }); - const model = topicWithStream({ + const model = topicWithStream.call(this, { stream: [1, 2, 3, 4, 5, 6, 7, 8], posts: [ { id: 5, cooked: "whisper post", post_type: 4 }, @@ -495,7 +523,7 @@ module("Unit | Controller | topic", function (hooks) { ], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); assert.deepEqual( @@ -513,11 +541,11 @@ module("Unit | Controller | topic", function (hooks) { response([{ id: 2, level: 1 }]) ); - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [{ id: 1 }, { id: 2 }], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); controller.send("selectReplies", { id: 1 }); @@ -552,10 +580,10 @@ module("Unit | Controller | topic", function (hooks) { }); test("topVisibleChanged", function (assert) { - const model = topicWithStream({ + const model = topicWithStream.call(this, { posts: [{ id: 1 }], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model }); const placeholder = new Placeholder("post-placeholder"); @@ -581,12 +609,12 @@ module("Unit | Controller | topic", function (hooks) { }); const currentUser = EmberObject.create({ moderator: true }); - const model = topicWithStream({ + const model = topicWithStream.call(this, { stream: [2, 3, 4], posts: [post, { id: 3 }, { id: 4 }], }); - const controller = this.owner.lookup("controller:topic"); + const controller = getOwner(this).lookup("controller:topic"); controller.setProperties({ model, currentUser }); const done = assert.async(); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/category-badge-test.js b/app/assets/javascripts/discourse/tests/unit/lib/category-badge-test.js index 2056cd43fe..ae4ae6f25e 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/category-badge-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/category-badge-test.js @@ -24,7 +24,7 @@ discourseModule("Unit | Utility | category-badge", function () { assert.strictEqual(tag.tagName, "A", "it creates a `a` wrapper tag"); assert.strictEqual( tag.className.trim(), - "badge-wrapper", + "badge-wrapper bullet", "it has the correct class" ); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/ember-action-modifer-test.js b/app/assets/javascripts/discourse/tests/unit/lib/ember-action-modifer-test.js index 6a865100e1..985da6c547 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/ember-action-modifer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/ember-action-modifer-test.js @@ -75,7 +75,7 @@ module("Unit | Lib | ember-action-modifer", function (hooks) { assert.strictEqual(this.dblClicked, 0); }); - module("used on a classic component", function () { + module("used on a classic component", function (innerHooks) { const ExampleClassicButton = ClassicComponent.extend({ tagName: "", onDoSomething: null, @@ -84,6 +84,7 @@ module("Unit | Lib | ember-action-modifer", function (hooks) { this.onDoSomething?.("doSomething"); }, }); + const ExampleClassicButtonWithActions = ClassicComponent.extend({ tagName: "", onDoSomething: null, @@ -98,6 +99,7 @@ module("Unit | Lib | ember-action-modifer", function (hooks) { }, }, }); + const exampleClassicButtonTemplate = hbs` `; - hooks.beforeEach(function () { + innerHooks.beforeEach(function () { this.owner.register( "component:example-classic-button", ExampleClassicButton diff --git a/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js b/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js index c34abbeed9..f6f3408e1c 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/emoji-test.js @@ -32,12 +32,12 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "emoticons :)", - `emoticons slight_smile`, + `emoticons slight_smile`, "emoticons are still supported" ); testUnescape( "With emoji :O: :frog: :smile:", - `With emoji O frog smile`, + `With emoji O frog smile`, "title with emoji" ); testUnescape( @@ -47,27 +47,27 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "(:frog:) :)", - `(frog) slight_smile`, + `(frog) slight_smile`, "non-word characters allowed next to emoji" ); testUnescape( ":smile: hi", - `smile hi`, + `smile hi`, "start of line" ); testUnescape( "hi :smile:", - `hi smile`, + `hi smile`, "end of line" ); testUnescape( "hi :blonde_woman:t4:", - `hi blonde_woman:t4`, + `hi blonde_woman:t4`, "support for skin tones" ); testUnescape( "hi :blonde_woman:t4: :blonde_man:t6:", - `hi blonde_woman:t4 blonde_man:t6`, + `hi blonde_woman:t4 blonde_man:t6`, "support for multiple skin tones" ); testUnescape( @@ -95,7 +95,7 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "Hello 😊 World", - `Hello blush World`, + `Hello blush World`, "emoji from Unicode emoji" ); testUnescape( @@ -108,7 +108,7 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "Hello😊World", - `HelloblushWorld`, + `HelloblushWorld`, "emoji from Unicode emoji when inline translation enabled", { enable_inline_emoji_translation: true, @@ -124,13 +124,13 @@ discourseModule("Unit | Utility | emoji", function () { ); testUnescape( "hi:smile:", - `hismile`, + `hismile`, "emoji when inline translation enabled", { enable_inline_emoji_translation: true } ); assert.strictEqual( emojiUnescape(":smile:", { tabIndex: "0" }), - `smile`, + `smile`, "emoji when tabindex is enabled" ); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js b/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js index 311fc2d763..415f5dade8 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js @@ -150,6 +150,7 @@ discourseModule("Unit | Utility | formatter", function (hooks) { test("formatting tiny dates", function (assert) { let shortDateYear = shortDateTester("MMM 'YY"); + this.siteSettings.relative_date_duration = 14; assert.strictEqual(formatMins(0), "1m"); assert.strictEqual(formatMins(1), "1m"); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/link-lookup-test.js b/app/assets/javascripts/discourse/tests/unit/lib/link-lookup-test.js index 9947393575..e1454fd4ae 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/link-lookup-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/link-lookup-test.js @@ -1,10 +1,11 @@ import LinkLookup from "discourse/lib/link-lookup"; import { module, test } from "qunit"; -import Post from "discourse/models/post"; +import { getOwner } from "discourse-common/lib/get-owner"; module("Unit | Utility | link-lookup", function (hooks) { hooks.beforeEach(function () { - this.post = Post.create(); + const store = getOwner(this).lookup("service:store"); + this.post = store.createRecord("post"); this.linkLookup = new LinkLookup({ "en.wikipedia.org/wiki/handheld_game_console": { post_number: 1, diff --git a/app/assets/javascripts/discourse/tests/unit/lib/plugin-api-test.js b/app/assets/javascripts/discourse/tests/unit/lib/plugin-api-test.js new file mode 100644 index 0000000000..a8b6b8f5d5 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/plugin-api-test.js @@ -0,0 +1,87 @@ +import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import EmberObject from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; +import { withPluginApi } from "discourse/lib/plugin-api"; + +discourseModule("Unit | Utility | plugin-api", function () { + test("modifyClass works with classic Ember objects", function (assert) { + const TestThingy = EmberObject.extend({ + @discourseComputed + prop() { + return "hello"; + }, + }); + + this.registry.register("test-thingy:main", TestThingy); + + withPluginApi("1.1.0", (api) => { + api.modifyClass("test-thingy:main", { + pluginId: "plugin-api-test", + + @discourseComputed + prop() { + return `${this._super(...arguments)} there`; + }, + }); + }); + + const thingy = this.container.lookup("test-thingy:main"); + assert.strictEqual(thingy.prop, "hello there"); + }); + + test("modifyClass works with native class Ember objects", function (assert) { + class NativeTestThingy extends EmberObject { + @discourseComputed + prop() { + return "howdy"; + } + } + + this.registry.register("native-test-thingy:main", NativeTestThingy); + + withPluginApi("1.1.0", (api) => { + api.modifyClass("native-test-thingy:main", { + pluginId: "plugin-api-test", + + @discourseComputed + prop() { + return `${this._super(...arguments)} partner`; + }, + }); + }); + + const thingy = this.container.lookup("native-test-thingy:main"); + assert.strictEqual(thingy.prop, "howdy partner"); + }); + + test("modifyClass works with native classes", function (assert) { + class ClassTestThingy { + get keep() { + return "hey!"; + } + + get prop() { + return "top of the morning"; + } + } + + this.registry.register("class-test-thingy:main", new ClassTestThingy(), { + instantiate: false, + }); + + withPluginApi("1.1.0", (api) => { + api.modifyClass("class-test-thingy:main", { + pluginId: "plugin-api-test", + + get prop() { + return "g'day"; + }, + }); + }); + + const thingy = this.container.lookup("class-test-thingy:main"); + assert.strictEqual(thingy.keep, "hey!"); + assert.strictEqual(thingy.prop, "g'day"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js index b0c2ead7d9..4e7e824722 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js @@ -4,19 +4,19 @@ import { deleteCachedInlineOnebox, } from "pretty-text/inline-oneboxer"; import QUnit, { module, test } from "qunit"; -import Post from "discourse/models/post"; import { buildQuote } from "discourse/lib/quote"; import { deepMerge } from "discourse-common/lib/object"; import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it"; import { registerEmoji } from "pretty-text/emoji"; import { IMAGE_VERSION as v } from "pretty-text/emoji/version"; +import { getOwner } from "discourse-common/lib/get-owner"; const rawOpts = { siteSettings: { enable_emoji: true, enable_emoji_shortcuts: true, enable_mentions: true, - emoji_set: "google_classic", + emoji_set: "twitter", external_emoji_url: "", highlighted_languages: "json|ruby|javascript", default_code_lang: "auto", @@ -1274,7 +1274,8 @@ eviltrout

    }); test("quotes", function (assert) { - const post = Post.create({ + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { cooked: "

    lorem ipsum

    ", username: "eviltrout", post_number: 1, @@ -1334,7 +1335,8 @@ eviltrout

    }); test("quoting a quote", function (assert) { - const post = Post.create({ + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { cooked: new PrettyText(defaultOpts).cook( '[quote="sam, post:1, topic:1, full:true"]\nhello\n[/quote]\n*Test*' ), @@ -1524,7 +1526,7 @@ var bar = 'bar'; assert.cookedOptions( ":grin: @sam", { featuresOverride: ["emoji"] }, - `

    :grin: @sam

    `, + `

    :grin: @sam

    `, "cooks emojis when only the emoji markdown engine is enabled" ); @@ -1539,15 +1541,15 @@ var bar = 'bar'; test("emoji", function (assert) { assert.cooked( ":smile:", - `

    :smile:

    ` + `

    :smile:

    ` ); assert.cooked( ":(", - `

    :frowning:

    ` + `

    :frowning:

    ` ); assert.cooked( "8-)", - `

    :sunglasses:

    ` + `

    :sunglasses:

    ` ); }); @@ -1561,7 +1563,7 @@ var bar = 'bar'; assert.cookedOptions( "test:smile:test", { siteSettings: { enable_inline_emoji_translation: true } }, - `

    test:smile:test

    ` + `

    test:smile:test

    ` ); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/text-test.js b/app/assets/javascripts/discourse/tests/unit/lib/text-test.js index b78b2de689..d4554d09dd 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/text-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/text-test.js @@ -16,13 +16,13 @@ module("Unit | Utility | text", function () { let cooked = await cookAsync("Hello! :wave:"); assert.strictEqual( await excerpt(cooked, 300), - 'Hello! :wave:' + 'Hello! :wave:' ); cooked = await cookAsync("[:wave:](https://example.com)"); assert.strictEqual( await excerpt(cooked, 300), - '
    :wave:' + ':wave:' ); cooked = await cookAsync(''); diff --git a/app/assets/javascripts/discourse/tests/unit/models/category-test.js b/app/assets/javascripts/discourse/tests/unit/models/category-test.js index 87a6d95cb2..4967bef84e 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/category-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/category-test.js @@ -401,4 +401,30 @@ module("Unit | Model | category", function () { "ignores case of category slug and search term" ); }); + + test("sortCategories returns categories with child categories sorted after parent categories", function (assert) { + const categories = [ + { id: 1003, name: "Test Sub Sub", parent_category_id: 1002 }, + { id: 1001, name: "Test" }, + { id: 1004, name: "Test Sub Sub Sub", parent_category_id: 1003 }, + { id: 1002, name: "Test Sub", parent_category_id: 1001 }, + { id: 1005, name: "Test Sub Sub Sub2", parent_category_id: 1003 }, + { id: 1006, name: "Test2" }, + { id: 1000, name: "Test2 Sub", parent_category_id: 1006 }, + { id: 997, name: "Test2 Sub Sub2", parent_category_id: 1000 }, + { id: 999, name: "Test2 Sub Sub", parent_category_id: 1000 }, + ]; + + assert.deepEqual(Category.sortCategories(categories).mapBy("name"), [ + "Test", + "Test Sub", + "Test Sub Sub", + "Test Sub Sub Sub", + "Test Sub Sub Sub2", + "Test2", + "Test2 Sub", + "Test2 Sub Sub2", + "Test2 Sub Sub", + ]); + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/composer-test.js b/app/assets/javascripts/discourse/tests/unit/models/composer-test.js index eb3f513dd2..216d007080 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/composer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/composer-test.js @@ -10,9 +10,9 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import AppEvents from "discourse/services/app-events"; import EmberObject from "@ember/object"; -import Post from "discourse/models/post"; import createStore from "discourse/tests/helpers/create-store"; import { test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; function createComposer(opts) { opts = opts || {}; @@ -276,7 +276,8 @@ discourseModule("Unit | Model | composer", function () { const composer = createComposer(); assert.ok(!composer.get("editingFirstPost"), "it's false by default"); - const post = Post.create({ id: 123, post_number: 2 }); + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { id: 123, post_number: 2 }); composer.setProperties({ post, action: EDIT }); assert.ok( !composer.get("editingFirstPost"), @@ -291,10 +292,11 @@ discourseModule("Unit | Model | composer", function () { }); test("clearState", function (assert) { + const store = getOwner(this).lookup("service:store"); const composer = createComposer({ originalText: "asdf", reply: "asdf2", - post: Post.create({ id: 1 }), + post: store.createRecord("post", { id: 1 }), title: "wat", }); @@ -358,7 +360,8 @@ discourseModule("Unit | Model | composer", function () { this.siteSettings.max_topic_title_length = 10; const composer = createComposer(); - const post = Post.create({ + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { id: 123, post_number: 2, static_doc: true, @@ -382,6 +385,7 @@ discourseModule("Unit | Model | composer", function () { }); test("title placeholder depends on what you're doing", function (assert) { + this.siteSettings.topic_featured_link_enabled = false; let composer = createComposer({ action: CREATE_TOPIC }); assert.strictEqual( composer.get("titlePlaceholder"), diff --git a/app/assets/javascripts/discourse/tests/unit/models/pending-post-test.js b/app/assets/javascripts/discourse/tests/unit/models/pending-post-test.js index d42f3b6082..4849fdd3e3 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/pending-post-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/pending-post-test.js @@ -1,18 +1,22 @@ import { module, test } from "qunit"; -import PendingPost from "discourse/models/pending-post"; -import createStore from "discourse/tests/helpers/create-store"; +import { setupTest } from "ember-qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; import { settled } from "@ember/test-helpers"; -module("Unit | Model | pending-post", function () { +module("Unit | Model | pending-post", function (hooks) { + setupTest(hooks); + test("Properties", async function (assert) { - const store = createStore(); + const store = getOwner(this).lookup("service:store"); const category = store.createRecord("category", { id: 2 }); - const post = PendingPost.create({ + const post = store.createRecord("pending-post", { id: 1, topic_url: "topic-url", username: "USERNAME", category_id: 2, }); + + // pending-post initializer performs async operations await settled(); assert.equal(post.postUrl, "topic-url", "topic_url is aliased to postUrl"); @@ -30,7 +34,12 @@ module("Unit | Model | pending-post", function () { }); test("it cooks raw_text", async function (assert) { - const post = PendingPost.create({ raw_text: "**bold text**" }); + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("pending-post", { + raw_text: "**bold text**", + }); + + // pending-post initializer performs async operations await settled(); assert.equal( diff --git a/app/assets/javascripts/discourse/tests/unit/models/post-test.js b/app/assets/javascripts/discourse/tests/unit/models/post-test.js index 6938b2b476..c702a5e6d5 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/post-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/post-test.js @@ -1,30 +1,20 @@ import { module, test } from "qunit"; -import Post from "discourse/models/post"; import User from "discourse/models/user"; -import { deepMerge } from "discourse-common/lib/object"; +import { getOwner } from "discourse-common/lib/get-owner"; -function buildPost(args) { - return Post.create( - deepMerge( - { - id: 1, - can_delete: true, - version: 1, - }, - args || {} - ) - ); -} +module("Unit | Model | post", function (hooks) { + hooks.beforeEach(function () { + this.store = getOwner(this).lookup("service:store"); + }); -module("Unit | Model | post", function () { test("defaults", function (assert) { - let post = Post.create({ id: 1 }); + const post = this.store.createRecord("post", { id: 1 }); assert.blank(post.get("deleted_at"), "it has no deleted_at by default"); assert.blank(post.get("deleted_by"), "there is no deleted_by by default"); }); test("new_user", function (assert) { - let post = Post.create({ trust_level: 0 }); + const post = this.store.createRecord("post", { trust_level: 0 }); assert.ok(post.get("new_user"), "post is from a new user"); post.set("trust_level", 1); @@ -32,7 +22,7 @@ module("Unit | Model | post", function () { }); test("firstPost", function (assert) { - let post = Post.create({ post_number: 1 }); + const post = this.store.createRecord("post", { post_number: 1 }); assert.ok(post.get("firstPost"), "it's the first post"); post.set("post_number", 10); @@ -40,17 +30,14 @@ module("Unit | Model | post", function () { }); test("updateFromPost", function (assert) { - let post = Post.create({ + const post = this.store.createRecord("post", { post_number: 1, raw: "hello world", }); post.updateFromPost( - Post.create({ + this.store.createRecord("post", { raw: "different raw", - wat: function () { - return 123; - }, }) ); @@ -58,8 +45,13 @@ module("Unit | Model | post", function () { }); test("destroy by staff", async function (assert) { - let user = User.create({ username: "staff", moderator: true }); - let post = buildPost({ user }); + const user = User.create({ username: "staff", moderator: true }); + const post = this.store.createRecord("post", { + id: 1, + can_delete: true, + version: 1, + user, + }); await post.destroy(user); @@ -85,7 +77,13 @@ module("Unit | Model | post", function () { test("destroy by non-staff", async function (assert) { const originalCooked = "this is the original cooked value"; const user = User.create({ username: "evil trout" }); - const post = buildPost({ user, cooked: originalCooked }); + const post = this.store.createRecord("post", { + id: 1, + can_delete: true, + version: 1, + user, + cooked: originalCooked, + }); await post.destroy(user); diff --git a/app/assets/javascripts/discourse/tests/unit/models/site-test.js b/app/assets/javascripts/discourse/tests/unit/models/site-test.js index 5fcd1ad9d7..edf509fc73 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/site-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/site-test.js @@ -77,26 +77,47 @@ module("Unit | Model | site", function () { ); }); - test("deeply nested categories", function (assert) { + test("sortedCategories returns categories sorted by topic counts and sorts child categories after parent", function (assert) { const store = createStore(); const site = store.createRecord("site", { categories: [ - { id: 1003, name: "Test Sub Sub", parent_category_id: 1002 }, - { id: 1001, name: "Test" }, + { + id: 1003, + name: "Test Sub Sub", + parent_category_id: 1002, + topic_count: 0, + }, + { id: 1001, name: "Test", topic_count: 1 }, { id: 1004, name: "Test Sub Sub Sub", parent_category_id: 1003 }, - { id: 1002, name: "Test Sub", parent_category_id: 1001 }, - { id: 1005, name: "Test Sub Sub Sub2", parent_category_id: 1003 }, - { id: 1006, name: "Test2" }, + { + id: 1002, + name: "Test Sub", + parent_category_id: 1001, + topic_count: 0, + }, + { + id: 1005, + name: "Test Sub Sub Sub2", + parent_category_id: 1003, + topic_count: 1, + }, + { id: 1006, name: "Test2", topic_count: 2 }, + { id: 1000, name: "Test2 Sub", parent_category_id: 1006 }, + { id: 997, name: "Test2 Sub Sub2", parent_category_id: 1000 }, + { id: 999, name: "Test2 Sub Sub", parent_category_id: 1000 }, ], }); assert.deepEqual(site.sortedCategories.mapBy("name"), [ + "Test2", + "Test2 Sub", + "Test2 Sub Sub2", + "Test2 Sub Sub", "Test", "Test Sub", "Test Sub Sub", - "Test Sub Sub Sub", "Test Sub Sub Sub2", - "Test2", + "Test Sub Sub Sub", ]); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js index 23093db921..5daeb9f65b 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js @@ -1,21 +1,21 @@ import { module, test } from "qunit"; -import Topic from "discourse/models/topic"; import User from "discourse/models/user"; - -function buildDetails(id, topicParams = {}) { - const topic = Topic.create(Object.assign({ id }, topicParams)); - return topic.get("details"); -} +import { getOwner } from "discourse-common/lib/get-owner"; module("Unit | Model | topic-details", function () { test("defaults", function (assert) { - let details = buildDetails(1234); + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic", { id: 1234 }); + const details = topic.details; + assert.present(details, "the details are present by default"); assert.ok(!details.get("loaded"), "details are not loaded by default"); }); test("updateFromJson", function (assert) { - let details = buildDetails(1234); + const store = getOwner(this).lookup("service:store"); + const topic = store.createRecord("topic", { id: 1234 }); + const details = topic.details; details.updateFromJson({ allowed_users: [{ username: "eviltrout" }], diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js index 5b5446eb3c..c4dc505d92 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js @@ -5,17 +5,22 @@ import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import { IMAGE_VERSION as v } from "pretty-text/emoji/version"; import createStore from "discourse/tests/helpers/create-store"; +import { getOwner } from "discourse-common/lib/get-owner"; + +discourseModule("Unit | Model | topic", function (hooks) { + hooks.beforeEach(function () { + this.store = getOwner(this).lookup("service:store"); + }); -discourseModule("Unit | Model | topic", function () { test("defaults", function (assert) { - const topic = Topic.create({ id: 1234 }); + const topic = this.store.createRecord("topic", { id: 1234 }); assert.blank(topic.get("deleted_at"), "deleted_at defaults to blank"); assert.blank(topic.get("deleted_by"), "deleted_by defaults to blank"); }); test("visited", function (assert) { - const topic = Topic.create({ + const topic = this.store.createRecord("topic", { highest_post_number: 2, last_read_post_number: 1, }); @@ -35,14 +40,47 @@ discourseModule("Unit | Model | topic", function () { ); }); - test("lastUnreadUrl", function (assert) { + test("lastUnreadUrl when user read the whole topic", function (assert) { + const topic = this.store.createRecord("topic", { + id: 101, + highest_post_number: 10, + last_read_post_number: 10, + slug: "hello", + }); + + assert.strictEqual(topic.lastUnreadUrl, "/t/hello/101/10"); + }); + + test("lastUnreadUrl when there are unread posts", function (assert) { + const topic = this.store.createRecord("topic", { + id: 101, + highest_post_number: 10, + last_read_post_number: 5, + slug: "hello", + }); + + assert.strictEqual(topic.lastUnreadUrl, "/t/hello/101/6"); + }); + + test("lastUnreadUrl when last_read_post_number is incorrect", function (assert) { + const topic = this.store.createRecord("topic", { + id: 101, + highest_post_number: 10, + last_read_post_number: 15, + slug: "hello", + }); + + assert.strictEqual(topic.lastUnreadUrl, "/t/hello/101/10"); + }); + + test("lastUnreadUrl with navigate_to_first_post_after_read setting", function (assert) { const store = createStore(); const category = store.createRecord("category", { id: 22, navigate_to_first_post_after_read: true, }); - const topic = Topic.create({ + const topic = this.store.createRecord("topic", { id: 101, highest_post_number: 10, last_read_post_number: 10, @@ -50,11 +88,29 @@ discourseModule("Unit | Model | topic", function () { category_id: category.id, }); - assert.strictEqual(topic.get("lastUnreadUrl"), "/t/hello/101/1"); + assert.strictEqual(topic.lastUnreadUrl, "/t/hello/101/1"); + }); + + test("lastUnreadUrl with navigate_to_first_post_after_read setting and unread posts", function (assert) { + const store = createStore(); + const category = store.createRecord("category", { + id: 22, + navigate_to_first_post_after_read: true, + }); + + const topic = this.store.createRecord("topic", { + id: 101, + highest_post_number: 10, + last_read_post_number: 5, + slug: "hello", + category_id: category.id, + }); + + assert.strictEqual(topic.lastUnreadUrl, "/t/hello/101/6"); }); test("has details", function (assert) { - const topic = Topic.create({ id: 1234 }); + const topic = this.store.createRecord("topic", { id: 1234 }); const topicDetails = topic.get("details"); assert.present(topicDetails, "a topic has topicDetails after we create it"); @@ -66,7 +122,7 @@ discourseModule("Unit | Model | topic", function () { }); test("has a postStream", function (assert) { - const topic = Topic.create({ id: 1234 }); + const topic = this.store.createRecord("topic", { id: 1234 }); const postStream = topic.get("postStream"); assert.present(postStream, "a topic has a postStream after we create it"); @@ -78,7 +134,9 @@ discourseModule("Unit | Model | topic", function () { }); test("has suggestedTopics", function (assert) { - const topic = Topic.create({ suggested_topics: [{ id: 1 }, { id: 2 }] }); + const topic = this.store.createRecord("topic", { + suggested_topics: [{ id: 1 }, { id: 2 }], + }); const suggestedTopics = topic.get("suggestedTopics"); assert.strictEqual( @@ -92,13 +150,16 @@ discourseModule("Unit | Model | topic", function () { test("category relationship", function (assert) { // It finds the category by id const category = Category.list()[0]; - const topic = Topic.create({ id: 1111, category_id: category.get("id") }); + const topic = this.store.createRecord("topic", { + id: 1111, + category_id: category.get("id"), + }); assert.strictEqual(topic.get("category"), category); }); test("updateFromJson", function (assert) { - const topic = Topic.create({ id: 1234 }); + const topic = this.store.createRecord("topic", { id: 1234 }); const category = Category.list()[0]; topic.updateFromJson({ @@ -124,7 +185,7 @@ discourseModule("Unit | Model | topic", function () { test("recover", async function (assert) { const user = User.create({ username: "eviltrout" }); - const topic = Topic.create({ + const topic = this.store.createRecord("topic", { id: 1234, deleted_at: new Date(), deleted_by: user, @@ -137,20 +198,24 @@ discourseModule("Unit | Model | topic", function () { }); test("fancyTitle", function (assert) { - const topic = Topic.create({ + const topic = this.store.createRecord("topic", { fancy_title: ":smile: with all :) the emojis :pear::peach:", }); assert.strictEqual( topic.get("fancyTitle"), - `smile with all slight_smile the emojis pearpeach`, + `smile with all slight_smile the emojis pearpeach`, "supports emojis" ); }); test("fancyTitle direction", function (assert) { - const rtlTopic = Topic.create({ fancy_title: "هذا اختبار" }); - const ltrTopic = Topic.create({ fancy_title: "This is a test" }); + const rtlTopic = this.store.createRecord("topic", { + fancy_title: "هذا اختبار", + }); + const ltrTopic = this.store.createRecord("topic", { + fancy_title: "This is a test", + }); this.siteSettings.support_mixed_text_direction = true; assert.strictEqual( @@ -166,28 +231,28 @@ discourseModule("Unit | Model | topic", function () { }); test("excerpt", function (assert) { - const topic = Topic.create({ + const topic = this.store.createRecord("topic", { excerpt: "This is a test topic :smile:", pinned: true, }); assert.strictEqual( topic.get("escapedExcerpt"), - `This is a test topic smile`, + `This is a test topic smile`, "supports emojis" ); }); test("visible & invisible", function (assert) { - const topic = Topic.create(); + const topic = this.store.createRecord("topic"); assert.strictEqual(topic.visible, undefined); assert.strictEqual(topic.invisible, undefined); - const visibleTopic = Topic.create({ visible: true }); + const visibleTopic = this.store.createRecord("topic", { visible: true }); assert.strictEqual(visibleTopic.visible, true); assert.strictEqual(visibleTopic.invisible, false); - const invisibleTopic = Topic.create({ visible: false }); + const invisibleTopic = this.store.createRecord("topic", { visible: false }); assert.strictEqual(invisibleTopic.visible, false); assert.strictEqual(invisibleTopic.invisible, true); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js index e37011fec3..482ea1a0d8 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js @@ -11,13 +11,14 @@ import { import { NotificationLevels } from "discourse/lib/notification-levels"; import TopicTrackingState from "discourse/models/topic-tracking-state"; import User from "discourse/models/user"; -import Topic from "discourse/models/topic"; import createStore from "discourse/tests/helpers/create-store"; import sinon from "sinon"; +import { getOwner } from "discourse-common/lib/get-owner"; discourseModule("Unit | Model | topic-tracking-state", function (hooks) { hooks.beforeEach(function () { this.clock = fakeTime("2012-12-31 12:00"); + this.store = getOwner(this).lookup("service:store"); }); hooks.afterEach(function () { @@ -103,6 +104,16 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { 0, "pending tag new counts" ); + + // Ensure it is not throwing an error when filterTag is set and message payload is missing tags + trackingState.trackIncoming("tag/test/l/latest"); + trackingState.notifyIncoming({ + message_type: "new_topic", + topic_id: 4, + payload: { category_id: 2 }, + }); + const testTagCount = trackingState.countTags(["test"]); + assert.strictEqual(testTagCount["test"].unreadCount, 0); }); test("tag counts - with total", function (assert) { @@ -295,7 +306,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { trackingState.updateSeen(111, 7); const list = { topics: [ - Topic.create({ + this.store.createRecord("topic", { highest_post_number: null, id: 111, unread_posts: 10, @@ -325,7 +336,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { const list = { topics: [ - Topic.create({ + this.store.createRecord("topic", { id: 111, unseen: false, seen: true, @@ -366,12 +377,12 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { const list = { topics: [ - Topic.create({ + this.store.createRecord("topic", { id: 111, last_read_post_number: null, unseen: true, }), - Topic.create({ + this.store.createRecord("topic", { id: 222, last_read_post_number: null, unseen: true, @@ -400,7 +411,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { const list = { topics: [ - Topic.create({ + this.store.createRecord("topic", { id: 111, unseen: true, seen: false, @@ -409,7 +420,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { category_id: 1, tags: ["pending"], }), - Topic.create({ + this.store.createRecord("topic", { id: 222, unseen: false, seen: true, @@ -588,12 +599,12 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { assert.strictEqual(trackingState.filterTag, "test"); assert.strictEqual(trackingState.filter, "latest"); - trackingState.trackIncoming("c/cat/subcat/6/l/latest"); + trackingState.trackIncoming("c/cat/sub-cat/6/l/latest"); assert.strictEqual(trackingState.filterCategory.id, 6); assert.strictEqual(trackingState.filterTag, undefined); assert.strictEqual(trackingState.filter, "latest"); - trackingState.trackIncoming("tags/c/cat/subcat/6/test/l/latest"); + trackingState.trackIncoming("tags/c/cat/sub-cat/6/test/l/latest"); assert.strictEqual(trackingState.filterCategory.id, 6); assert.strictEqual(trackingState.filterTag, "test"); assert.strictEqual(trackingState.filter, "latest"); diff --git a/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js b/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js index dc7598055e..1c07f7dcf5 100644 --- a/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js +++ b/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js @@ -57,12 +57,18 @@ class NativeComponent extends Component { const TestStub = EmberObject.extend({ counter: 0, otherCounter: 0, + state: null, @debounce(50) increment(value) { this.counter += value; }, + @debounce(50, true) + setState(state) { + this.state = state; + }, + // Note: it only works in this particular order: // `@observes()` first, then `@debounce()` @observes("prop") @@ -149,6 +155,16 @@ module("Unit | Utils | decorators", function (hooks) { assert.strictEqual(stub.counter, 6); }); + test("immediate debounce", async function (assert) { + const stub = TestStub.create(); + + stub.setState("foo"); + stub.setState("bar"); + await settled(); + + assert.strictEqual(stub.state, "foo"); + }); + test("debounce works with @observe", async function (assert) { const stub = TestStub.create(); diff --git a/app/assets/javascripts/pretty-text/addon/engines/discourse-markdown-it.js b/app/assets/javascripts/pretty-text/addon/engines/discourse-markdown-it.js index 8ce6442575..e0813c2310 100644 --- a/app/assets/javascripts/pretty-text/addon/engines/discourse-markdown-it.js +++ b/app/assets/javascripts/pretty-text/addon/engines/discourse-markdown-it.js @@ -385,6 +385,12 @@ function setupMarkdownEngine(opts, featureConfig) { opts.pluginCallbacks.forEach(([feature, callback]) => { if (featureConfig[feature]) { + if (callback === null || callback === undefined) { + // eslint-disable-next-line no-console + console.log("BAD MARKDOWN CALLBACK FOUND"); + // eslint-disable-next-line no-console + console.log(`FEATURE IS: ${feature}`); + } opts.engine.use(callback); } }); diff --git a/app/assets/javascripts/pretty-text/package.json b/app/assets/javascripts/pretty-text/package.json index c2817a55ca..04de7a4790 100644 --- a/app/assets/javascripts/pretty-text/package.json +++ b/app/assets/javascripts/pretty-text/package.json @@ -34,7 +34,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit.js b/app/assets/javascripts/select-kit/addon/components/select-kit.js index 11348538cd..316e80749c 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit.js @@ -283,6 +283,7 @@ export default Component.extend( closeOnChange: true, limitMatches: null, placement: isDocumentRTL() ? "bottom-end" : "bottom-start", + verticalOffset: 3, filterComponent: "select-kit/select-kit-filter", selectedNameComponent: "selected-name", selectedChoiceComponent: "selected-choice", @@ -898,7 +899,7 @@ export default Component.extend( { name: "offset", options: { - offset: [0, 3], + offset: [0, this.selectKit.options.verticalOffset], }, }, { diff --git a/app/assets/javascripts/select-kit/package.json b/app/assets/javascripts/select-kit/package.json index eeaba25d81..9d369ac410 100644 --- a/app/assets/javascripts/select-kit/package.json +++ b/app/assets/javascripts/select-kit/package.json @@ -33,7 +33,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index 8580e6e6fc..e54831fef0 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -8,6 +8,8 @@ workbox.setConfig({ }); var authUrls = ["auth", "session/sso_login", "session/sso"].map(path => `<%= Discourse.base_path %>/${path}`); +var chatRegex = /\/chat\/channel\/(\d+)\//; +var inlineReplyIcon = "<%= UrlHelper.absolute("/images/push-notifications/inline_reply.png") %>"; var cacheVersion = "1"; var discourseCacheName = "discourse-" + cacheVersion; @@ -134,6 +136,16 @@ function showNotification(title, body, icon, badge, tag, baseUrl, url) { tag: tag } + if (chatRegex.test(url)) { + notificationOptions['actions'] = [{ + action: "reply", + title: "Reply", + placeholder: "reply", + type: "text", + icon: inlineReplyIcon + }]; + } + return self.registration.showNotification(title, notificationOptions); } @@ -163,18 +175,51 @@ self.addEventListener('notificationclick', function(event) { var url = event.notification.data.url; var baseUrl = event.notification.data.baseUrl; - // This looks to see if the current window is already open and - // focuses if it is - event.waitUntil( - clients.matchAll({ type: "window" }) - .then(function(clientList) { - var reusedClientWindow = clientList.some(function(client) { - if (client.url === baseUrl + url && 'focus' in client) { + if (event.action === "reply") { + let csrf; + fetch("/session/csrf", { + credentials: "include", + headers: { + Accept: "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error("Network response was not OK"); + } + return response.json(); + }) + .then((data) => { + csrf = data.csrf; + + let chatTest = url.match(chatRegex); + if (chatTest.length > 0) { + let chatChannel = chatTest[1]; + + fetch(`${baseUrl}/chat/${chatChannel}.json`, { + credentials: "include", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-CSRF-Token": csrf, + }, + body: `message=${event.reply}`, + method: "POST", + mode: "cors", + }); + } + }); + } else { + // This looks to see if the current window is already open and + // focuses if it is + event.waitUntil( + clients.matchAll({ type: "window" }).then(function (clientList) { + var reusedClientWindow = clientList.some(function (client) { + if (client.url === baseUrl + url && "focus" in client) { client.focus(); return true; } - if ('postMessage' in client && 'focus' in client) { + if ("postMessage" in client && "focus" in client) { client.focus(); client.postMessage({ url: url }); return true; @@ -182,9 +227,11 @@ self.addEventListener('notificationclick', function(event) { return false; }); - if (!reusedClientWindow && clients.openWindow) return clients.openWindow(baseUrl + url); + if (!reusedClientWindow && clients.openWindow) + return clients.openWindow(baseUrl + url); }) - ); + ); + } }); self.addEventListener('message', function(event) { diff --git a/app/assets/javascripts/truth-helpers/package.json b/app/assets/javascripts/truth-helpers/package.json index bf2bbc2606..ffe17088c1 100644 --- a/app/assets/javascripts/truth-helpers/package.json +++ b/app/assets/javascripts/truth-helpers/package.json @@ -33,7 +33,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/wizard/package.json b/app/assets/javascripts/wizard/package.json index 6c0f1c383f..79c915026a 100644 --- a/app/assets/javascripts/wizard/package.json +++ b/app/assets/javascripts/wizard/package.json @@ -34,7 +34,7 @@ "ember-disable-prototype-extensions": "^1.1.3", "ember-load-initializers": "^2.1.1", "ember-resolver": "^8.0.3", - "ember-source": "~3.28.8", + "ember-source": "~3.28.10", "ember-source-channel-url": "^3.0.0", "loader.js": "^4.7.0" }, diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index 4dee6eb138..6c9a00b534 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -18,42 +18,42 @@ "@babel/highlight" "^7.18.6" "@babel/compat-data@^7.13.11", "@babel/compat-data@^7.16.4", "@babel/compat-data@^7.16.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" - integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== - -"@babel/compat-data@^7.19.3": version "7.19.3" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.3.tgz#707b939793f867f5a73b2666e6d9a3396eb03151" integrity sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw== -"@babel/core@^7.1.6", "@babel/core@^7.12.0", "@babel/core@^7.13.8", "@babel/core@^7.16.7", "@babel/core@^7.19.6", "@babel/core@^7.3.4": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.6.tgz#7122ae4f5c5a37c0946c066149abd8e75f81540f" - integrity sha512-D2Ue4KHpc6Ys2+AxpIx1BZ8+UegLLLE2p3KJEuJRKmokHOtl49jQ5ny1773KsGLZs8MQvBidAF6yWUJxRqtKtg== +"@babel/compat-data@^7.20.0": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30" + integrity sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ== + +"@babel/core@^7.1.6", "@babel/core@^7.12.0", "@babel/core@^7.13.8", "@babel/core@^7.16.7", "@babel/core@^7.20.2", "@babel/core@^7.3.4": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.2.tgz#8dc9b1620a673f92d3624bd926dc49a52cf25b92" + integrity sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.6" - "@babel/helper-compilation-targets" "^7.19.3" - "@babel/helper-module-transforms" "^7.19.6" - "@babel/helpers" "^7.19.4" - "@babel/parser" "^7.19.6" + "@babel/generator" "^7.20.2" + "@babel/helper-compilation-targets" "^7.20.0" + "@babel/helper-module-transforms" "^7.20.2" + "@babel/helpers" "^7.20.1" + "@babel/parser" "^7.20.2" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.2" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.1" semver "^6.3.0" -"@babel/generator@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.6.tgz#9e481a3fe9ca6261c972645ae3904ec0f9b34a1d" - integrity sha512-oHGRUQeoX1QrKeJIKVe0hwjGqNnVYsM5Nep5zo0uE0m42sLH+Fsd2pStJ5sRM1bNyTUUoz0pe2lTeMJrb/taTA== +"@babel/generator@^7.20.1", "@babel/generator@^7.20.2": + version "7.20.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.3.tgz#e58c9ae2f7bf7fdf4899160cf1e04400a82cd641" + integrity sha512-Wl5ilw2UD1+ZYprHVprxHZJCFeBWlzZYOovE4SDYLZnqCOD11j+0QzNeEWKLLTWM7nixrZEh7vNIyb76MyJg3A== dependencies: - "@babel/types" "^7.19.4" + "@babel/types" "^7.20.2" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -72,12 +72,12 @@ "@babel/helper-explode-assignable-expression" "^7.16.7" "@babel/types" "^7.16.7" -"@babel/helper-compilation-targets@^7.12.0", "@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.16.7", "@babel/helper-compilation-targets@^7.19.3": - version "7.19.3" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz#a10a04588125675d7c7ae299af86fa1b2ee038ca" - integrity sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg== +"@babel/helper-compilation-targets@^7.12.0", "@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.16.7", "@babel/helper-compilation-targets@^7.20.0": + version "7.20.0" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz#6bf5374d424e1b3922822f1d9bdaa43b1a139d0a" + integrity sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ== dependencies: - "@babel/compat-data" "^7.19.3" + "@babel/compat-data" "^7.20.0" "@babel/helper-validator-option" "^7.18.6" browserslist "^4.21.3" semver "^6.3.0" @@ -131,12 +131,7 @@ resolve "^1.14.2" semver "^6.1.2" -"@babel/helper-environment-visitor@^7.16.7": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.6.tgz#b7eee2b5b9d70602e59d1a6cad7dd24de7ca6cd7" - integrity sha512-8n6gSfn2baOY+qlp+VSzsosjCVGFqWKmDF0cCWOybh52Dw3SEyoWR1KrhMJASjLwIEkkAufZ0xvr+SxLHSpy2Q== - -"@babel/helper-environment-visitor@^7.18.9": +"@babel/helper-environment-visitor@^7.16.7", "@babel/helper-environment-visitor@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== @@ -148,15 +143,7 @@ dependencies: "@babel/types" "^7.16.7" -"@babel/helper-function-name@^7.16.7": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.6.tgz#8334fecb0afba66e6d87a7e8c6bb7fed79926b83" - integrity sha512-0mWMxV1aC97dhjCah5U5Ua7668r5ZmSC2DLfH2EZnf9c3/dHZKiFa5pRLMH5tjSl471tY6496ZWk/kjNONBxhw== - dependencies: - "@babel/template" "^7.18.6" - "@babel/types" "^7.18.6" - -"@babel/helper-function-name@^7.19.0": +"@babel/helper-function-name@^7.16.7", "@babel/helper-function-name@^7.19.0": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== @@ -185,19 +172,19 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.16.7", "@babel/helper-module-transforms@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.6.tgz#6c52cc3ac63b70952d33ee987cbee1c9368b533f" - integrity sha512-fCmcfQo/KYr/VXXDIyd3CBGZ6AFhPFy1TfSEJ+PilGVlQT6jcbqtHAM4C1EciRqMza7/TpOUZliuSH+U6HAhJw== +"@babel/helper-module-transforms@^7.16.7", "@babel/helper-module-transforms@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz#ac53da669501edd37e658602a21ba14c08748712" + integrity sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.19.4" + "@babel/helper-simple-access" "^7.20.2" "@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-validator-identifier" "^7.19.1" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.2" "@babel/helper-optimise-call-expression@^7.16.7": version "7.16.7" @@ -232,19 +219,19 @@ "@babel/types" "^7.16.7" "@babel/helper-simple-access@^7.16.7": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" - integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-simple-access@^7.19.4": version "7.19.4" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.19.4.tgz#be553f4951ac6352df2567f7daa19a0ee15668e7" integrity sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg== dependencies: "@babel/types" "^7.19.4" +"@babel/helper-simple-access@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9" + integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA== + dependencies: + "@babel/types" "^7.20.2" + "@babel/helper-skip-transparent-expression-wrappers@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz#0ee3388070147c3ae051e487eca3ebb0e2e8bb09" @@ -264,12 +251,7 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== -"@babel/helper-validator-identifier@^7.16.7", "@babel/helper-validator-identifier@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" - integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== - -"@babel/helper-validator-identifier@^7.19.1": +"@babel/helper-validator-identifier@^7.16.7", "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== @@ -289,14 +271,14 @@ "@babel/traverse" "^7.16.8" "@babel/types" "^7.16.8" -"@babel/helpers@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.4.tgz#42154945f87b8148df7203a25c31ba9a73be46c5" - integrity sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw== +"@babel/helpers@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.1.tgz#2ab7a0fcb0a03b5bf76629196ed63c2d7311f4c9" + integrity sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg== dependencies: "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.4" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.0" "@babel/highlight@^7.18.6": version "7.18.6" @@ -307,10 +289,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.18.10", "@babel/parser@^7.19.6", "@babel/parser@^7.4.5": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.6.tgz#b923430cb94f58a7eae8facbffa9efd19130e7f8" - integrity sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA== +"@babel/parser@^7.18.10", "@babel/parser@^7.20.1", "@babel/parser@^7.20.2", "@babel/parser@^7.4.5": + version "7.20.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" + integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.7": version "7.16.7" @@ -985,12 +967,12 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/standalone@^7.20.0": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.20.0.tgz#0d5b9b57bde3923503e84a9ef1eef6da244b7d61" - integrity sha512-8toFReoMyknVN538KZYS9HJLUlpvibQiPQqt8TYFeyV+FlZUmM8TG2zcS8q4vAijCRLoAKT1EzeBVvbxjMfi9A== +"@babel/standalone@^7.20.4": + version "7.20.4" + resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.20.4.tgz#eb48c8d43087e95f3795322c28d84577f881bb11" + integrity sha512-27bv4h47jbaFZ7+e7gT1VEo9PNL1ynxqUX6/BERLz1qxm/5gzpbcHX+47VnSeYHyEyGZkRznpSOd8zPBhiz6tw== -"@babel/template@^7.16.7", "@babel/template@^7.18.10", "@babel/template@^7.18.6": +"@babel/template@^7.16.7", "@babel/template@^7.18.10": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== @@ -999,26 +981,26 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.13.0", "@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8", "@babel/traverse@^7.19.4", "@babel/traverse@^7.19.6", "@babel/traverse@^7.4.5": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.6.tgz#7b4c865611df6d99cb131eec2e8ac71656a490dc" - integrity sha512-6l5HrUCzFM04mfbG09AagtYyR2P0B71B1wN7PfSPiksDPz2k5H9CBC1tcZpz2M8OxbKTPccByoOJ22rUKbpmQQ== +"@babel/traverse@^7.1.6", "@babel/traverse@^7.13.0", "@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8", "@babel/traverse@^7.20.1", "@babel/traverse@^7.4.5": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" + integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.6" + "@babel/generator" "^7.20.1" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/parser" "^7.20.1" + "@babel/types" "^7.20.0" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.1.6", "@babel/types@^7.16.0", "@babel/types@^7.16.7", "@babel/types@^7.16.8", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.19.0", "@babel/types@^7.19.4", "@babel/types@^7.4.4", "@babel/types@^7.7.2": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.4.tgz#0dd5c91c573a202d600490a35b33246fed8a41c7" - integrity sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw== +"@babel/types@^7.1.6", "@babel/types@^7.16.0", "@babel/types@^7.16.7", "@babel/types@^7.16.8", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.19.0", "@babel/types@^7.19.4", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.4.4", "@babel/types@^7.7.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.2.tgz#67ac09266606190f496322dbaff360fdaa5e7842" + integrity sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog== dependencies: "@babel/helper-string-parser" "^7.19.4" "@babel/helper-validator-identifier" "^7.19.1" @@ -1335,16 +1317,11 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.stat@^2.0.2": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz#a3f2dd61bab43b8db8fa108a121cfffe4c676655" - integrity sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q== - "@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" @@ -1363,26 +1340,40 @@ resolved "https://registry.yarnpkg.com/@simple-dom/interface/-/interface-1.4.0.tgz#e8feea579232017f89b0138e2726facda6fbb71f" integrity sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA== -"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": +"@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== dependencies: type-detect "4.0.8" -"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2": +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^7.0.4": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" + integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/fake-timers@^9.1.2": version "9.1.2" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c" integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw== dependencies: "@sinonjs/commons" "^1.7.0" -"@sinonjs/samsam@^6.1.1": - version "6.1.1" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1" - integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA== +"@sinonjs/samsam@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-7.0.1.tgz#5b5fa31c554636f78308439d220986b9523fc51f" + integrity sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw== dependencies: - "@sinonjs/commons" "^1.6.0" + "@sinonjs/commons" "^2.0.0" lodash.get "^4.4.2" type-detect "^4.0.8" @@ -1391,6 +1382,11 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -1428,6 +1424,16 @@ dependencies: "@types/node" "*" +"@types/cookie@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" + integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== + +"@types/cors@^2.8.12": + version "2.8.12" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" + integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" @@ -1505,10 +1511,10 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== -"@types/node@*": - version "14.14.37" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e" - integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== +"@types/node@*", "@types/node@>=10.0.0": + version "18.11.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" + integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== "@types/qs@*": version "6.9.6" @@ -1875,6 +1881,11 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" +"@xmldom/xmldom@^0.8.0": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.5.tgz#7f4b797cfda39355b512b4cfcc66b49b5d93d5f3" + integrity sha512-0dpjDLeCXYThL2YhqZcd/spuwoH+dmnFoND9ZxZkAYxp1IJUB2GP16ow2MJRsjVxy8j1Qv8BJRmN5GKnbDKCmQ== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -1943,11 +1954,6 @@ acorn@^8.1.0, acorn@^8.7.1, acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== -after@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" - integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8= - agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -2096,16 +2102,16 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -aproba@^1.0.3, aproba@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - "aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== +aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + are-we-there-yet@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd" @@ -2114,14 +2120,6 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -2181,11 +2179,6 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= -arraybuffer.slice@~0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" - integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog== - asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -2576,27 +2569,17 @@ backbone@^1.1.2: dependencies: underscore ">=1.8.3" -backo2@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" - integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-arraybuffer@0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812" - integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI= - base64-js@^1.0.2: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@2.0.0: +base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -2653,12 +2636,7 @@ blank-object@^1.0.1: resolved "https://registry.yarnpkg.com/blank-object/-/blank-object-1.0.2.tgz#f990793fbe9a8c8dd013fb3219420bec81d5f4b9" integrity sha1-+ZB5P76ajI3QE/syGUIL7IHV9Lk= -blob@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683" - integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig== - -bluebird@^3.1.1, bluebird@^3.4.6, bluebird@^3.5.5: +bluebird@^3.4.6, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -3282,18 +3260,7 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.14.5, browserslist@^4.19.1: - version "4.20.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.4.tgz#98096c9042af689ee1e0271333dbc564b8ce4477" - integrity sha512-ok1d+1WpnU24XYN7oC3QWgTyMhY/avPJ/r9T00xxvUOIparA/gc+UPUMaod3i+G6s+nI2nUb9xZ5k794uIwShw== - dependencies: - caniuse-lite "^1.0.30001349" - electron-to-chromium "^1.4.147" - escalade "^3.1.1" - node-releases "^2.0.5" - picocolors "^1.0.0" - -browserslist@^4.21.3: +browserslist@^4.14.5, browserslist@^4.19.1, browserslist@^4.21.3: version "4.21.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== @@ -3422,11 +3389,6 @@ can-symlink@^1.0.0: dependencies: tmp "0.0.28" -caniuse-lite@^1.0.30001349: - version "1.0.30001357" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001357.tgz#dec7fc4158ef6ad24690d0eec7b91f32b8cb1b5d" - integrity sha512-b+KbWHdHePp+ZpNj+RDHFChZmuN+J5EvuQUlee9jOQIUAdhv9uvAZeEtUeLAknXbkiu1uxjQ9NLp1ie894CuWg== - caniuse-lite@^1.0.30001370: version "1.0.30001399" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001399.tgz#1bf994ca375d7f33f8d01ce03b7d5139e8587873" @@ -3642,11 +3604,6 @@ clone@^2.1.2: resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - coffee-script@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/coffee-script/-/coffee-script-1.2.0.tgz#b5e61e55f1ca8c4a9eb87d53aa0657ea43125b91" @@ -3738,26 +3695,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= -component-bind@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" - integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E= - -component-emitter@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= - -component-emitter@^1.2.1, component-emitter@~1.3.0: +component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== -component-inherit@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" - integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM= - compressible@~2.0.16: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -3820,7 +3762,7 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== -console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0: +console-control-strings@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= @@ -3836,12 +3778,12 @@ console-ui@^3.0.4, console-ui@^3.1.2: ora "^3.4.0" through2 "^3.0.1" -consolidate@^0.15.1: - version "0.15.1" - resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" - integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw== +consolidate@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" + integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== dependencies: - bluebird "^3.1.1" + bluebird "^3.7.2" constants-browserify@^1.0.0: version "1.0.0" @@ -3934,6 +3876,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -4076,7 +4026,7 @@ debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, debug@^2.3. dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -4090,20 +4040,6 @@ debug@^3.0.1, debug@^3.1.0, debug@^3.1.1: dependencies: ms "^2.1.1" -debug@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@~4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - decimal.js@^10.4.1: version "10.4.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.1.tgz#be75eeac4a2281aace80c1a8753587c27ef053e7" @@ -4295,11 +4231,6 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.4.147: - version "1.4.161" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.161.tgz#49cb5b35385bfee6cc439d0a04fbba7a7a7f08a1" - integrity sha512-sTjBRhqh6wFodzZtc5Iu8/R95OkwaPNn7tj/TaDU5nu/5EFiQDtADGAXdR4tJcTEHlYfJpHqigzJqHvPgehP8A== - electron-to-chromium@^1.4.202: version "1.4.250" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.250.tgz#e4535fc00d17b9a719bc688352c4a185acc2a347" @@ -4938,10 +4869,10 @@ ember-source-channel-url@^3.0.0: dependencies: node-fetch "^2.6.0" -ember-source@~3.28.8: - version "3.28.9" - resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-3.28.9.tgz#804c56b2d71d3cc3decff15a3273bb35d668300a" - integrity sha512-Fy7V3yvj+3oyo2+ke52aaihKMcFnnF7Oj9ixj547yzh2faqRfqouB5ZSiwXFH8rxw22rKaM8DiuQO4JN2Ay6xQ== +ember-source@~3.28.10: + version "3.28.10" + resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-3.28.10.tgz#f4be7e2852d421a558f686505748f4c88f6d6ae6" + integrity sha512-TH8ug2rRUq6pLwqjciwvnuF8GDKBXNW2v5mvDkkf+k5S84XVHPjn3K0q2uGaR2W/mCDYg+mGmqu/PIGy0STx9Q== dependencies: "@babel/helper-module-imports" "^7.8.3" "@babel/plugin-transform-block-scoping" "^7.8.3" @@ -5001,45 +4932,26 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" -engine.io-client@~3.5.0: - version "3.5.1" - resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.1.tgz#b500458a39c0cd197a921e0e759721a746d0bdb9" - integrity sha512-oVu9kBkGbcggulyVF0kz6BV3ganqUeqXvD79WOFKa+11oK692w1NyFkuEj4xrkFRpZhn92QOqTk4RQq5LiBXbQ== - dependencies: - component-emitter "~1.3.0" - component-inherit "0.0.3" - debug "~3.1.0" - engine.io-parser "~2.2.0" - has-cors "1.1.0" - indexof "0.0.1" - parseqs "0.0.6" - parseuri "0.0.6" - ws "~7.4.2" - xmlhttprequest-ssl "~1.5.4" - yeast "0.1.2" +engine.io-parser@~5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0" + integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg== -engine.io-parser@~2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7" - integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg== - dependencies: - after "0.8.2" - arraybuffer.slice "~0.0.7" - base64-arraybuffer "0.1.4" - blob "0.0.5" - has-binary2 "~1.0.2" - -engine.io@~3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b" - integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA== +engine.io@~6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.2.0.tgz#003bec48f6815926f2b1b17873e576acd54f41d0" + integrity sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg== dependencies: + "@types/cookie" "^0.4.1" + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" accepts "~1.3.4" base64id "2.0.0" cookie "~0.4.1" - debug "~4.1.0" - engine.io-parser "~2.2.0" - ws "~7.4.2" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.0.3" + ws "~8.2.3" enhanced-resolve@^4.0.0, enhanced-resolve@^4.5.0: version "4.5.0" @@ -6048,20 +5960,6 @@ gauge@^4.0.3: strip-ansi "^6.0.1" wide-align "^1.1.5" -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -6312,18 +6210,6 @@ has-bigints@^1.0.1: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== -has-binary2@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d" - integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw== - dependencies: - isarray "2.0.1" - -has-cors@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" - integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -6346,7 +6232,7 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" -has-unicode@^2.0.0, has-unicode@^2.0.1: +has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= @@ -6627,11 +6513,6 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indexof@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" - integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10= - individual@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/individual/-/individual-3.0.0.tgz#e7ca4f85f8957b018734f285750dc22ec2f9862d" @@ -6871,13 +6752,6 @@ is-finite@^1.0.0: resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" @@ -7049,11 +6923,6 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= -isarray@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" - integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4= - isbinaryfile@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b" @@ -7371,9 +7240,9 @@ loader-runner@^4.2.0: integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw== loader-utils@^1.2.3, loader-utils@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" - integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + version "1.4.1" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.1.tgz#278ad7006660bccc4d2c0c1578e17c5c78d5c0e0" + integrity sha512-1Qo97Y2oKaU+Ro2xnDMR26g1BwMT29jNbem1EvcujW2jqt+j5COXyscjM7bLQkM9HaxI7pkWeW7gnI072yMI9Q== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" @@ -7946,11 +7815,16 @@ minimist@^0.2.1: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.1.tgz#827ba4e7593464e7c221e8c5bed930904ee2c455" integrity sha512-GY8fANSrTMfBVfInqJAY41QkOM+upUTytK1jZ0c8+3HdHrJxBJ3rF5i9moClXTE8uUSnUo8cAsCoxDXvSY4DHg== -minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimist@^1.2.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" + integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== + minipass@^2.2.0: version "2.9.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" @@ -8048,10 +7922,10 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mustache@^3.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/mustache/-/mustache-3.2.1.tgz#89e78a9d207d78f2799b1e95764a25bf71a28322" - integrity sha512-RERvMFdLpaFfSRIEe632yDm5nsd0SDKn8hGmcUwswnyiE5mtdZLDybtHAz6hjJhawokF0hXvGLtx9mrQfm6FkA== +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== mute-stream@0.0.7: version "0.0.7" @@ -8120,21 +7994,21 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -nise@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3" - integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A== +nise@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.2.tgz#a7b8909c216b3491fd4fc0b124efb69f3939b449" + integrity sha512-+gQjFi8v+tkfCuSCxfURHLhRhniE/+IaYbIphxAN2JRR9SHKhY8hgXpaXiYfHdw+gcGe4buxgbprBQFab9FkhA== dependencies: - "@sinonjs/commons" "^1.8.3" - "@sinonjs/fake-timers" ">=5" + "@sinonjs/commons" "^2.0.0" + "@sinonjs/fake-timers" "^7.0.4" "@sinonjs/text-encoding" "^0.7.1" just-extend "^4.0.2" path-to-regexp "^1.7.0" -node-fetch@^2.6.0, node-fetch@^2.6.6: - version "2.6.6" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" - integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA== +node-fetch@^2.6.0, node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: whatwg-url "^5.0.0" @@ -8177,23 +8051,18 @@ node-modules-path@^1.0.0: resolved "https://registry.yarnpkg.com/node-modules-path/-/node-modules-path-1.0.2.tgz#e3acede9b7baf4bc336e3496b58e5b40d517056e" integrity sha512-6Gbjq+d7uhkO7epaKi5DNgUJn7H0gEyA4Jg0Mo1uQOi3Rk50G83LtmhhFyw0LxnAFhtlspkiiw52ISP13qzcBg== -node-notifier@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-9.0.1.tgz#cea837f4c5e733936c7b9005e6545cea825d1af4" - integrity sha512-fPNFIp2hF/Dq7qLDzSg4vZ0J4e9v60gJR+Qx7RbjbWqzPDdEqeVpEx5CFeDAELIl+A/woaaNn1fQ5nEVerMxJg== +node-notifier@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-10.0.1.tgz#0e82014a15a8456c4cfcdb25858750399ae5f1c7" + integrity sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ== dependencies: growly "^1.3.0" is-wsl "^2.2.0" - semver "^7.3.2" + semver "^7.3.5" shellwords "^0.1.1" - uuid "^8.3.0" + uuid "^8.3.2" which "^2.0.2" -node-releases@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.5.tgz#280ed5bc3eba0d96ce44897d8aee478bfb3d9666" - integrity sha512-U9h1NLROZTq9uE1SNffn6WuPDg8icmi3ns4rEl/oTfIle4iLjTliCzgTsbaIFMq/Xn078/lfY/BL0GWZ+psK4Q== - node-releases@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" @@ -8260,16 +8129,6 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npmlog@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - npmlog@^6.0.0: version "6.0.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830" @@ -8280,17 +8139,12 @@ npmlog@^6.0.0: gauge "^4.0.3" set-blocking "^2.0.0" -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - nwsapi@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0" integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw== -object-assign@4.1.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.1.1, object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -8563,16 +8417,6 @@ parse5@^7.1.1: dependencies: entities "^4.4.0" -parseqs@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5" - integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== - -parseuri@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a" - integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== - parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -9027,7 +8871,7 @@ raw-body@~1.1.0: bytes "1" string_decoder "0.10" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -9305,7 +9149,7 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@^2.2.8, rimraf@^2.3.4, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@^2.5.3, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: +rimraf@^2.2.8, rimraf@^2.3.4, rimraf@^2.4.3, rimraf@^2.5.3, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -9422,10 +9266,10 @@ sane@^4.0.0, sane@^4.1.0: minimist "^1.1.1" walker "~1.0.5" -sass@^1.55.0: - version "1.55.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.55.0.tgz#0c4d3c293cfe8f8a2e8d3b666e1cf1bff8065d1c" - integrity sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A== +sass@^1.56.0: + version "1.56.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.56.0.tgz#134032075a3223c8d49cb5c35e091e5ba1de8e0a" + integrity sha512-WFJ9XrpkcnqZcYuLRJh5qiV6ibQOR4AezleeEjTjMsCocYW59dEG19U3fwTTXxzi2Ed3yjPBp727hbbj53pHFw== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -9540,7 +9384,7 @@ serve-static@1.14.1: parseurl "~1.3.3" send "0.17.1" -set-blocking@^2.0.0, set-blocking@~2.0.0: +set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -9638,16 +9482,16 @@ simple-html-tokenizer@^0.5.11: resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.5.11.tgz#4c5186083c164ba22a7b477b7687ac056ad6b1d9" integrity sha512-C2WEK/Z3HoSFbYq8tI7ni3eOo/NneSPRoPpcM7WdLjFOArFuyXEjAoCdOC3DgMfRyziZQ1hCNR4mrNdWEvD0og== -sinon@^14.0.1: - version "14.0.1" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.1.tgz#9f02e13ad86b695c0c554525e3bf7f8245b31a9c" - integrity sha512-JhJ0jCiyBWVAHDS+YSjgEbDn7Wgz9iIjA1/RK+eseJN0vAAWIWiXBdrnb92ELPyjsfreCYntD1ORtLSfIrlvSQ== +sinon@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.2.tgz#585a81a3c7b22cf950762ac4e7c28eb8b151c46f" + integrity sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w== dependencies: - "@sinonjs/commons" "^1.8.3" + "@sinonjs/commons" "^2.0.0" "@sinonjs/fake-timers" "^9.1.2" - "@sinonjs/samsam" "^6.1.1" + "@sinonjs/samsam" "^7.0.1" diff "^5.0.0" - nise "^5.1.1" + nise "^5.1.2" supports-color "^7.2.0" slash@^1.0.0: @@ -9690,57 +9534,30 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -socket.io-adapter@~1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9" - integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g== - -socket.io-client@2.4.0: +socket.io-adapter@~2.4.0: version "2.4.0" - resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35" - integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ== - dependencies: - backo2 "1.0.2" - component-bind "1.0.0" - component-emitter "~1.3.0" - debug "~3.1.0" - engine.io-client "~3.5.0" - has-binary2 "~1.0.2" - indexof "0.0.1" - parseqs "0.0.6" - parseuri "0.0.6" - socket.io-parser "~3.3.0" - to-array "0.1.4" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz#b50a4a9ecdd00c34d4c8c808224daa1a786152a6" + integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg== -socket.io-parser@~3.3.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6" - integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg== +socket.io-parser@~4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5" + integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g== dependencies: - component-emitter "~1.3.0" - debug "~3.1.0" - isarray "2.0.1" + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" -socket.io-parser@~3.4.0: - version "3.4.1" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a" - integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A== +socket.io@^4.1.2: + version "4.5.3" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.5.3.tgz#44dffea48d7f5aa41df4a66377c386b953bc521c" + integrity sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg== dependencies: - component-emitter "1.2.1" - debug "~4.1.0" - isarray "2.0.1" - -socket.io@^2.1.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2" - integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w== - dependencies: - debug "~4.1.0" - engine.io "~3.5.0" - has-binary2 "~1.0.2" - socket.io-adapter "~1.1.0" - socket.io-client "2.4.0" - socket.io-parser "~3.4.0" + accepts "~1.3.4" + base64id "~2.0.0" + debug "~4.3.2" + engine.io "~6.2.0" + socket.io-adapter "~2.4.0" + socket.io-parser "~4.2.0" sort-object-keys@^1.1.3: version "1.1.3" @@ -9940,24 +9757,7 @@ string-template@~0.2.0, string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -"string-width@^1.0.2 || 2", string-width@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9966,14 +9766,13 @@ string-width@^1.0.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^4.1.0, string-width@^4.2.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" - integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== +string-width@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.0" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" string.prototype.matchall@^4.0.5: version "4.0.6" @@ -10024,7 +9823,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^3.0.0, strip-ansi@^3.0.1: +strip-ansi@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= @@ -10227,16 +10026,17 @@ terser@^5.3.0, terser@^5.7.2: source-map-support "~0.5.20" testem@^3.2.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/testem/-/testem-3.4.0.tgz#48ab6b98e96085eeddac1fb46337872b13e9e06c" - integrity sha512-09mhy7fQj9o1W1c/Lfcs56FYqhFiZrXZjnOSJn+KxWAdYjbF5yHEuGrg+L5ooBlleCGD9r1TQwKd3+DixskT0Q== + version "3.9.0" + resolved "https://registry.yarnpkg.com/testem/-/testem-3.9.0.tgz#a82ccf01e5a248e3924244186e348c665ab90f7d" + integrity sha512-YTxCYKj0cc8uUSKEziJtSC5T/pw4fQnY0ZXNOyvAFgrijfsN9NxmncJZOHLhPgFOuhbRd5i+DBQxw0Cpe0SEFg== dependencies: + "@xmldom/xmldom" "^0.8.0" backbone "^1.1.2" bluebird "^3.4.6" charm "^1.0.0" commander "^2.6.0" compression "^1.7.4" - consolidate "^0.15.1" + consolidate "^0.16.0" execa "^1.0.0" express "^4.10.7" fireworm "^0.7.0" @@ -10248,18 +10048,17 @@ testem@^3.2.0: lodash.clonedeep "^4.4.1" lodash.find "^4.5.1" lodash.uniqby "^4.7.0" - mkdirp "^0.5.1" - mustache "^3.0.0" - node-notifier "^9.0.1" - npmlog "^4.0.0" + mkdirp "^1.0.4" + mustache "^4.2.0" + node-notifier "^10.0.0" + npmlog "^6.0.0" printf "^0.6.1" - rimraf "^2.4.4" - socket.io "^2.1.0" + rimraf "^3.0.2" + socket.io "^4.1.2" spawn-args "^0.2.0" styled_string "0.0.1" tap-parser "^7.0.0" tmp "0.0.33" - xmldom "^0.1.19" text-table@^0.2.0: version "0.2.0" @@ -10348,14 +10147,9 @@ tmp@^0.1.0: rimraf "^2.6.3" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= - -to-array@0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" - integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-arraybuffer@^1.0.0: version "1.0.1" @@ -10729,7 +10523,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^8.3.0, uuid@^8.3.2: +uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== @@ -10749,7 +10543,7 @@ validate-peer-dependencies@^1.2.0: resolve-package-path "^3.1.0" semver "^7.3.2" -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= @@ -11029,13 +10823,6 @@ which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" @@ -11104,10 +10891,10 @@ ws@^8.9.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== -ws@~7.4.2: - version "7.4.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" - integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== +ws@~8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" + integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== x-is-array@0.1.0: version "0.1.0" @@ -11134,16 +10921,6 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xmldom@^0.1.19: - version "0.1.31" - resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" - integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== - -xmlhttprequest-ssl@~1.5.4: - version "1.5.5" - resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" - integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= - xss@^1.0.14: version "1.0.14" resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694" @@ -11180,11 +10957,6 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" -yeast@0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" - integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= - yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" diff --git a/app/assets/stylesheets/common/base/_index.scss b/app/assets/stylesheets/common/base/_index.scss index 163ec74b3c..eb810f8194 100644 --- a/app/assets/stylesheets/common/base/_index.scss +++ b/app/assets/stylesheets/common/base/_index.scss @@ -12,7 +12,6 @@ @import "crawler_layout"; @import "d-icon"; @import "d-popover"; -@import "d-onboarding"; @import "dialog"; @import "directory"; @import "discourse"; @@ -59,4 +58,5 @@ @import "topic"; @import "upload"; @import "user-badges"; +@import "user-tips"; @import "user"; diff --git a/app/assets/stylesheets/common/base/d-onboarding.scss b/app/assets/stylesheets/common/base/d-onboarding.scss deleted file mode 100644 index f7f795d9d7..0000000000 --- a/app/assets/stylesheets/common/base/d-onboarding.scss +++ /dev/null @@ -1,37 +0,0 @@ -.onboarding-popup-container { - min-width: 300px; - padding: 0.5em; - text-align: left; - - .onboarding-popup-title { - font-size: $font-up-2; - font-weight: bold; - } - - .onboarding-popup-content { - margin-top: 0.25em; - } - - .onboarding-popup-buttons { - margin-top: 1em; - } -} - -.tippy-box[data-theme~="d-onboarding"][data-placement^="left"] - > .tippy-svg-arrow - > svg { - left: 11px; -} - -.tippy-box[data-theme~="d-onboarding"][data-placement^="bottom"] - > .tippy-svg-arrow - > svg { - top: -13px; - left: -1px; -} - -.tippy-box[data-theme~="d-onboarding"] > .tippy-svg-arrow:after, -.tippy-box[data-theme~="d-onboarding"] > .tippy-svg-arrow > svg { - width: 18px; - height: 18px; -} diff --git a/app/assets/stylesheets/common/base/sidebar-section-link.scss b/app/assets/stylesheets/common/base/sidebar-section-link.scss index b885a50bee..79a5043c71 100644 --- a/app/assets/stylesheets/common/base/sidebar-section-link.scss +++ b/app/assets/stylesheets/common/base/sidebar-section-link.scss @@ -36,7 +36,7 @@ @include ellipsis; padding-left: 0.5em; text-align: right; - color: var(--tertiary); + color: var(--primary-high); font-size: var(--font-down-1); font-weight: normal; margin-left: auto; @@ -45,6 +45,7 @@ .sidebar-section-link-suffix { margin-left: 0.25rem; font-size: var(--font-down-4); + color: var(--tertiary-medium); } .sidebar-section-link-content-text { diff --git a/app/assets/stylesheets/common/base/user-tips.scss b/app/assets/stylesheets/common/base/user-tips.scss new file mode 100644 index 0000000000..a20bc51cce --- /dev/null +++ b/app/assets/stylesheets/common/base/user-tips.scss @@ -0,0 +1,37 @@ +.user-tip-container { + min-width: 300px; + padding: 0.5em; + text-align: left; + + .user-tip-title { + font-size: $font-up-2; + font-weight: bold; + } + + .user-tip-content { + margin-top: 0.25em; + } + + .user-tip-buttons { + margin-top: 1em; + } +} + +.tippy-box[data-theme~="user-tips"][data-placement^="left"] + > .tippy-svg-arrow + > svg { + left: 11px; +} + +.tippy-box[data-theme~="user-tips"][data-placement^="bottom"] + > .tippy-svg-arrow + > svg { + top: -13px; + left: -1px; +} + +.tippy-box[data-theme~="user-tips"] > .tippy-svg-arrow:after, +.tippy-box[data-theme~="user-tips"] > .tippy-svg-arrow > svg { + width: 18px; + height: 18px; +} diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index e91f197542..1c274156ae 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -269,9 +269,6 @@ display: flex; flex-direction: row; justify-content: space-between; - position: absolute; - top: 48px; - width: calc(100% - 40px); z-index: z("usercard"); &__content { width: 70%; diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index e5ff07b7da..dd4e81e0e4 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -342,6 +342,18 @@ span.highlighted { /* must render on top of topic-body + topic-meta-data, otherwise not tappable */ } +.small-action .topic-avatar { + display: flex; + align-self: stretch; + align-items: flex-start; + margin-right: 0; + float: unset; + height: auto; + .d-icon { + font-size: 1.8em; + } +} + .topic-meta-data { margin-left: 50px; font-size: var(--font-down-1); diff --git a/app/assets/stylesheets/publish.scss b/app/assets/stylesheets/publish.scss index cdd768b751..2055ea405b 100644 --- a/app/assets/stylesheets/publish.scss +++ b/app/assets/stylesheets/publish.scss @@ -1,11 +1,18 @@ @import "common"; .published-page-content-wrapper { + box-sizing: border-box; margin: 2em auto; - max-width: 800px; + padding: 0.5em 10px 2em; // 10px matches .wrap + max-width: calc( + var(--topic-body-width) + (var(--topic-body-width-padding) * 2) + + var(--topic-avatar-width) + ); + width: 100%; } .published-page-header { + box-sizing: border-box; width: 100%; top: 0; z-index: z("header"); @@ -14,10 +21,15 @@ position: sticky; top: 0; .published-page-header-wrapper { - width: 925px; + box-sizing: border-box; + max-width: calc( + var(--topic-body-width) + (var(--topic-body-width-padding) * 2) + + var(--topic-avatar-width) + ); + width: 100%; display: flex; margin: 0em auto; - padding: 0.5em 0; + padding: 0.5em 10px; // 10px matches .wrap padding align-items: center; .published-page-logo { height: 45px; @@ -26,9 +38,12 @@ } .published-page-title { color: var(--header_primary); - font-size: 2em; + font-size: var(--font-up-5); margin: 0; - max-height: 1.25em; + line-height: var(--line-height-medium); + @include breakpoint(mobile-extra-large) { + font-size: var(--font-up-3); + } } } } @@ -62,5 +77,8 @@ .published-page-content-body { font-size: 1.25em; - padding-bottom: 2em; + img { + max-width: 100%; + height: auto; + } } diff --git a/app/assets/stylesheets/publish_desktop.scss b/app/assets/stylesheets/publish_desktop.scss deleted file mode 100644 index d9611b081d..0000000000 --- a/app/assets/stylesheets/publish_desktop.scss +++ /dev/null @@ -1 +0,0 @@ -@import "publish"; diff --git a/app/assets/stylesheets/publish_desktop_rtl.scss b/app/assets/stylesheets/publish_desktop_rtl.scss deleted file mode 100644 index b13ca0ae62..0000000000 --- a/app/assets/stylesheets/publish_desktop_rtl.scss +++ /dev/null @@ -1 +0,0 @@ -@import "publish_desktop"; diff --git a/app/assets/stylesheets/publish_mobile.scss b/app/assets/stylesheets/publish_mobile.scss deleted file mode 100644 index cdd7d9259c..0000000000 --- a/app/assets/stylesheets/publish_mobile.scss +++ /dev/null @@ -1,20 +0,0 @@ -@import "publish"; - -.published-page-header { - .published-page-header-wrapper { - width: auto; - - .published-page-title { - font-size: var(--font-up-3); - width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } -} - -.published-page-content-wrapper { - margin: 2em auto; - padding: 0 10px; -} diff --git a/app/assets/stylesheets/publish_mobile_rtl.scss b/app/assets/stylesheets/publish_mobile_rtl.scss deleted file mode 100644 index 0b2e7dfe0b..0000000000 --- a/app/assets/stylesheets/publish_mobile_rtl.scss +++ /dev/null @@ -1 +0,0 @@ -@import "publish_mobile"; diff --git a/app/assets/stylesheets/testem.scss b/app/assets/stylesheets/testem.scss index cb117a8825..a1e4c5dad3 100644 --- a/app/assets/stylesheets/testem.scss +++ b/app/assets/stylesheets/testem.scss @@ -19,6 +19,7 @@ $love: #fa6c8d !default; @import "common/foundation/mixins"; @import "desktop"; @import "color_definitions"; +@import "admin"; #ember-testing-container { box-sizing: border-box; diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 377ceccaac..788f5e6006 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -111,7 +111,7 @@ class Admin::GroupsController < Admin::StaffController raise Discourse::NotFound unless group users = User.where(username: group_params[:usernames].split(",")) - users.each { |user| guardian.ensure_can_change_primary_group!(user) } + users.each { |user| guardian.ensure_can_change_primary_group!(user, group) } users.update_all(primary_group_id: params[:primary] == "true" ? group.id : nil) render json: success_json diff --git a/app/controllers/admin/site_settings_controller.rb b/app/controllers/admin/site_settings_controller.rb index a0a6ad9b86..4db8b72e43 100644 --- a/app/controllers/admin/site_settings_controller.rb +++ b/app/controllers/admin/site_settings_controller.rb @@ -197,7 +197,8 @@ class Admin::SiteSettingsController < Admin::AdminController default_email_digest_frequency: "digest_after_minutes", default_include_tl0_in_digests: "include_tl0_in_digests", default_text_size: "text_size_key", - default_title_count_mode: "title_count_mode_key" + default_title_count_mode: "title_count_mode_key", + default_hide_profile_and_presence: "hide_profile_and_presence" } end diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 7f3013e65f..a47f2016d7 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -97,32 +97,32 @@ class Admin::ThemesController < Admin::AdminController return end - begin - branch = params[:branch] ? params[:branch] : nil - private_key = params[:public_key] ? Discourse.redis.get("ssh_key_#{params[:public_key]}") : nil - return render_json_error I18n.t("themes.import_error.ssh_key_gone") if params[:public_key].present? && private_key.blank? + hijack do + begin + branch = params[:branch] ? params[:branch] : nil + private_key = params[:public_key] ? Discourse.redis.get("ssh_key_#{params[:public_key]}") : nil + return render_json_error I18n.t("themes.import_error.ssh_key_gone") if params[:public_key].present? && private_key.blank? - hijack do @theme = RemoteTheme.import_theme(remote, theme_user, private_key: private_key, branch: branch) render json: @theme, status: :created - end - rescue RemoteTheme::ImportError => e - if params[:force] - theme_name = params[:remote].gsub(/.git$/, "").split("/").last + rescue RemoteTheme::ImportError => e + if params[:force] + theme_name = params[:remote].gsub(/.git$/, "").split("/").last - remote_theme = RemoteTheme.new - remote_theme.private_key = private_key - remote_theme.branch = params[:branch] ? params[:branch] : nil - remote_theme.remote_url = params[:remote] - remote_theme.save! + remote_theme = RemoteTheme.new + remote_theme.private_key = private_key + remote_theme.branch = params[:branch] ? params[:branch] : nil + remote_theme.remote_url = params[:remote] + remote_theme.save! - @theme = Theme.new(user_id: theme_user&.id || -1, name: theme_name) - @theme.remote_theme = remote_theme - @theme.save! + @theme = Theme.new(user_id: theme_user&.id || -1, name: theme_name) + @theme.remote_theme = remote_theme + @theme.save! - render json: @theme, status: :created - else - render_json_error e.message + render json: @theme, status: :created + else + render_json_error e.message + end end end elsif params[:bundle] || (params[:theme] && THEME_CONTENT_TYPES.include?(params[:theme].content_type)) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 86a9ad4df1..6d2cf1d3fd 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -241,11 +241,11 @@ class Admin::UsersController < Admin::StaffController end def primary_group - guardian.ensure_can_change_primary_group!(@user) - if params[:primary_group_id].present? primary_group_id = params[:primary_group_id].to_i if group = Group.find(primary_group_id) + guardian.ensure_can_change_primary_group!(@user, group) + if group.user_ids.include?(@user.id) @user.primary_group_id = primary_group_id end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 33325cf61c..d6e1a6e2de 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class CategoriesController < ApplicationController + include TopicQueryParams requires_login except: [:index, :categories_and_latest, :categories_and_top, :show, :redirect, :find_by_slug, :visible_groups] @@ -291,6 +292,8 @@ class CategoriesController < ApplicationController per_page: CategoriesController.topics_per_page, no_definitions: true, } + + topic_options.merge!(build_topic_list_options) style = SiteSetting.desktop_category_page_style topic_options[:order] = 'created' if style == "categories_and_latest_topics_created_date" diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 0bc45d0ddf..d4b856d73b 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -65,7 +65,7 @@ class GroupsController < ApplicationController if !guardian.is_staff? # hide automatic groups from all non stuff to de-clutter page - groups = groups.where("automatic IS FALSE OR groups.id = #{Group::AUTO_GROUPS[:moderators]}") + groups = groups.where("automatic IS FALSE OR groups.id = ?", Group::AUTO_GROUPS[:moderators]) type_filters.delete(:automatic) end @@ -129,7 +129,7 @@ class GroupsController < ApplicationController format.json do groups = Group.visible_groups(current_user) if !guardian.is_staff? - groups = groups.where("automatic IS FALSE OR groups.id = #{Group::AUTO_GROUPS[:moderators]}") + groups = groups.where("automatic IS FALSE OR groups.id = ?", Group::AUTO_GROUPS[:moderators]) end render_json_dump( diff --git a/app/controllers/new_topic_controller.rb b/app/controllers/new_topic_controller.rb new file mode 100644 index 0000000000..f6b9017666 --- /dev/null +++ b/app/controllers/new_topic_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class NewTopicController < ApplicationController + def index; end +end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index d05ba4351f..490f4db0ed 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -760,7 +760,7 @@ class SessionController < ApplicationController end if invite.redeemable? - if !invite.is_invite_link? && sso.email != invite.email + if invite.is_email_invite? && sso.email != invite.email raise Invite::ValidationFailed.new(I18n.t("invite.not_matching_email")) end elsif invite.expired? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d2078d15eb..96d3c11a65 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -626,13 +626,16 @@ module ApplicationHelper end def discourse_theme_color_meta_tags - result = +<<~HTML - - HTML + result = +"" if dark_scheme_id != -1 result << <<~HTML + HTML + else + result << <<~HTML + + HTML end result.html_safe end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index 952fb40cfe..ba14776aff 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -60,7 +60,7 @@ module EmailHelper p, span, td { - color: #dddddd !important; + color: inherit !important; } [data-stripped-secure-media] { diff --git a/app/jobs/onceoff/migrate_custom_emojis.rb b/app/jobs/onceoff/migrate_custom_emojis.rb index 866ec4e421..f359a23c99 100644 --- a/app/jobs/onceoff/migrate_custom_emojis.rb +++ b/app/jobs/onceoff/migrate_custom_emojis.rb @@ -29,7 +29,7 @@ module Jobs Emoji.clear_cache - Post.where("cooked LIKE '%#{Emoji.base_url}%'").find_each do |post| + Post.where("cooked LIKE ?", "%#{Emoji.base_url}%").find_each do |post| post.rebake! end end diff --git a/app/jobs/regular/anonymize_user.rb b/app/jobs/regular/anonymize_user.rb index c078706c04..7343163e49 100644 --- a/app/jobs/regular/anonymize_user.rb +++ b/app/jobs/regular/anonymize_user.rb @@ -46,8 +46,9 @@ module Jobs # UserHistory for delete_user logs the user's IP. Note this is quite ugly but we don't # have a better way of querying on details right now. UserHistory.where( - "action = :action AND details LIKE 'id: #{@user_id}\n%'", - action: UserHistory.actions[:delete_user] + "action = :action AND details LIKE :details", + action: UserHistory.actions[:delete_user], + details: "id: #{@user_id}\n%", ).update_all(ip_address: new_ip) end diff --git a/app/jobs/regular/export_user_archive.rb b/app/jobs/regular/export_user_archive.rb index 256b8a3d4d..657cc4aafe 100644 --- a/app/jobs/regular/export_user_archive.rb +++ b/app/jobs/regular/export_user_archive.rb @@ -26,7 +26,7 @@ module Jobs ) HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( - user_archive: ['topic_title', 'categories', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], + user_archive: ['topic_title', 'categories', 'is_pm', 'post_raw', 'post_cooked', 'like_count', 'reply_count', 'url', 'created_at'], user_archive_profile: ['location', 'website', 'bio', 'views'], auth_tokens: ['id', 'auth_token_hash', 'prev_auth_token_hash', 'auth_token_seen', 'client_ip', 'user_agent', 'seen_at', 'rotated_at', 'created_at', 'updated_at'], auth_token_logs: ['id', 'action', 'user_auth_token_id', 'client_ip', 'auth_token_hash', 'created_at', 'path', 'user_agent'], @@ -134,7 +134,7 @@ module Jobs Post.includes(topic: :category) .where(user_id: @current_user.id) - .select(:topic_id, :post_number, :raw, :like_count, :reply_count, :created_at) + .select(:topic_id, :post_number, :raw, :cooked, :like_count, :reply_count, :created_at) .order(:created_at) .with_deleted .each do |user_archive| @@ -441,7 +441,15 @@ module Jobs is_pm = topic_data.archetype == "private_message" ? I18n.t("csv_export.boolean_yes") : I18n.t("csv_export.boolean_no") url = "#{Discourse.base_url}/t/#{topic_data.slug}/#{topic_data.id}/#{user_archive['post_number']}" - topic_hash = { "post" => user_archive['raw'], "topic_title" => topic_data.title, "categories" => categories, "is_pm" => is_pm, "url" => url } + topic_hash = { + "post_raw" => user_archive['raw'], + "post_cooked" => user_archive["cooked"], + "topic_title" => topic_data.title, + "categories" => categories, + "is_pm" => is_pm, + "url" => url + } + user_archive.merge!(topic_hash) HEADER_ATTRS_FOR['user_archive'].each do |attr| diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index 9c9ce5df3c..ecaa5d3876 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -41,9 +41,9 @@ module Jobs if upload.sha1.present? # TODO: Remove this check after UploadReferences records were created encoded_sha = Base62.encode(upload.sha1.hex) - next if ReviewableQueuedPost.pending.where("payload->>'raw' LIKE '%#{upload.sha1}%' OR payload->>'raw' LIKE '%#{encoded_sha}%'").exists? - next if Draft.where("data LIKE '%#{upload.sha1}%' OR data LIKE '%#{encoded_sha}%'").exists? - next if UserProfile.where("bio_raw LIKE '%#{upload.sha1}%' OR bio_raw LIKE '%#{encoded_sha}%'").exists? + next if ReviewableQueuedPost.pending.where("payload->>'raw' LIKE ? OR payload->>'raw' LIKE ?", "%#{upload.sha1}%", "%#{encoded_sha}%").exists? + next if Draft.where("data LIKE ? OR data LIKE ?", "%#{upload.sha1}%", "%#{encoded_sha}%").exists? + next if UserProfile.where("bio_raw LIKE ? OR bio_raw LIKE ?", "%#{upload.sha1}%", "%#{encoded_sha}%").exists? upload.destroy else diff --git a/app/jobs/scheduled/enqueue_digest_emails.rb b/app/jobs/scheduled/enqueue_digest_emails.rb index e76a60d97e..7d9ad9fa16 100644 --- a/app/jobs/scheduled/enqueue_digest_emails.rb +++ b/app/jobs/scheduled/enqueue_digest_emails.rb @@ -23,12 +23,12 @@ module Jobs .where(staged: false) .joins(:user_option, :user_stat, :user_emails) .where("user_options.email_digests") - .where("user_stats.bounce_score < #{SiteSetting.bounce_score_threshold}") + .where("user_stats.bounce_score < ?", SiteSetting.bounce_score_threshold) .where("user_emails.primary") .where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") .where("COALESCE(user_stats.digest_attempted_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") .where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") - .where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})") + .where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * ?)", SiteSetting.suppress_digest_email_after_days) .order("user_stats.digest_attempted_at ASC NULLS FIRST") # If the site requires approval, make sure the user is approved diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 2bd607d1e6..c6947cf078 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -204,10 +204,8 @@ end # # Indexes # -# idx_bookmarks_user_polymorphic_unique (user_id,bookmarkable_type,bookmarkable_id) UNIQUE -# index_bookmarks_on_post_id (post_id) -# index_bookmarks_on_reminder_at (reminder_at) -# index_bookmarks_on_reminder_set_at (reminder_set_at) -# index_bookmarks_on_user_id (user_id) -# index_bookmarks_on_user_id_and_post_id_and_for_topic (user_id,post_id,for_topic) UNIQUE +# idx_bookmarks_user_polymorphic_unique (user_id,bookmarkable_type,bookmarkable_id) UNIQUE +# index_bookmarks_on_reminder_at (reminder_at) +# index_bookmarks_on_reminder_set_at (reminder_set_at) +# index_bookmarks_on_user_id (user_id) # diff --git a/app/models/category.rb b/app/models/category.rb index 0efbb5f3ed..7e7bd947b0 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -374,7 +374,7 @@ class Category < ActiveRecord::Base elsif SiteSetting.slug_generation_method == 'ascii' && !CGI.unescape(self.slug).ascii_only? errors.add(:slug, I18n.t("category.errors.slug_contains_non_ascii_chars")) elsif duplicate_slug? - errors.add(:slug, 'is already in use') + errors.add(:slug, I18n.t("category.errors.is_already_in_use")) end else # auto slug diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index 16d9872344..07ebc880ce 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -64,9 +64,7 @@ module HasCustomFields has_many :_custom_fields, dependent: :destroy, class_name: "#{name}CustomField" after_save :save_custom_fields - # TODO (martin) Post 2.8 release, change to attr_reader because this is - # set by set_preloaded_custom_fields - attr_accessor :preloaded_custom_fields + attr_reader :preloaded_custom_fields def custom_fields_fk @custom_fields_fk ||= "#{_custom_fields.reflect_on_all_associations(:belongs_to)[0].name}_id" diff --git a/app/models/concerns/second_factor_manager.rb b/app/models/concerns/second_factor_manager.rb index e21957e75f..4e212306af 100644 --- a/app/models/concerns/second_factor_manager.rb +++ b/app/models/concerns/second_factor_manager.rb @@ -79,7 +79,7 @@ module SecondFactorManager end def has_any_second_factor_methods_enabled? - totp_enabled? || security_keys_enabled? + totp_enabled? || security_keys_enabled? || backup_codes_enabled? end def has_multiple_second_factor_methods? diff --git a/app/models/invite.rb b/app/models/invite.rb index 917b486f29..1910700185 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -74,8 +74,16 @@ class Invite < ActiveRecord::Base end end + # Even if a domain is specified on the invite, it still counts as + # an invite link. def is_invite_link? - email.blank? + self.email.blank? + end + + # Email invites have specific behaviour and it's easier to visually + # parse is_email_invite? than !is_invite_link? + def is_email_invite? + self.email.present? end def redeemable? @@ -201,8 +209,6 @@ class Invite < ActiveRecord::Base ) return if !redeemable? - email = self.email if email.blank? && !is_invite_link? - InviteRedeemer.new( invite: self, email: email, diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index ba823aaf51..6a7ae4b22a 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -1,5 +1,18 @@ # frozen_string_literal: true +# NOTE: There are a _lot_ of complicated rules and conditions for our +# invite system, and the code is spread out through a lot of places. +# Tread lightly and read carefully when modifying this code. You may +# also want to look at: +# +# * InvitesController +# * SessionController +# * Invite model +# * User model +# +# Invites that are scoped to a specific email (email IS NOT NULL on the Invite +# model) have different rules to invites that are considered an "invite link", +# (email IS NULL) on the Invite model. class InviteRedeemer attr_reader :invite, :email, @@ -13,7 +26,7 @@ class InviteRedeemer :redeeming_user def initialize( - invite: nil, + invite:, email: nil, username: nil, name: nil, @@ -23,9 +36,7 @@ class InviteRedeemer session: nil, email_token: nil, redeeming_user: nil) - @invite = invite - @email = email @username = username @name = name @password = password @@ -34,6 +45,8 @@ class InviteRedeemer @session = session @email_token = email_token @redeeming_user = redeeming_user + + ensure_email_is_present!(email) end def redeem @@ -45,7 +58,29 @@ class InviteRedeemer end end - # extracted from User cause it is very specific to invites + # The email must be present in some form since many of the methods + # for processing + redemption rely on it. If it's still nil after + # these checks then we have hit an edge case and should not proceed! + def ensure_email_is_present!(email) + if email.blank? + Rails.logger.warn( + "email param was blank in InviteRedeemer for invite ID #{@invite.id}. The `redeeming_user` was #{@redeeming_user.present? ? "(ID: #{@redeeming_user.id})" : "not"} present.", + ) + end + + if email.blank? && @invite.is_email_invite? + @email = @invite.email + elsif @redeeming_user.present? + @email = @redeeming_user.email + else + @email = email + end + + raise Discourse::InvalidParameters if @email.blank? + end + + # This will _never_ be called if there is a redeeming_user being passed + # in to InviteRedeemer -- see invited_user below. def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil) if username && UsernameValidator.new(username).valid_format? && User.username_available?(username, email) available_username = username @@ -107,7 +142,10 @@ class InviteRedeemer user.save! authenticator.finish - if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email && invite.email_token.present? && email_token == invite.email_token + if invite.emailed_status != Invite.emailed_status_types[:not_required] && + email == invite.email && + invite.email_token.present? && + email_token == invite.email_token user.activate end @@ -118,24 +156,26 @@ class InviteRedeemer def can_redeem_invite? return false if !invite.redeemable? + return false if email.blank? - # Invite has already been redeemed by anyone. - if !invite.is_invite_link? && InvitedUser.exists?(invite_id: invite.id) + # Invite scoped to email has already been redeemed by anyone. + if invite.is_email_invite? && InvitedUser.exists?(invite_id: invite.id) return false end - # Email will not be present if we are claiming an invite link, which - # does not have an email or domain scope on the invitation. - if email.present? || redeeming_user.present? - email_to_check = redeeming_user&.email || email + # The email will be present for either an invite link (where the user provides + # us the email manually) or for an invite scoped to an email, where we + # prefill the email and do not let the user modify it. + # + # Note that an invite link can also have a domain scope which must be checked. + email_to_check = redeeming_user&.email || email - if invite.email.present? && !invite.email_matches?(email_to_check) - raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.not_matching_email')) - end + if invite.email.present? && !invite.email_matches?(email_to_check) + raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.not_matching_email')) + end - if invite.domain.present? && !invite.domain_matches?(email_to_check) - raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed')) - end + if invite.domain.present? && !invite.domain_matches?(email_to_check) + raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed')) end # Anon user is trying to redeem an invitation, if an existing user already @@ -148,6 +188,10 @@ class InviteRedeemer true end + # Note that the invited_user is returned by #redeemed, so other places + # (e.g. the InvitesController) can perform further actions on it, this + # is why things like send_welcome_message are set without being saved + # on the model. def invited_user return @invited_user if defined?(@invited_user) @@ -196,9 +240,18 @@ class InviteRedeemer end def add_to_private_topics_if_invited - topic_ids = Topic.where(archetype: Archetype::private_message).includes(:invites).where(invites: { email: email }).pluck(:id) + # Should not happen because of ensure_email_is_present!, but better to cover bases. + return if email.blank? + + topic_ids = TopicInvite.joins(:invite) + .joins(:topic) + .where("topics.archetype = ?", Archetype::private_message) + .where("invites.email = ?", email) + .pluck(:topic_id) topic_ids.each do |id| - TopicAllowedUser.create!(user_id: invited_user.id, topic_id: id) unless TopicAllowedUser.exists?(user_id: invited_user.id, topic_id: id) + if !TopicAllowedUser.exists?(user_id: invited_user.id, topic_id: id) + TopicAllowedUser.create!(user_id: invited_user.id, topic_id: id) + end end end @@ -209,6 +262,7 @@ class InviteRedeemer group = Group.find_by(id: id) if guardian.can_edit_group?(group) invited_user.group_users.create!(group_id: group.id) + GroupActionLogger.new(invite.invited_by, group).log_add_user_to_group(invited_user) DiscourseEvent.trigger(:user_added_to_group, invited_user, group, automatic: false) end end @@ -220,15 +274,17 @@ class InviteRedeemer end def notify_invitee - if inviter = invite.invited_by - inviter.notifications.create!( - notification_type: Notification.types[:invitee_accepted], - data: { display_username: invited_user.username }.to_json - ) - end + return if invite.invited_by.blank? + invite.invited_by.notifications.create!( + notification_type: Notification.types[:invitee_accepted], + data: { display_username: invited_user.username }.to_json + ) end def delete_duplicate_invites + # Should not happen because of ensure_email_is_present!, but better to cover bases. + return if email.blank? + Invite .where('invites.max_redemptions_allowed = 1') .joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id") diff --git a/app/models/onboarding_popup.rb b/app/models/onboarding_popup.rb deleted file mode 100644 index f672f6eea4..0000000000 --- a/app/models/onboarding_popup.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class OnboardingPopup - def self.types - @types ||= Enum.new( - first_notification: 1, - topic_timeline: 2, - ) - end -end diff --git a/app/models/post.rb b/app/models/post.rb index 5f493c4585..362ce9bf8b 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -989,10 +989,6 @@ class Post < ActiveRecord::Base end end - def downloaded_images - self.custom_fields[Post::DOWNLOADED_IMAGES] || {} - end - def each_upload_url(fragments: nil, include_local_upload: true) current_db = RailsMultisite::ConnectionManagement.current_db upload_patterns = [ diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index fc73aa76dc..7ec4016dc1 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -106,14 +106,6 @@ class SiteSetting < ActiveRecord::Base SiteSetting.manual_polling_enabled? || SiteSetting.pop3_polling_enabled? end - WATCHED_SETTINGS ||= [ - :default_locale, - :blocked_attachment_content_types, - :blocked_attachment_filenames, - :allowed_unicode_username_characters, - :markdown_typographer_quotation_marks - ] - def self.blocked_attachment_content_types_regex current_db = RailsMultisite::ConnectionManagement.current_db @@ -193,8 +185,7 @@ class SiteSetting < ActiveRecord::Base def self.whispers_allowed_group_ids if SiteSetting.enable_whispers && SiteSetting.whispers_allowed_groups.present? - # TODO (martin) Change to whispers_allowed_groups_map - SiteSetting.whispers_allowed_groups.split("|").map(&:to_i) + SiteSetting.whispers_allowed_groups_map else [] end diff --git a/app/models/upload.rb b/app/models/upload.rb index 813c5e57e9..ad004f6e66 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -34,6 +34,7 @@ class Upload < ActiveRecord::Base attr_accessor :for_export attr_accessor :for_site_setting attr_accessor :for_gravatar + attr_accessor :validate_file_size validates_presence_of :filesize validates_presence_of :original_filename @@ -92,6 +93,11 @@ class Upload < ActiveRecord::Base .where("ur.upload_id IS NULL") end + def initialize(*args) + super + self.validate_file_size = true + end + def to_s self.url end @@ -480,7 +486,7 @@ class Upload < ActiveRecord::Base db = RailsMultisite::ConnectionManagement.current_db scope = Upload.by_users - .where("url NOT LIKE '%/original/_X/%' AND url LIKE '%/uploads/#{db}%'") + .where("url NOT LIKE '%/original/_X/%' AND url LIKE ?", "%/uploads/#{db}%") .order(id: :desc) scope = scope.limit(limit) if limit diff --git a/app/models/user.rb b/app/models/user.rb index d92780a13b..59a881e6f4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -138,7 +138,7 @@ class User < ActiveRecord::Base after_create :set_default_sidebar_section_links after_update :set_default_sidebar_section_links, if: Proc.new { - self.saved_change_to_staged? + self.saved_change_to_staged? || self.saved_change_to_admin? } after_update :trigger_user_updated_event, if: Proc.new { @@ -284,6 +284,13 @@ class User < ActiveRecord::Base MAX_STAFF_DELETE_POST_COUNT ||= 5 + def self.user_tips + @user_tips ||= Enum.new( + first_notification: 1, + topic_timeline: 2, + ) + end + def visible_sidebar_tags(user_guardian = nil) user_guardian ||= guardian DiscourseTagging.filter_visible(custom_sidebar_tags, user_guardian) @@ -1944,7 +1951,7 @@ class User < ActiveRecord::Base end end - SidebarSectionLink.insert_all!(records) if records.present? + SidebarSectionLink.insert_all(records) if records.present? end def stat diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 8add4161d9..1bf0062d20 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -88,6 +88,8 @@ class UserOption < ActiveRecord::Base self.title_count_mode = SiteSetting.default_title_count_mode + self.hide_profile_and_presence = SiteSetting.default_hide_profile_and_presence + true end @@ -232,45 +234,52 @@ end # # Table name: user_options # -# user_id :integer not null, primary key -# mailing_list_mode :boolean default(FALSE), not null -# email_digests :boolean -# external_links_in_new_tab :boolean default(FALSE), not null -# enable_quoting :boolean default(TRUE), not null -# dynamic_favicon :boolean default(FALSE), not null -# automatically_unpin_topics :boolean default(TRUE), not null -# digest_after_minutes :integer -# auto_track_topics_after_msecs :integer -# new_topic_duration_minutes :integer -# last_redirected_to_top_at :datetime -# email_previous_replies :integer default(2), not null -# email_in_reply_to :boolean default(TRUE), not null -# like_notification_frequency :integer default(1), not null -# mailing_list_mode_frequency :integer default(1), not null -# include_tl0_in_digests :boolean default(FALSE) -# notification_level_when_replying :integer -# theme_key_seq :integer default(0), not null -# allow_private_messages :boolean default(TRUE), not null -# homepage_id :integer -# theme_ids :integer default([]), not null, is an Array -# hide_profile_and_presence :boolean default(FALSE), not null -# text_size_key :integer default(0), not null -# text_size_seq :integer default(0), not null -# email_level :integer default(1), not null -# email_messages_level :integer default(0), not null -# title_count_mode_key :integer default(0), not null -# enable_defer :boolean default(FALSE), not null -# timezone :string -# enable_allowed_pm_users :boolean default(FALSE), not null -# dark_scheme_id :integer -# skip_new_user_tips :boolean default(FALSE), not null -# color_scheme_id :integer -# default_calendar :integer default("none_selected"), not null -# oldest_search_log_date :datetime -# bookmark_auto_delete_preference :integer default(3), not null -# enable_experimental_sidebar :boolean default(FALSE) -# seen_popups :integer is an Array -# sidebar_list_destination :integer default("none_selected"), not null +# user_id :integer not null, primary key +# mailing_list_mode :boolean default(FALSE), not null +# email_digests :boolean +# external_links_in_new_tab :boolean default(FALSE), not null +# enable_quoting :boolean default(TRUE), not null +# dynamic_favicon :boolean default(FALSE), not null +# automatically_unpin_topics :boolean default(TRUE), not null +# digest_after_minutes :integer +# auto_track_topics_after_msecs :integer +# new_topic_duration_minutes :integer +# last_redirected_to_top_at :datetime +# email_previous_replies :integer default(2), not null +# email_in_reply_to :boolean default(TRUE), not null +# like_notification_frequency :integer default(1), not null +# mailing_list_mode_frequency :integer default(1), not null +# include_tl0_in_digests :boolean default(FALSE) +# notification_level_when_replying :integer +# theme_key_seq :integer default(0), not null +# allow_private_messages :boolean default(TRUE), not null +# homepage_id :integer +# theme_ids :integer default([]), not null, is an Array +# hide_profile_and_presence :boolean default(FALSE), not null +# text_size_key :integer default(0), not null +# text_size_seq :integer default(0), not null +# email_level :integer default(1), not null +# email_messages_level :integer default(0), not null +# title_count_mode_key :integer default(0), not null +# enable_defer :boolean default(FALSE), not null +# timezone :string +# enable_allowed_pm_users :boolean default(FALSE), not null +# dark_scheme_id :integer +# skip_new_user_tips :boolean default(FALSE), not null +# color_scheme_id :integer +# default_calendar :integer default("none_selected"), not null +# chat_enabled :boolean default(TRUE), not null +# only_chat_push_notifications :boolean +# oldest_search_log_date :datetime +# chat_sound :string +# dismissed_channel_retention_reminder :boolean +# dismissed_dm_retention_reminder :boolean +# bookmark_auto_delete_preference :integer default(3), not null +# ignore_channel_wide_mention :boolean +# chat_email_frequency :integer default(1), not null +# enable_experimental_sidebar :boolean default(FALSE) +# seen_popups :integer is an Array +# sidebar_list_destination :integer default("none_selected"), not null # # Indexes # diff --git a/app/models/username_validator.rb b/app/models/username_validator.rb index 1bb51491e6..747eb2f777 100644 --- a/app/models/username_validator.rb +++ b/app/models/username_validator.rb @@ -138,6 +138,6 @@ class UsernameValidator end def self.allowed_char?(c) - c.match?(/[\w.-]/) || c.match?(SiteSetting.allowed_unicode_username_characters) + c.match?(/[\w.-]/) || c.match?(SiteSetting.allowed_unicode_username_characters_regex) end end diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index aab939247c..454fcf6cbd 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -45,7 +45,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer def second_factor_enabled - object.totp_enabled? || object.security_keys_enabled? + object.totp_enabled? || object.security_keys_enabled? || object.backup_codes_enabled? end def can_disable_second_factor diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 0a607648e7..70920d01a5 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -170,7 +170,7 @@ class CurrentUserSerializer < BasicUserSerializer end def can_send_private_messages - scope.can_send_private_message?(Discourse.system_user) + scope.can_send_private_messages? end def can_edit @@ -293,7 +293,7 @@ class CurrentUserSerializer < BasicUserSerializer end def include_seen_popups? - SiteSetting.enable_onboarding_popups + SiteSetting.enable_user_tips end def include_primary_group_id? @@ -323,7 +323,7 @@ class CurrentUserSerializer < BasicUserSerializer end def second_factor_enabled - object.totp_enabled? || object.security_keys_enabled? + object.totp_enabled? || object.security_keys_enabled? || object.backup_codes_enabled? end def featured_topic diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index de82619c9f..30636fe61f 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -6,7 +6,7 @@ class SiteSerializer < ApplicationSerializer :default_archetype, :notification_types, :post_types, - :onboarding_popup_types, + :user_tips, :trust_levels, :groups, :filters, @@ -104,12 +104,12 @@ class SiteSerializer < ApplicationSerializer Post.types end - def onboarding_popup_types - OnboardingPopup.types + def user_tips + User.user_tips end - def include_onboarding_popup_types? - SiteSetting.enable_onboarding_popups + def include_user_tips? + SiteSetting.enable_user_tips end def filters diff --git a/app/serializers/user_card_serializer.rb b/app/serializers/user_card_serializer.rb index a109e9ebbf..5e208169c7 100644 --- a/app/serializers/user_card_serializer.rb +++ b/app/serializers/user_card_serializer.rb @@ -141,7 +141,7 @@ class UserCardSerializer < BasicUserSerializer # Needed because 'send_private_message_to_user' will always return false # when the current user is being serialized def can_send_private_messages - scope.can_send_private_message?(Discourse.system_user) + scope.can_send_private_messages? end def can_send_private_message_to_user diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 0e9f975032..eb2db4e56e 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -105,7 +105,7 @@ class UserSerializer < UserCardSerializer end def second_factor_enabled - object.totp_enabled? || object.security_keys_enabled? + object.totp_enabled? || object.security_keys_enabled? || object.backup_codes_enabled? end def include_second_factor_backup_enabled? diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index ad7271c067..0083ff6955 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -181,11 +181,7 @@ class UserUpdater end if attributes.key?(:skip_new_user_tips) - user.user_option.seen_popups = if user.user_option.skip_new_user_tips - OnboardingPopup.types.values - else - nil - end + user.user_option.seen_popups = user.user_option.skip_new_user_tips ? [-1] : nil end # automatically disable digests when mailing_list_mode is enabled diff --git a/app/views/common/_discourse_publish_stylesheet.html.erb b/app/views/common/_discourse_publish_stylesheet.html.erb index ae48d98f98..9429b0cd8d 100644 --- a/app/views/common/_discourse_publish_stylesheet.html.erb +++ b/app/views/common/_discourse_publish_stylesheet.html.erb @@ -1,11 +1,5 @@ <%= discourse_stylesheet_link_tag 'publish', theme_id: nil %> -<%- if rtl? %> - <%= discourse_stylesheet_link_tag(mobile_view? ? :publish_mobile_rtl : :publish_mobile_rtl) %> -<%- else %> - <%= discourse_stylesheet_link_tag(mobile_view? ? :publish_mobile : :publish_desktop) %> -<%- end %> - <%- Discourse.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?, request: request).each do |file| %> <%= discourse_stylesheet_link_tag(file) %> <%- end %> diff --git a/app/views/qunit/theme.html.erb b/app/views/qunit/theme.html.erb index f2db3572fb..84397a564a 100644 --- a/app/views/qunit/theme.html.erb +++ b/app/views/qunit/theme.html.erb @@ -13,6 +13,7 @@ <%- end %> <%= preload_script "test-support" %> <%= preload_script "test-helpers" %> + <%= preload_script "test-site-settings" %> <%= theme_translations_lookup %> <%= theme_js_lookup %> <%= theme_lookup("head_tag") %> diff --git a/app/views/users/password_reset.html.erb b/app/views/users/password_reset.html.erb index 9b9a5268f6..2cb06b1add 100644 --- a/app/views/users/password_reset.html.erb +++ b/app/views/users/password_reset.html.erb @@ -1,16 +1,20 @@
    <%if @error%> -
    - <%= @error.html_safe %> +
    +
    + sweat-smile-face emoji +
    +
    + <%= @error.html_safe %> + + <% if @user.present? and @user.errors.present? %> + <% @user.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> + <% end %> + <%end%> - <% if @user.present? and @user.errors.present? %> -
    - <% @user.errors.full_messages.each do |msg| %> -
  • <%= msg %>
  • - <% end %> -
    - <% end %> <% content_for :title do %><%=t "password_reset.title" %> - <%= SiteSetting.title %><% end %> diff --git a/config/application.rb b/config/application.rb index ca71d106b8..3aba15bee0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -58,6 +58,7 @@ if !GlobalSetting.skip_redis? end require 'pry-rails' if Rails.env.development? +require 'pry-byebug' if Rails.env.development? require 'discourse_fonts' diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 84bc719d32..98564a17cf 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -28,6 +28,7 @@ Rails.application.config.assets.precompile += %w{ wizard.js test-support.js test-helpers.js + test-site-settings.js browser-detect.js browser-update.js break_string.js diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index 3da9dbe0d9..9df1f755c1 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -121,6 +121,13 @@ ar: date_month: "D ‏MMMM" date_year: "MMMM ‏YYYY" medium: + less_than_x_minutes: + zero: "أقل من %{count} دقيقة" + one: "أقل من دقيقة واحدة (%{count})" + two: "أقل من دقيقتين (%{count})" + few: "أقل من %{count} دقائق" + many: "أقل من %{count} دقيقة" + other: "أقل من %{count} دقيقة" x_minutes: zero: "%{count} دقيقة" one: "دقيقة واحدة (%{count})" @@ -135,6 +142,13 @@ ar: few: "%{count} ساعات" many: "%{count} ساعة" other: "%{count} ساعة" + about_x_hours: + zero: "حوالي %{count} ساعة" + one: "حوالي ساعة واحدة (%{count})" + two: "حوالي ساعتين (%{count})" + few: "حوالي %{count} ساعات" + many: "حوالي %{count} ساعة" + other: "حوالي %{count} ساعة" x_days: zero: "%{count} يوم" one: "يوم واحد (%{count})" @@ -142,13 +156,34 @@ ar: few: "%{count} أيام" many: "%{count} يومًا" other: "%{count} يوم" + x_months: + zero: "%{count} شهر" + one: "شهر واحد (%{count})" + two: "شهران (%{count})" + few: "%{count} أشهر" + many: "%{count} شهرًا" + other: "%{count} شهر" about_x_years: - zero: "حوالي %{count} سنة" - one: "حوالي %{count} سنة واحدة" - two: "حوالي %{count} سنتين" - few: "حوالي %{count} سنوات" - many: "حوالي %{count} سنة" - other: "حوالي %{count} سنوات" + zero: "نحو %{count} سنة" + one: "نحو سنة واحدة (%{count})" + two: "نحو سنتين (%{count})" + few: "نحو %{count} سنوات" + many: "نحو %{count} سنة" + other: "نحو %{count} سنة" + over_x_years: + zero: "أكثر من %{count} عام" + one: "أكثر من عام واحد (%{count})" + two: "أكثر من عامين (%{count})" + few: "أكثر من %{count} أعوام" + many: "أكثر من %{count} عامًا" + other: "أكثر من %{count} عام" + almost_x_years: + zero: "%{count} عام تقريبًا" + one: "عام واحد (%{count}) تقريبًا" + two: "عامان (%{count}) تقريبًا" + few: "%{count} أعوام تقريبًا" + many: "%{count} عامًا تقريبًا" + other: "%{count} عام تقريبًا" date_year: "D ‏MMMM ‏YYYY" medium_with_ago: x_minutes: @@ -211,10 +246,11 @@ ar: previous_month: "الشهر الماضي" next_month: "الشهر القادم" placeholder: التاريخ - from_placeholder: "من تاريخ" + from_placeholder: "من التاريخ" to_placeholder: "إلى التاريخ" share: topic_html: 'الموضوع: %{topicTitle}' + post: "المنشور #%{postNumber} بواسطة @%{username}" close: "إغلاق" twitter: "المشاركة على Twitter" facebook: "المشاركة على Facebook" @@ -222,6 +258,7 @@ ar: url: "نسخ عنوان URL ومشاركته" action_codes: public_topic: "جعل هذا الموضوع عامًا في %{when}" + open_topic: "حوَّل هذا إلى موضوع في %{when}" private_topic: "حوَّل هذا الموضوع إلى رسالة خاصة في %{when}" split_topic: "قسَّم هذا الموضوع في %{when}" invited_user: "دعا %{who} في ‏%{when}" @@ -255,6 +292,7 @@ ar: topic_admin_menu: "إجراءات الموضوع" skip_to_main_content: "تخطي إلى المحتوى الرئيسي" emails_are_disabled: "أوقف أحد المسؤولين البريد الصادر بشكلٍ عام. ولن يتم إرسال إشعارات عبر البريد الإلكتروني أيًا كان نوعها." + emails_are_disabled_non_staff: "تم تعطيل البريد الإلكتروني الصادر للمستخدمين من خارج طاقم العمل." software_update_prompt: message: "لقد حدَّثنا هذا الموقع، يُرجى التحديث ، أو قد تواجه سلوكًا غير متوقَّع." dismiss: "تجاهل" @@ -267,8 +305,13 @@ ar: other: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل الرسائل الإلكترونية التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام %{count} مستخدم." bootstrap_mode_disabled: "سيتم إيقاف وضع تمهيد التشغيل خلال 24 ساعة." bootstrap_invite_button_title: "إرسال الدعوات" + bootstrap_wizard_link_title: "إنهاء معالج الإعداد" themes: default_description: "افتراضي" + broken_theme_alert: "قد لا يعمل موقعك كما ينبغي بسبب وجود أخطاء في السمة/المكوِّن." + error_caused_by: "بسبب '%{name}'. انقر هنا للتحديث أو إعادة الإعداد أو الإيقاف" + only_admins: "(تظهر هذه الرسالة لمسؤولي الموقع فقط)" + broken_decorator_alert: "قد لا يتم عرض المنشورات بشكلٍ صحيح لأن أحد مصمِّمي محتوى المنشورات على موقعك أشار إلى خطأ." s3: regions: ap_northeast_1: "آسيا والمحيط الهادئ (طوكيو)" @@ -305,6 +348,7 @@ ar: delete: "حذف" generic_error: "عذرًا، حدث خطأ." generic_error_with_reason: "حدث خطأ: %{error}" + multiple_errors: "حدثت عدة أخطاء: %{errors}" sign_up: "الاشتراك" log_in: "تسجيل الدخول" age: "العمر" @@ -356,7 +400,7 @@ ar: many: "%{count} حرفًا" other: "%{count} حرف" period_chooser: - aria_label: "فرز حسب الفترة" + aria_label: "التصفية حسب الفترة" related_messages: title: "الرسائل ذات الصلة" see_all: 'عرض كل الرسائل من ⁨@%{username}⁩...' @@ -375,7 +419,7 @@ ar: last_day: "آخر 24 ساعة" last_7_days: "آخر 7 أيام" last_30_days: "آخر 30 يومًا" - like_count: "الإعجابات" + like_count: "تسجيلات الإعجاب" topic_count: "الموضوعات" post_count: "المنشورات" user_count: "المستخدمون" @@ -389,16 +433,18 @@ ar: help: bookmark: "انقر لإضافة إشارة مرجعية على المنشور الأول في هذا الموضوع" edit_bookmark: "انقر لتعديل الإشارة المرجعية في هذا الموضوع" - edit_bookmark_for_topic: "انقر لتعديل الإشارة المرجعية في هذا الموضوع" + edit_bookmark_for_topic: "انقر لتعديل الإشارة المرجعية لهذا الموضوع" unbookmark: "انقر لإزالة كل الإشارات المرجعية في هذا الموضوع" unbookmark_with_reminder: "انقر لإزالة جميع الإشارات المرجعية والتذكيرات في هذا الموضوع." bookmarks: created: "لقد وضعت إشارة مرجعية على هذا المنشور. %{name}" + created_generic: "لقد وضعت إشارة مرجعية على هذا. %{name}" create: "إنشاء إشارة مرجعية" edit: "تعديل الإشارة المرجعية" not_bookmarked: "وضع إشارة مرجعية على هذا المنشور" - remove_reminder_keep_bookmark: "إزالة التذكير والاحتفاظ بالعلامة المرجعية" + remove_reminder_keep_bookmark: "إزالة التذكير والاحتفاظ بالإشارة المرجعية" created_with_reminder: "لقد وضعت إشارة مرجعية على هذا المنشور وضبطت تذكيرًا في %{date}. ‏%{name}" + created_with_reminder_generic: "لقد وضعت إشارة مرجعية على هذا المنشور وضبطت تذكيرًا في %{date}. ‏%{name}" remove: "إزالة الإشارة المرجعية" delete: "حذف الإشارة المرجعية" confirm_delete: "هل تريد بالتأكيد حذف هذه الإشارة المرجعية؟ سيتم حذف التذكير أيضًا." @@ -409,7 +455,11 @@ ar: list_permission_denied: "ليس لديك إذن بعرض الإشارات المرجعية لهذا المستخدم." no_user_bookmarks: "ليس لديك منشورات موضوع عليها إشارة مرجعية. تتيح لك الإشارات المرجعية الرجوع إلى المنشورات التي تريدها بسرعة." auto_delete_preference: + label: "بعد إرسال إشعار إليك" + never: "الاحتفاظ بالإشارة المرجعية" when_reminder_sent: "حذف الإشارة المرجعية" + on_owner_reply: "حذف الإشارة المرجعية بعد أن أرد" + clear_reminder: "الاحتفاظ بالعلامة المرجعية ومسح التذكير" search_placeholder: "البحث عن الإشارات المرجعية بالاسم أو عنوان الموضوع أو محتوى المنشور" search: "البحث" reminders: @@ -419,6 +469,8 @@ ar: existing_reminder: "لقد ضبطت تذكيرًا لهذه الإشارة المرجعية، وسيتم إرساله إليك في %{at_date_time}" copy_codeblock: copied: "تم النسخ!" + copy: "نسخ الرمز إلى الحافظة" + fullscreen: "عرض الرمز في وضع ملء الشاشة" drafts: label: "المسودات" label_with_count: "المسودات (%{count})" @@ -475,6 +527,7 @@ ar: saved: "تم الحفظ!" upload: "تحميل" uploading: "جارٍ التحميل..." + processing: "جارٍ المعالجة..." uploading_filename: "جارٍ تحميل: %{filename}..." processing_filename: "قيد المعالجة: %{filename}..." clipboard: "الحافظة" @@ -489,6 +542,7 @@ ar: switch_to_anon: "دخول وضع التخفي" switch_from_anon: "الخروج من وضع التخفي" banner: + close: "تجاهل هذا البانر" edit: "تعديل" pwa: install_banner: "هل تريد تثبيت %{title} على هذا الجهاز؟" @@ -504,7 +558,7 @@ ar: placeholder: "اكتب عنوان الرسالة أو عنوان URL أو المُعرِّف هنا" review: order_by: "الترتيب حسب" - date_filter: "نشر بين" + date_filter: "تم نشره بين" in_reply_to: "ردًا على" explain: why: "اشرح سبب دخول هذا العنصر في قائمة الانتظار" @@ -561,7 +615,7 @@ ar: filtered_topic: "لقد قمت بالتصفية لعرض المحتوى القابل للمراجعة في موضوع واحد." filtered_user: "المستخدم" filtered_reviewed_by: "تمت المراجعة بواسطة" - show_all_topics: "عرض جميع الموضوعات" + show_all_topics: "عرض كل الموضوعات" deleted_post: "(تم حذف المنشور)" deleted_user: "(تم حذف المستخدم)" user: @@ -737,7 +791,10 @@ ar: relative: "نسبي" time_shortcut: now: "الآن" + in_one_hour: "بعد ساعة واحدة" + in_two_hours: "بعد ساعتين" later_today: "لاحقًا اليوم" + two_days: "يومان" next_business_day: "يوم العمل التالي" tomorrow: "غدًا" post_local_date: "التاريخ في المنشور" @@ -806,6 +863,8 @@ ar: reset_to_default: "إعادة الضبط على الافتراضي" group: all: "كل المجموعات" + sort: + label: "الترتيب حسب %{criteria}" group_histories: actions: change_group_setting: "تغيير إعدادات المجموعة" @@ -883,6 +942,8 @@ ar: title: "الإعدادات" allow_unknown_sender_topic_replies: "السماح بالرد على الموضوعات من مُرسِلين غير معروفين." allow_unknown_sender_topic_replies_hint: "يسمح لمُرسِلين غير معروفين بالرد على موضوعات المجموعة. إذا لم يتم تفعيل هذا الإعداد، فستؤدي الردود الواردة من عناوين البريد الإلكتروني التي لم تتم دعوتها بالفعل إلى الموضوع إلى إنشاء موضوع جديد." + from_alias: "من الاسم المستعار" + from_alias_hint: "اسم مستعار لاستخدامه في عنوان \"من\" عند إرسال رسائل بريد إلكتروني SMTP جماعية. لاحظ أن هذا قد لا يكون مدعومًا لدى كل مقدِّمي خدمة البريد الإلكتروني، يُرجى الرجوع إلى وثائق مقدِّم خدمة البريد الإلكتروني." mailboxes: synchronized: "صندوق البريد المتزامن" none_found: "لم يتم العثور على صناديق بريد في حساب البريد الإلكتروني هذا." @@ -894,8 +955,8 @@ ar: title: الفئات long_title: "الإشعارات الافتراضية للفئة" description: "عند إضافة مستخدمين إلى هذه المجموعة، سيتم ضبط إعدادات إشعارات الفئة لديهم على تلك القيم. ويمكنهم تغييرها بعد ذلك." - watched_categories_instructions: "يمكنك مراقبة جميع الموضوعات في هذه الفئات تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." - tracked_categories_instructions: "يمكنك تتبُّع جميع الموضوعات في هذه الفئات تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." + watched_categories_instructions: "يمكنك مراقبة كل الموضوعات في هذه الفئات تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_categories_instructions: "يمكنك تتبُّع كل الموضوعات في هذه الفئات تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." watching_first_post_categories_instructions: "سيتلقى المستخدمون إشعارًا بأول منشور في كل موضوع جديد في هذه الفئات." regular_categories_instructions: "إذا تم كتم هذه الفئات، فلن يتم كتمها لأعضاء المجموعة. سيتم إرسال إشعار إلى المستخدمين إذا تمت الإشارة إليهم أو رد شخص ما عليهم." muted_categories_instructions: "لن يتلقى المستخدمون أي إشعارات أبدًا بخصوص الموضوعات الجديدة في هذه الفئات، ولن تظهر في الفئات أو صفحات أحدث الموضوعات." @@ -903,8 +964,8 @@ ar: title: الوسوم long_title: "الإشعارات الافتراضية للوسوم" description: "عند إضافة مستخدمين إلى هذه المجموعة، سيتم تعيين الإعدادات المتعلقة بإشعارات الوسوم لديهم على الإعدادات الافتراضية. ويمكنهم تغييرها بعد ذلك." - watched_tags_instructions: "يمكنك مراقبة جميع الموضوعات التي تحمل هذه الوسوم تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." - tracked_tags_instructions: "يمكنك تتبُّع جميع الموضوعات التي تحمل هذه الوسوم تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." + watched_tags_instructions: "يمكنك مراقبة كل الموضوعات التي تحمل هذه الوسوم تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_tags_instructions: "يمكنك تتبُّع كل الموضوعات التي تحمل هذه الوسوم تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." watching_first_post_tags_instructions: "سيتم إرسال إشعار للمستخدمين بأول منشور في كل موضوع يحمل هذه الوسوم." regular_tags_instructions: "إذا تم كتم هذه الوسوم، فلن يتم كتمها لأعضاء المجموعة. سيتم إرسال إشعارات إلى المستخدمين إذا تمت الإشارة إليهم أو رد شخص ما عليهم." muted_tags_instructions: "لن يتلقى المستخدمون أي إشعارات أبدًا بخصوص الموضوعات الجديدة التي تحمل هذه الوسوم، ولن تظهر في أحدث الموضوعات." @@ -968,6 +1029,7 @@ ar: group_type: "نوع المجموعة" is_group_user: "عضو" is_group_owner: "المالك" + search_results: "ستظهر نتائج البحث أدناه." title: zero: "المجموعات" one: "المجموعة" @@ -1006,6 +1068,7 @@ ar: no_filter_matches: "لا يوجد أعضاء يطابقون هذا البحث." topics: "الموضوعات" posts: "المنشورات" + aria_post_number: "%{title} - المنشور #%{postNumber}" mentions: "الإشارات" messages: "الرسائل" notification_level: "مستوى الإشعارات الافتراضي لرسائل المجموعات" @@ -1051,8 +1114,8 @@ ar: modal_yes: "نعم" modal_no: "لا، تطبيق التغيير من الآن فصاعدًا فقط" user_action_groups: - "1": "الإعجابات" - "2": "الإعجابات" + "1": "تسجيلات الإعجاب" + "2": "تسجيلات الإعجاب" "3": "الإشارات المرجعية" "4": "الموضوعات" "5": "الردود" @@ -1140,6 +1203,7 @@ ar: user_fields: none: "(تحديد خيار)" required: 'يُرجى إدخال قيمة لـ "%{name}"' + same_as_password: "ينبغي عدم تكرار كلمة مرورك في الحقول الأخرى." user: said: "%{username}:" profile: "الملف الشخصي" @@ -1159,7 +1223,7 @@ ar: all: "الكل" read: "المقروءة" unread: "غير المقروءة" - unseen: "غير مرئي" + unseen: "غير مقروءة" ignore_duration_title: "تجاهل المستخدم" ignore_duration_username: "اسم المستخدم" ignore_duration_when: "المدة:" @@ -1226,6 +1290,8 @@ ar: dismiss: "تجاهل" dismiss_notifications: "تجاهل الكل" dismiss_notifications_tooltip: "وضع علامة مقروءة على كل الإشعارات غير المقروءة" + dismiss_bookmarks_tooltip: "وضع علامة \"مقروءة\" على كل تذكيرات الإشارات المرجعية غير المقروءة" + dismiss_messages_tooltip: "وضع علامة \"مقروءة\" على كل إشعارات الرسائل الشخصية غير المقروءة" no_messages_title: "ليس لديك أي رسائل" no_messages_body: > هل تحتاج إلى إجراء محادثة شخصية مباشرة مع شخص ما خارج مسار إجراء المحادثات التقليدي؟ راسله عن طريق تحديد صورته الرمزية واستخدام زر الرسالة %{icon}.

    إذا كنت بحاجة إلى مساعدة، يمكنك مراسلة عضو في فريق العمل. @@ -1236,9 +1302,12 @@ ar: no_notifications_title: "ليس لديك أي إشعارات بعد" no_notifications_body: > سيتم إعلامك في هذه اللوحة بالنشاط ذي الصلة المباشرة بك، بما في ذلك الردود على موضوعاتك ومنشوراتك، وعندما يشير إليك شخص ما @mentions أو يقتبس منك، وعندما يرد على الموضوعات التي تراقبها. سيتم أيضًا إرسال الإشعارات إلى بريدك الإلكتروني في حال عدم قيامك بتسجيل الدخول لفترة من الوقت.

    ابحث عن %{icon} لتحديد الموضوعات والفئات والوسوم المحدَّدة التي تريد أن يتم إرسال إشعار إليك بشأنها. لمزيد من المعلومات، راجع تفضيلات الإشعارات. + no_other_notifications_title: "ليس لديك أي إشعارات أخرى بعد" + no_other_notifications_body: > + سيتم تنبيهك في هذه اللوحة بشأن أنواع الأنشطة الأخرى التي قد تكون ذات صلة بك - على سبيل المثال، عندما ينشر شخص ما رابطًا إلى أحد منشوراتك أو يعدِّله. no_notifications_page_title: "ليس لديك أي إشعارات بعد" no_notifications_page_body: > - سيتم إعلامك عن النشاط الذي يتعلق بك مباشرةً، بما في ذلك الردود على الموضوعات و المشاركات الخاصة بك، عندما يقوم شخص @يذكر أو يقتبس منك، والردود على المواضيع التي تشاهدها. سيتم إرسال الإشعارات أيضا إلى بريدك الإلكتروني عندما لا تسجّل الدخول لفترة طويلة من الوقت.

    ابحث عن %{icon} لتحديد المواضيع والفئات والعلامات المحددة التي تريد إعلامك بها. للمزيد، راجع تفضيلات الإشعارات الخاصة بك. + سيتم إرسال إشعار إليك بشأن النشاط الذي يتعلق بك مباشرةً، بما في ذلك الردود على موضوعاتك ومشاركاتك، عندما يشير شخص ما إليك @mentions أو يقتبس منك، والردود على الموضوعات التي تشاهدها. سيتم إرسال الإشعارات أيضًا إلى بريدك الإلكتروني عندما لا تسجِّل الدخول لفترة طويلة من الوقت.

    ابحث عن %{icon} لتحديد الموضوعات والفئات والعلامات المحدَّدة التي تريد تلقي إشعار بشأنها. للمزيد، راجع تفضيلات الإشعارات لديك. first_notification: "أول إشعار تستلمه! اضغط عليه للبدء." dynamic_favicon: "عرض الأعداد على أيقونة المتصفح" skip_new_user_tips: @@ -1246,6 +1315,7 @@ ar: not_first_time: "ليست المرة الأولى لك؟" skip_link: "تخطي هذه النصائح" read_later: "سأقرأها لاحقًا." + reset_seen_popups: "عرض نصائح إعداد المستخدم مرة أخرى" theme_default_on_all_devices: "جعل هذه السمة الافتراضية على كل أجهزتي" color_scheme_default_on_all_devices: "ضبط نظام (أنظمة) الألوان الافتراضي على جميع أجهزتي" color_scheme: "نظام الألوان" @@ -1265,7 +1335,13 @@ ar: enable_quoting: "تفعيل الرد باقتباس للنص المميز" enable_defer: "تفعيل التأجيل لوضع علامة على الموضوعات كغير مقروءة" experimental_sidebar: + enable: "تفعيل الشريط الجانبي" options: "الخيارات" + categories_section: "قسم الفئات" + categories_section_instruction: "سيتم عرض الفئات المحدَّدة ضمن قسم فئات الشريط الجانبي." + tags_section: "قسم الوسوم" + tags_section_instruction: "سيتم عرض الوسوم المحدَّدة ضمن قسم وسوم الشريط الجانبي." + navigation_section: "التنقل" change: "تغيير" featured_topic: "الموضوع المميز" moderator: "‏%{user} مشرف في الموقع" @@ -1291,15 +1367,15 @@ ar: warning: "تم تفعيل وضع القائمة البريدية. تم تجاوز الإعدادات المتعلقة بإشعارات البريد الإلكتروني." tag_settings: "الوسوم" watched_tags: "المُراقَبة" - watched_tags_instructions: "ستراقب تلقائيًا جميع الموضوعات التي تحمل هذه الوسوم. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." + watched_tags_instructions: "ستراقب تلقائيًا كل الموضوعات التي تحمل هذه الوسوم. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." tracked_tags: "المتتبَّعة" - tracked_tags_instructions: "ستتتبَّع تلقائيًا جميع الموضوعات التي تحمل هذه الوسوم. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_tags_instructions: "ستتتبَّع تلقائيًا كل الموضوعات التي تحمل هذه الوسوم. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." muted_tags: "المكتومة" muted_tags_instructions: "لن تتلقى أي إشعارات أبدًا بخصوص الموضوعات الجديدة التي تحمل هذه الوسوم، ولن تظهر في قائمة أحدث الموضوعات." watched_categories: "المُراقَبة" - watched_categories_instructions: "ستراقب تلقائيًا جميع الموضوعات في هذه الفئات. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." + watched_categories_instructions: "ستراقب تلقائيًا كل الموضوعات في هذه الفئات. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." tracked_categories: "المتتبَّعة" - tracked_categories_instructions: "ستتتبَّع تلقائيًا جميع الموضوعات في هذه الفئات. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_categories_instructions: "ستتتبَّع تلقائيًا كل الموضوعات في هذه الفئات. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." watched_first_post_categories: "مراقبة أول منشور" watched_first_post_categories_instructions: "ستتلقى إشعارًا بأول منشور في كل موضوع جديد في هذه الفئات." watched_first_post_tags: "مراقبة أول منشور" @@ -1345,7 +1421,7 @@ ar: messages: all: "جميع صناديق الوارد" inbox: "صندوق الوارد" - personal: "شخصي" + personal: "شخصية" latest: "الحديثة" sent: "المُرسَلة" unread: "غير المقروءة" @@ -1387,6 +1463,7 @@ ar: tags: "الوسوم" interface: "الواجهة" apps: "التطبيقات" + sidebar: "الشريط الجانبي" change_password: success: "(تم إرسال رسالة البريد الإلكتروني)" in_progress: "(جارٍ إرسال رسالة البريد الإلكتروني)" @@ -1451,6 +1528,8 @@ ar: edit: "تعديل" edit_title: "تعديل تطبيق المصادقة" edit_description: "اسم تطبيق المصادقة" + enable_security_key_description: | + عندما يكون مفتاح الأمان المحمول أو جهاز الجوَّال المتوافق لديك جاهزًا، اضغط على الزر "تسجيل" أدناه. totp: title: "تطبيقات المصادقة المعتمدة على الرموز المميزة" add: "إضافة تطبيق مصادقة" @@ -1458,10 +1537,16 @@ ar: name_and_code_required_error: "يجب عليك إدخال اسم، والرمز من تطبيق المصادقة." security_key: register: "التسجيل" + title: "مفاتيح الأمان المادية" + add: "إضافة مفتاح أمان مادي" default_name: "مفتاح الأمان الرئيسي" + iphone_default_name: "iPhone" + android_default_name: "Android" not_allowed_error: "انتهت مهلة عملية تسجيل مفتاح الأمان أو تم إلغاؤها." already_added_error: "لقد سجَّلت مفتاح الأمان هذا بالفعل. ليس عليك تسجيله مرة أخرى." + edit: "تعديل مفتاح الأمان المادي" save: "حفظ" + edit_description: "اسم مفتاح الأمان المادي" name_required_error: "يجب عليك إدخال اسم لمفتاح الأمان." change_about: title: "تغيير \"نبذة عني\"" @@ -1493,6 +1578,7 @@ ar: upload_title: "تحميل صورتك" image_is_not_a_square: "تحذير: لقد قصصنا صورتك؛ لأن عرضها وارتفاعها لم يكونا متساويين." logo_small: "شعار الموقع الصغير. يتم استخدامه بشكلٍ افتراضي." + use_custom: "أو حمِّل صورة رمزية مخصَّصة:" change_profile_background: title: "رأس الملف الشخصي" instructions: "سيتم توسيط رؤوس الملفات الشخصية وسيكون عرضها الافتراضي 1110 بكسل." @@ -1541,7 +1627,7 @@ ar: not_connected: "(غير مرتبط)" confirm_modal_title: "ربط حساب %{provider}" confirm_description: - disconnect: "سيتم قطع الاتصال بحسابك الحالي %{provider} '%{account_description}'." + disconnect: "سيتم إلغاء ربط حسابك الحالي %{provider} '%{account_description}'." account_specific: "سيتم استخدام حسابك على %{provider} \"%{account_description}\" للمصادقة." generic: "سيتم استخدام حسابك على %{provider} للمصادقة." name: @@ -1629,9 +1715,11 @@ ar: every_month: "كل شهر" every_six_months: "كل ستة أشهر" email_level: + title: "مراسلتي عبر البريد الإلكتروني عند اقتباس كلامي أو الرد عليَّ أو عند الإشارة إلى @username الخاص بي، أو عندما يكون هناك نشاط جديد على فئاتي أو وسومي أو موضوعاتي المراقبة" always: "دائمًا" only_when_away: "عندما أكون بعيدًا فقط" never: "أبدًا" + email_messages_level: "مراسلتي عبر البريد الإلكتروني عند إرسال رسالة شخصية إليَّ" include_tl0_in_digests: "تضمين المحتوى من المستخدمين الجُدد في الرسائل الإلكترونية التلخيصية" email_in_reply_to: "تضمين مقتطف من الرد على المنشور في الرسائل الإلكترونية" other_settings: "أخرى" @@ -1714,15 +1802,15 @@ ar: invite: new_title: "إنشاء دعوة" edit_title: "تعديل الدعوة" - instructions: "شارك هذا الرابط لمنح حق الوصول إلى هذا الموقع على الفور:" + instructions: "شارك هذا الرابط لمنح إذن الوصول إلى هذا الموقع على الفور:" copy_link: "نسخ الرابط" expires_in_time: "تنتهي صلاحيتها في %{time}" expired_at_time: "تنتهي صلاحيتها في %{time}" show_advanced: "إظهار الخيارات المتقدمة" hide_advanced: "إخفاء الخيارات المتقدمة" - restrict: "تقييد لـ" - restrict_email: "تقييد البريد الإلكتروني" - restrict_domain: "تقييد النطاق" + restrict: "التقييد إلى" + restrict_email: "التقييد إلى البريد الإلكتروني" + restrict_domain: "التقييد إلى النطاق" email_or_domain_placeholder: "name@example.com أو example.com" max_redemptions_allowed: "الحد الأقصى لمرات الاستخدام" add_to_groups: "إضافة إلى المجموعات" @@ -1756,7 +1844,9 @@ ar: title: "الملخص" stats: "الإحصاءات" time_read: "وقت القراءة" + time_read_title: "%{duration} (جميع الأوقات)" recent_time_read: "وقت آخر قراءة" + recent_time_read_title: "%{duration} (في آخر 60 يومًا)" topic_count: zero: "موضوع تم إنشاؤه" one: "موضوع واحد تم إنشاؤه" @@ -1827,7 +1917,7 @@ ar: most_liked_by: "الأكثر تسجيلًا للإعجاب" most_liked_users: "الأكثر تلقيًا للإعجاب" most_replied_to_users: "الأكثر تلقيًا للردود" - no_likes: "لا توجد مرات إعجاب بعد." + no_likes: "لا توجد تسجيلات إعجاب حتى الآن." top_categories: "أهم الفئات" topics: "الموضوعات" replies: "الردود" @@ -1848,6 +1938,9 @@ ar: title: "الطابع" none: "(لا يوجد)" instructions: "يتم عرض الرمز بجوار صورة ملفك الشخصي" + status: + title: "حالة مخصَّصة" + not_set: "غير محدَّد" primary_group: title: "المجموعة الأساسية" none: "(لا يوجد)" @@ -1860,8 +1953,18 @@ ar: the_topic: "الموضوع" user_status: save: "حفظ" - set_custom_status: "تعيين حالة مخصصة" + set_custom_status: "تعيين حالة مخصَّصة" what_are_you_doing: "ماذا تفعل؟" + remove_status: "إزالة الحالة" + popup: + primary: "فهمت!" + secondary: "عدم عرض هذه النصائح لي" + first_notification: + title: "أول إشعار لك!" + content: "تُستخدَم الإشعارات لإبقائك على اطِّلاع بما يحدث في المجتمع." + topic_timeline: + title: "الجدول الزمني للموضوع" + content: "مرِّر عبر المنشور بسرعة باستخدام الجدول الزمني للموضوع." loading: "جارٍ التحميل..." errors: prev_page: "في أثناء محاولة التحميل" @@ -1894,6 +1997,8 @@ ar: enabled: "هذا الموقع في وضع القراءة فقط. نأمل أن تواصل تصفُّحه، لكن الرد وتسجيل الإعجاب وغيرهما من الإجراءات ستكون متوقفة حاليًا." login_disabled: "يكون تسجيل الدخول متوقفًا في حال كان الموقع في وضع القراءة فقط." logout_disabled: "يتم إيقاف تسجيل الخروج عندما يكون الموقع في وضع القراءة فقط." + staff_writes_only_mode: + enabled: "هذا الموقع في وضع القراءة فقط. يُرجى المتابعة للتصفح، لكن سيقتصر التصفح والرد وتسجيل الإعجاب والإجراءات الأخرى على الأعضاء في طاقم العمل فقط." too_few_topics_and_posts_notice_MF: >- لنبدأ المناقشة! هناك {currentTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}} و{currentPosts, plural, zero {# منشور} one {منشور واحد (#)} two {منشوران (#)} few {# منشورات} many {# منشورًا} other {# منشور}}. يحتاج الزوار إلى المزيد ليقرؤوه ويردوا عليه. إننا نقترح {requiredTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}} و{requiredPosts, plural, zero {# منشور} one {منشور واحد (#)} two {منشوران (#)} few {# منشورات} many {# منشورًا} other {# منشور}} على الأقل. يمكن لفريق العمل فقط رؤية هذه الرسالة. too_few_topics_notice_MF: >- @@ -1925,9 +2030,11 @@ ar: other: ردود signup_cta: sign_up: "الاشتراك" + hide_session: "ربما لاحقًا" hide_forever: "لا، شكرًا" hidden_for_session: "حسنًا، سنسألك غدًا. يمكنك دائمًا استخدام \"تسجيل الدخول\" لإنشاء حساب أيضًا." intro: "مرحبًا! يبدو أنك تستمتع بالمناقشة، لكنك لم تشترك للحصول على حساب حتى الآن." + value_prop: "هل سئمت من التمرير عبر المنشورات نفسها؟ عندما تُنشئ حسابًا، فستستأنف دائمًا من حيث توقفت. عند إنشاء حساب، ستتمكن أيضًا من تلقي إشعارات بالردود الجديدة وحفظ الإشارات المرجعية وتسجيل الإعجاب لشكر الآخرين. يمكننا جميعًا العمل معًا للارتقاء بهذا المجتمع. :heart:" summary: enabled_description: "أنت تعرض ملخصًا لهذا الموضوع: المنشورات الأكثر إثارة للاهتمام وفقًا للمجتمع." description: @@ -1957,6 +2064,7 @@ ar: remove_allowed_user: "هل تريد حقًا إزالة %{name} من هذه الرسالة؟" remove_allowed_group: "هل تريد حقًا إزالة %{name} من هذه الرسالة؟" leave: "مغادرة" + remove_group: "إزالة المجموعة" remove_user: "إزالة مستخدم" email: "البريد الإلكتروني" username: "اسم المستخدم" @@ -1971,12 +2079,12 @@ ar: disclaimer: "يشير التسجيل إلى موافقتك على سياسة الخصوصية وشروط الخدمة." title: "إنشاء حسابك" failed: "حدث خطأ ما. قد يكون هذا البريد الإلكتروني مسجلًا بالفعل. جرِّب رابط نسيان كلمة المرور" - associate: "لديك حساب بالفعل؟ سجّل الدخول لربط حسابك %{provider}." + associate: "لديك حساب بالفعل؟ سجِّل الدخول لربط حسابك من %{provider}." forgot_password: title: "إعادة ضبط كلمة المرور" action: "نسيت كلمة مروري" invite: "أدخِل اسم المستخدم أو عنوان البريد الإلكتروني، وسنُرسل إليك رسالة إلكترونية لإعادة ضبط كلمة المرور." - invite_no_username: "أدخل عنوان بريدك الإلكتروني، وسوف نرسل لك رسالة إعادة تعيين كلمة المرور." + invite_no_username: "أدخل عنوان بريدك الإلكتروني، وسنُرسل إليك رسالة لإعادة تعيين كلمة المرور." reset: "إعادة ضبط كلمة المرور" complete_username: "إذا تطابق أحد الحسابات مع اسم المستخدم %{username}، فستتلقى سريعًا رسالة إلكترونية تتضمَّن تعليمات عن كيفية إعادة ضبط كلمة المرور." complete_email: "إذا تطابق أحد الحسابات مع %{email}، فستتلقى سريعًا رسالة إلكترونية تتضمَّن تعليمات عن كيفية إعادة ضبط كلمة المرور." @@ -2013,6 +2121,7 @@ ar: second_factor_backup_title: "الرمز الاحتياطي للمصادقة الثنائية" second_factor_backup_description: "يُرجى إدخال أحد الرموز الاحتياطية:" second_factor: "تسجيل الدخول باستخدام تطبيق المصادقة" + security_key_description: "عندما يكون مفتاح الأمان المادي أو جهاز الجوَّال المتوافق جاهزًا، اضغط على زر \"المصادقة باستخدام مفتاح الأمان\" أدناه." security_key_alternative: "جرِّب طريقة أخرى" security_key_authenticate: "المصادقة باستخدام مفتاح الأمان" security_key_not_allowed_error: "انتهت مهلة عملية المصادقة باستخدام مفتاح الأمان أو تم إلغاؤها." @@ -2048,26 +2157,32 @@ ar: not_approved: "لم تتم الموافقة على حسابك حتى الآن. سيتم إعلامك عبر البريد الإلكتروني عندما تكون مستعدًا لتسجيل الدخول." google_oauth2: name: "Google" + title: "تسجيل الدخول باستخدام Google" + sr_title: "تسجيل الدخول باستخدام Google" twitter: name: "Twitter" + title: "تسجيل الدخول باستخدام Twitter" + sr_title: "تسجيل الدخول باستخدام Twitter" instagram: name: "Instagram" + title: "تسجيل الدخول باستخدام Instagram" sr_title: "تسجيل الدخول باستخدام Instagram" facebook: name: "Facebook" - title: "تسجيل الدخول باستخدام فيسبوك" - sr_title: "تسجيل الدخول باستخدام فيسبوك" + title: "تسجيل الدخول باستخدام Facebook" + sr_title: "تسجيل الدخول باستخدام Facebook" github: name: "GitHub" title: "تسجيل الدخول باستخدام GitHub" sr_title: "تسجيل الدخول باستخدام GitHub" discord: name: "Discord" - title: "تسجيل الدخول باستخدام ديسكورد" - sr_title: "تسجيل الدخول باستخدام ديسكورد" + title: "تسجيل الدخول باستخدام Discord" + sr_title: "تسجيل الدخول باستخدام Discord" second_factor_toggle: totp: "استخدام تطبيق مصادقة بدلًا من ذلك" backup_code: "استخدام رمز احتياطي بدلًا من ذلك" + security_key: "استخدم مفتاح أمان بدلًا من ذلك" invites: accept_title: "الدعوة" emoji: "رمز مظروف" @@ -2092,9 +2207,11 @@ ar: categories_only: "الفئات فقط" categories_with_featured_topics: "الفئات ذات الموضوعات المميزة" categories_and_latest_topics: "الفئات وأحدث الموضوعات" + categories_and_latest_topics_created_date: "الفئات وأحدث الموضوعات (الترتيب حسب تاريخ إنشاء الموضوع)" categories_and_top_topics: "الفئات والموضوعات الأكثر نشاطًا" categories_boxes: "المربعات ذات الفئات الفرعية" categories_boxes_with_topics: "مربعات بالموضوعات المميزة" + subcategories_with_featured_topics: "الفئات الفرعية ذات الموضوعات المميزة" shortcut_modifier_key: shift: "Shift" ctrl: "Ctrl" @@ -2131,12 +2248,12 @@ ar: default_header_text: تحديد... no_content: لم يتم العثور على نتائج مطابقة results_count: - zero: "%{count} لا توجد نتائج" - one: "%{count} نتيجة" - two: "%{count} نتيجتان" - few: "%{count} القليل من النتائج" - many: "%{count} نتائج" - other: "%{count} نتائج أخرى" + zero: "%{count} نتيجة" + one: "نتيجة واحدة (%{count})" + two: "نتيجتان (%{count})" + few: "%{count} نتائج" + many: "%{count} نتيجة" + other: "%{count} نتيجة" filter_placeholder: بحث... filter_placeholder_with_any: بحث أو إنشاء... create: "إنشاء: \"%{content}\"" @@ -2202,6 +2319,9 @@ ar: similar_topics: "موضوعك يشابه..." drafts_offline: "مسودات بلا اتصال" edit_conflict: "تعارض في التعديل" + esc: "Esc" + esc_label: "انقر أو اضغط على Esc للتجاهل" + ok_proceed: "حسنًا، المتابعة" group_mentioned_limit: zero: "تحذير!لقد أشرت إلى %{group}، لكن هذه المجموعة تتضمَّن عدد أعضاء أكثر من حد الإشارة الذي عيَّنه المسؤول وهو %{count} مستخدم. لن يتم إرسال إشعار إلى أي أحد." one: "تحذير!لقد أشرت إلى %{group}، لكن هذه المجموعة تتضمَّن عدد أعضاء أكثر من حد الإشارة الذي عيَّنه المسؤول وهو مستخدم واحد (%{count}). لن يتم إرسال إشعار إلى أي أحد." @@ -2217,17 +2337,17 @@ ar: many: "تشير الإشارة إلى %{group} أنك على وشك إرسال إشعار إلى %{count} شخصًا). هل أنت متأكد؟" other: "تشير الإشارة إلى %{group} أنك على وشك إرسال إشعار إلى %{count} شخص). هل أنت متأكد؟" cannot_see_mention: - category: "لقد أشرت إلى @%{username}، لكنه لن يتلقى إشعارًا لأنه ليس لديه إذن بالوصول إلى هذا التصنيف. عليك إضافته إلى إحدى المجموعات التي لديها إذن بالوصول إلى هذا التصنيف." - private: "لقد أشرت إلى @%{username}، ولكن لن يتم إشعاره لأنه لا يمكنه رؤية هذه الرسالة الخاصة. عليك دعوته إلى هذه الرسالة الخاصة." - muted_topic: "لقد ذكرت @%{username} ولكن لن يتم إخطاره لأنه كتم هذا الموضوع." - not_allowed: "لقد ذكرت @%{username} ولكن لن يُعلم لأنه لم يدع إلى هذا الموضوع." + category: "لقد أشرت إلى @%{username}، لكنه لن يتلقى إشعارًا لأنه ليس لديه إذن بالوصول إلى هذه الفئة. عليك إضافته إلى إحدى المجموعات التي لديها إذن بالوصول إلى هذه الفئة." + private: "لقد أشرت إلى @%{username}، لكنه لن يتلقى إشعارًا لأنه لا يمكنه رؤية هذه الرسالة الخاصة. عليك دعوته إلى هذه الرسالة الخاصة." + muted_topic: "لقد أشرت إلى @%{username}، لكنه لن يتلقى إشعارًا لأنه كتم هذا الموضوع." + not_allowed: "لقد أشرت إلى @%{username}، لكنه لن يتلقى إشعارًا لأنه لم تتم دعوته إلى هذا الموضوع." here_mention: - zero: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدم - هل أنت متأكد من ذلك؟" - one: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدم - هل أنت متأكد من ذلك؟" - two: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدمان - هل أنت متأكد من ذلك؟" - few: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدم - هل أنت متأكد من ذلك؟" - many: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدمين - هل أنت متأكد من ذلك؟" - other: "بالإشارة إلى @%{here}، أنت على وشك إشعار %{count} المستخدمين - هل أنت متأكد من ذلك؟" + zero: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى %{count} مستخدم - هل أنت متأكد من ذلك؟" + one: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى مستخدم واحد (%{count}) - هل أنت متأكد من ذلك؟" + two: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى مستخدمَين (%{count}) - هل أنت متأكد من ذلك؟" + few: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى %{count} مستخدمين - هل أنت متأكد من ذلك؟" + many: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى %{count} مستخدمًا - هل أنت متأكد من ذلك؟" + other: "بالإشارة إلى @%{here}، أنت على وشك إرسال إشعار إلى %{count} مستخدم - هل أنت متأكد من ذلك؟" duplicate_link: "يبدو أن رابطك إلى %{domain} قدم تم نشره في الموضوع بواسطة @%{username} في رد بتاريخ %{ago}. هل تريد بالتأكيد نشره مرة أخرى؟" reference_topic_title: "بخصوص: %{title}" error: @@ -2276,6 +2396,7 @@ ar: create_shared_draft: "إنشاء مسودة مشتركة" edit_shared_draft: "تعديل المسودة المشتركة" title: "أو اضغط على %{modifier}Enter" + users_placeholder: "إضافة مستخدمين أو مجموعات" title_placeholder: "ما موضوع هذه المناقشة في جملة واحدة مختصرة؟" title_or_link_placeholder: "اكتب عنوانًا أو الصق رابطًا هنا" edit_reason_placeholder: "ما سبب التعديل؟" @@ -2320,6 +2441,7 @@ ar: abandon: "إغلاق أداة الإنشاء وتجاهل المسودة" enter_fullscreen: "فتح أداة الإنشاء في وضع ملء الشاشة" exit_fullscreen: "الخروج من أداة الإنشاء في وضع ملء الشاشة" + exit_fullscreen_prompt: "اضغط على ESC للخروج من وضع الشاشة الكاملة" show_toolbar: "عرض شريط أداة الإنشاء" hide_toolbar: "إخفاء شريط أداة الإنشاء" modal_ok: "حسنًا" @@ -2330,6 +2452,9 @@ ar: body: "هذه الرسالة يتم إرسالها إليك فقط في الوقت الحالي!" slow_mode: error: "هذا الموضوع في الوضع البطيء. لقد نشرت بالفعل مؤخرًا؛ يمكنك النشر مرة أخرى بعد %{timeLeft}." + user_not_seen_in_a_while: + single: "لم يظهر الشخص الذي تراسله، %{usernames}، هنا منذ فترة طويلة جدًا - %{time_ago}. وقد لا يتلقَّى رسالتك. قد ترغب في البحث عن طرق بديلة للتواصل مع %{usernames}." + multiple: "لم يظهر الأشخاص الذين تراسلهم، %{usernames}، هنا منذ فترة طويلة جدًا - %{time_ago}. وقد لا يتلقون رسالتك. قد ترغب في البحث عن طرق بديلة للتواصل معهم." admin_options_title: "إعدادات فريق العمل الاختيارية لهذا الموضوع" composer_actions: reply: الرد @@ -2363,6 +2488,7 @@ ar: ignore: "تجاهل" image_alt_text: aria_label: النص البديل للصورة + delete_image_button: حذف الصورة notifications: tooltip: regular: @@ -2392,6 +2518,7 @@ ar: post_approved: "تمت الموافقة على منشورك" reviewable_items: "عناصر تتطلب المراجعة" watching_first_post_label: "موضوع جديد" + user_moved_post: "تم نقل %{username}" mentioned: "‏⁨%{username}⁩ ‏%{description}" group_mentioned: "‏⁨%{username}⁩ ‏%{description}" quoted: "‏⁨%{username}⁩ ‏%{description}" @@ -2408,14 +2535,14 @@ ar: few: "%{username} و%{username2} و%{count} مستخدمين آخرين %{description}" many: "‏%{username} و%{username2} و%{count} مستخدمًا آخر ‏%{description}" other: "‏%{username} و%{username2} و%{count} مستخدم آخر ‏%{description}" - liked_by_2_users: "%{username}, %{username2}" + liked_by_2_users: "%{username}، %{username2}" liked_by_multiple_users: - zero: "%{username} و%{username2} و%{count} مستخدمين آخرين" + zero: "%{username} و%{username2} و%{count} مستخدم آخر" one: "%{username} و%{username2} ومستخدم واحد (%{count}) آخر" two: "%{username} و%{username2} ومستخدمان (%{count}) آخران" few: "%{username} و%{username2} و%{count} مستخدمين آخرين" - many: "%{username} و%{username2} و%{count} مستخدمين آخرين" - other: "%{username} و%{username2} و%{count} مستخدمين آخرين" + many: "%{username} و%{username2} و%{count} مستخدمًا آخر" + other: "%{username} و%{username2} و%{count} مستخدم آخر" liked_consolidated_description: zero: "سجَّل إعجابه على %{count} من منشوراتك" one: "سجَّل إعجابه على واحد (%{count}) من منشوراتك" @@ -2428,6 +2555,7 @@ ar: invited_to_private_message: "‏

    ⁨%{username}⁩ ‏%{description}" invited_to_topic: "‏⁨%{username}⁩ ‏%{description}" invitee_accepted: "قَبِل ‏⁨%{username}⁩ دعوتك" + invitee_accepted_your_invitation: "قبل دعوتك" moved_post: "‏نَقَل ⁨%{username}⁩ المنشور %{description}" linked: "‏⁨%{username}⁩ ‏%{description}" granted_badge: "تم منحك شارة \"%{description}\"" @@ -2447,12 +2575,26 @@ ar: dismiss_confirmation: body: default: - zero: "هل أنت متأكد؟ لديك %{count} من الإشعارات الهامة." - one: "هل أنت متأكد؟ لديك %{count} إشعار مهم." - two: "هل أنت متأكد؟ لديك %{count} إشعاران هامان." - few: "هل أنت متأكد؟ لديك %{count} من الإشعارات الهامة." - many: "هل أنت متأكد؟ لديك %{count} إشعارات مهمة." - other: "هل أنت متأكد؟ لديك %{count} من الإشعارات الهامة." + zero: "هل أنت متأكد؟ لديك %{count} من الإشعارات المهمة." + one: "هل أنت متأكد؟ لديك إشعار واحد (%{count}) مهم." + two: "هل أنت متأكد؟ لديك إشعاران (%{count}) مهمان." + few: "هل أنت متأكد؟ لديك %{count} إشعارات مهمة." + many: "هل أنت متأكد؟ لديك %{count} إشعارًا مهمًا." + other: "هل أنت متأكد؟ لديك %{count} إشعار مهم." + bookmarks: + zero: "هل أنت متأكد؟ لديك %{count} تذكير بإشارة مرجعية غير مقروء." + one: "هل أنت متأكد؟ لديك تذكير واحد (%{count}) بإشارة مرجعية غير مقروء." + two: "هل أنت متأكد؟ لديك تذكيران (%{count}) بإشارة مرجعية غير مقروءين." + few: "هل أنت متأكد؟ لديك %{count} تذكيرات بإشارة مرجعية غير مقروءة." + many: "هل أنت متأكد؟ لديك %{count} تذكيرًا بإشارة مرجعية غير مقروء." + other: "هل أنت متأكد؟ لديك %{count} تذكير بإشارة مرجعية غير مقروء." + messages: + zero: "هل أنت متأكد؟ لديك %{count} رسالة شخصية غير مقروءة." + one: "هل أنت متأكد؟ لديك رسالة شخصية واحدة (%{count}) غير مقروءة." + two: "هل أنت متأكد؟ لديك رسالتان (%{count}) شخصيتان غير مقروءتين." + few: "هل أنت متأكد؟ لديك %{count} رسائل شخصية غير مقروءة." + many: "هل أنت متأكد؟ لديك %{count} رسالة شخصية غير مقروءة." + other: "هل أنت متأكد؟ لديك %{count} رسالة شخصية غير مقروءة." dismiss: "تجاهل" cancel: "إلغاء" group_message_summary: @@ -2514,9 +2656,9 @@ ar: select_all: "تحديد الكل" clear_all: "مسح الكل" too_short: "عبارة البحث قصيرة جدًا." - open_advanced: "فتح البحث المتقدم" + open_advanced: "فتح بحث متقدم" clear_search: "مسح البحث" - sort_or_bulk_actions: "فرز النتائج أو تحديدها بشكل جماعي" + sort_or_bulk_actions: "ترتيب النتائج أو تحديدها بشكلٍ جماعي" result_count: zero: "%{count}%{plus} نتيجة بحث عن العبارة %{term}" one: "نتيجة بحث واحدة (%{count}) عن العبارة %{term}" @@ -2542,19 +2684,21 @@ ar: tags: "الوسوم" in: "في" in_this_topic: "في هذا الموضوع" - in_this_topic_tooltip: "التبديل إلى البحث في جميع الموضوعات" - in_topics_posts: "في جميع الموضوعات والمشاركات" + in_this_topic_tooltip: "التبديل إلى البحث في كل الموضوعات" + in_messages: "في الرسائل" + in_messages_tooltip: "التبديل إلى البحث في الموضوعات العادية" + in_topics_posts: "في كل الموضوعات والمنشورات" enter_hint: "أو اضغط على Enter" - in_posts_by: "في المشاركات بواسطة %{username}" + in_posts_by: "في منشورات %{username}" browser_tip: "%{modifier} + f" browser_tip_description: "مرة أخرى لاستخدام بحث المتصفح الأصلي" recent: "عمليات البحث الأخيرة" clear_recent: "مسح عمليات البحث الأخيرة" type: - default: "المواضيع/المشاركات" + default: "الموضوعات/المنشورات" users: "المستخدمون" categories: "الفئات" - categories_and_tags: "التصنيفات/الوسوم" + categories_and_tags: "الفئات/الوسوم" context: user: "البحث عن المنشورات باسم المستخدم @%{username}" category: "البحث في الفئة #%{category}" @@ -2562,17 +2706,17 @@ ar: topic: "البحث في هذا الموضوع" private_messages: "البحث في الرسائل" tips: - category_tag: "فرز حسب التصنيف أو الوسم" - author: "فرز حسب كاتب المشاركة" - in: "فرز بواسطة بيانات التعريف (مثلاً: in:title, in:personal, in:pinne)" - status: "فرز حسب حالة الموضوع" - full_search: "تشغيل البحث الكامل في الصفحة" + category_tag: "يقوم بالتصفية حسب الفئة أو الوسم" + author: "يقوم بالتصفية حسب مؤلف المنشور" + in: "يقوم بالتصفية حسب البيانات الوصفية (مثلًا: in:title، in:personal، in:pinned)" + status: "يقوم بالتصفية حسب حالة الموضوع" + full_search: "يشغِّل البحث في الصفحة بأكملها" full_search_key: "%{modifier} + Enter" advanced: - title: فلاتر متقدمة + title: عوامل تصفية متقدمة posted_by: label: تم النشر بواسطة - aria_label: فرز حسب كاتب المشاركة + aria_label: التصفية حسب كاتب المنشور in_category: label: في الفئة in_group: @@ -2581,7 +2725,7 @@ ar: label: باستخدام الشارة with_tags: label: بالوسوم - aria_label: فرز باستخدام الوسوم + aria_label: التصفية باستخدام الوسوم filters: label: تقييد نتائج البحث على الموضوعات/المنشورات التي... title: مطابقة العنوان فقط @@ -2612,25 +2756,25 @@ ar: label: المنشورات min: placeholder: الحد الأدنى - aria_label: فرز حسب الحد الأدنى لعدد المشاركات + aria_label: التصفية حسب أدنى عدد من المنشورات max: placeholder: الحد الأقصى - aria_label: فرز حسب الحد الأقصى لعدد المشاركات + aria_label: التصفية حسب أقصى عدد من المنشورات time: label: تاريخ النشر - aria_label: فرز حسب تاريخ المنشور + aria_label: التصفية حسب تاريخ النشر before: قبل after: بعد views: label: مرات العرض min_views: placeholder: الحد الأدنى - aria_label: فرز حسب الحد الأدنى من المشاهدات + aria_label: التصفية حسب أدنى عدد من المشاهدات max_views: placeholder: الحد الأقصى - aria_label: فرز حسب الحد الأقصى من المشاهدات + aria_label: التصفية حسب أقصى عدد من المشاهدات additional_options: - label: "فرز حسب عدد المشاركات وعدد مشاهدات الموضوع" + label: "التصفية حسب عدد المنشورات وعدد مشاهدات الموضوع" hamburger_menu: "القائمة" new_item: "جديد" go_back: "الرجوع" @@ -2638,12 +2782,87 @@ ar: current_user: "الانتقال إلى صفحة المستخدم" view_all: "عرض الكل %{tab}" user_menu: + generic_no_items: "لا توجد عناصر في هذه القائمة." + sr_menu_tabs: "علامات تبويب قائمة المستخدم" + view_all_notifications: "عرض كل الإشعارات" + view_all_bookmarks: "عرض كل الإشارات المرجعية" + view_all_messages: "عرض كل الرسائل الشخصية" tabs: + all_notifications: "كل الإشعارات" replies: "الردود" + replies_with_unread: + zero: "الردود - %{count} رد غير مقروء" + one: "الردود - رد واحد (%{count}) غير مقروء" + two: "الردود - ردَّان (%{count}) غير مقروءين" + few: "الردود - %{count} ردود غير مقروءة" + many: "الردود - %{count} ردًا غير مقروء" + other: "الردود - %{count} رد غير مقروء" mentions: "الإشارات" - likes: "الاعجابات" + mentions_with_unread: + zero: "الإشارات - %{count} إشارة غير مقروءة" + one: "الإشارات - إشارة واحدة (%{count}) غير مقروءة" + two: "الإشارات - إشارتان (%{count}) غير مقروءتين" + few: "الإشارات - %{count} إشارات غير مقروءة" + many: "الإشارات - %{count} إشارة غير مقروءة" + other: "الإشارات - %{count} إشارة غير مقروءة" + likes: "تسجيلات الإعجاب" + likes_with_unread: + zero: "تسجيلات الإعجاب - %{count} إعجاب غير مقروء" + one: "تسجيلات الإعجاب - إعجاب واحد (%{count}) غير مقروء" + two: "تسجيلات الإعجاب - إعجابان (%{count}) غير مقروءين" + few: "تسجيلات الإعجاب - %{count} إعجابات غير مقروءة" + many: "تسجيلات الإعجاب - %{count} إعجابًا غير مقروء" + other: "تسجيلات الإعجاب - %{count} إعجاب غير مقروء" + watching: "الموضوعات المراقبة" + watching_with_unread: + zero: "الموضوعات المراقبة - %{count} موضوع غير مراقب" + one: "الموضوعات المراقبة - موضوع واحد (%{count}) غير مراقب" + two: "الموضوعات المراقبة - موضوعان (%{count}) غير مراقبين" + few: "الموضوعات المراقبة - %{count} موضوعات غير مراقبة" + many: "الموضوعات المراقبة - %{count} موضوعًا غير مراقب" + other: "الموضوعات المراقبة - %{count} موضوع غير مراقب" + messages: "الرسائل الشخصية" + messages_with_unread: + zero: "الرسائل الشخصية - %{count} رسالة غير مقروءة" + one: "الرسائل الشخصية - رسالة واحدة (%{count}) غير مقروءة" + two: "الرسائل الشخصية - رسالتان (%{count}) غير مقروءتين" + few: "الرسائل الشخصية - %{count} رسائل غير مقروءة" + many: "الرسائل الشخصية - %{count} رسالة غير مقروءة" + other: "الرسائل الشخصية - %{count} رسالة غير مقروءة" bookmarks: "الإشارات المرجعية" + bookmarks_with_unread: + zero: "الإشارات المرجعية - %{count} إشارة مرجعية غير مقروءة" + one: "الإشارات المرجعية - إشارة مرجعية واحدة (%{count}) غير مقروءة" + two: "الإشارات المرجعية - إشارتان مرجعيتان (%{count}) غير مقروءتين" + few: "الإشارات المرجعية - %{count} إشارات مرجعية غير مقروءة" + many: "الإشارات المرجعية - %{count} إشارة مرجعية غير مقروءة" + other: "الإشارات المرجعية - %{count} إشارة مرجعية غير مقروءة" + review_queue: "قائمة انتظار المراجعة" + review_queue_with_unread: + zero: "قائمة انتظار المراجعة - %{count} عنصر بحاجة إلى المراجعة" + one: "قائمة انتظار المراجعة - عنصر واحد (%{count}) بحاجة إلى المراجعة" + two: "قائمة انتظار المراجعة - عنصران (%{count}) بحاجة إلى المراجعة" + few: "قائمة انتظار المراجعة - %{count} عناصر بحاجة إلى المراجعة" + many: "قائمة انتظار المراجعة - %{count} عنصرًا بحاجة إلى المراجعة" + other: "قائمة انتظار المراجعة - %{count} عنصر بحاجة إلى المراجعة" + other_notifications: "إشعارات أخرى" + other_notifications_with_unread: + zero: "إشعارات أخرى - %{count} إشعار غير مقروء" + one: "إشعارات أخرى - إشعار واحد (%{count}) غير مقروء" + two: "إشعارات أخرى - إشعاران (%{count}) غير مقروءين" + few: "إشعارات أخرى - %{count} إشعارات غير مقروءة" + many: "إشعارات أخرى - %{count} إشعارًا غير مقروء" + other: "إشعارات أخرى - %{count} إشعار غير مقروء" profile: "الملف الشخصي" + reviewable: + view_all: "عرض كل عناصر المراجعة" + queue: "قائمة الانتظار" + deleted_user: "(مستخدم محذوف)" + deleted_post: "(منشور محذوف)" + post_number_with_topic_title: "المنشور #%{post_number} - %{title}" + new_post_in_topic: "منشور جديد في %{title}" + user_requires_approval: "%{username} يتطلب الموافقة" + default_item: "العنصر القابل للمراجعة #%{reviewable_id}" topics: new_messages_marker: "آخر زيارة" bulk: @@ -2651,10 +2870,11 @@ ar: clear_all: "مسح الكل" unlist_topics: "إلغاء إدراج الموضوعات" relist_topics: "إعادة إدراج الموضوعات" + reset_bump_dates: "إعادة تعيين تواريخ الرفع" defer: "تأجيل" delete: "حذف الموضوعات" dismiss: "تجاهل" - dismiss_read: "تجاهل جميع الموضوعات غير المقروءة" + dismiss_read: "تجاهل كل الموضوعات غير المقروءة" dismiss_read_with_selected: zero: "تجاهل %{count} غير مقروءة" one: "تجاهل %{count} غير مقروءة" @@ -2682,7 +2902,7 @@ ar: other: "تجاهل الجديدة (%{count})" toggle: "تفعيل التحديد الجماعي للموضوعات" actions: "الإجراءات الجماعية" - change_category: "تعيين التصنيف..." + change_category: "ضبط الفئة..." close_topics: "إغلاق الموضوعات" archive_topics: "أرشفة الموضوعات" move_messages_to_inbox: "النقل إلى صندوق الوارد" @@ -2718,7 +2938,7 @@ ar: other: "التقدُّم: %{count} موضوع" none: unread: "ليس لديك أي موضوع غير مقروء." - unseen: "ليس لديك مواضيع غير مقروءة." + unseen: "ليس لديك موضوعات غير مقروءة." new: "ليس لديك أي موضوع جديد." read: "لم تقرأ أي موضوع بعد." posted: "لم تنشر في أي موضوع بعد." @@ -2983,6 +3203,8 @@ ar: unpin: "إلغاء تثبيت الموضوع" unarchive: "إلغاء أرشفة الموضوع" archive: "أرشفة الموضوع" + invisible: "إلغاء إدراج الموضوع" + visible: "إدارج الموضوع" reset_read: "إعادة ضبط بيانات القراءة" make_public: "التحويل إلى موضوع عام..." make_private: "التحويل إلى رسالة خاصة" @@ -2997,16 +3219,17 @@ ar: title: "الرد" help: "ابدأ في كتابة رد على هذه الموضوع" share: + title: "مشاركة الموضوع" extended_title: "مشاركة رابط" help: "شارِك رابطًا إلى هذا الموضوع" instructions: "مشاركة رابط إلى هذا الموضوع:" copied: "تم نسخ رابط الدعوة." restricted_groups: zero: "مرئي فقط لأعضاء المجموعة: %{groupNames}" - one: "مرئي فقط لعضو المجموعة: %{groupNames}" - two: "مرئي فقط لعضوان المجموعة: %{groupNames}" - few: "مرئي فقط لأعضاء المجموعة: %{groupNames}" - many: "مرئي فقط لأعضاء المجموعة: %{groupNames}" + one: "مرئي فقط لأعضاء المجموعة: %{groupNames}" + two: "مرئي فقط لأعضاء المجموعتَين: %{groupNames}" + few: "مرئي فقط لأعضاء المجموعات: %{groupNames}" + many: "مرئي فقط لأعضاء المجموعات: %{groupNames}" other: "مرئي فقط لأعضاء المجموعات: %{groupNames}" invite_users: "دعوة" print: @@ -3222,6 +3445,7 @@ ar: other: "لقد حدَّدت %{count} منشور." deleted_by_author_simple: "(تم حذف الموضوع بواسطة الكاتب)" post: + confirm_delete: "هل تريد بالتأكيد حذف هذا المنشور؟" quote_reply: "اقتباس" quote_reply_shortcut: "أو اضغط على q" quote_edit: "تعديل" @@ -3240,7 +3464,18 @@ ar: show_hidden: "عرض المحتوى الذي تم تجاهله" deleted_by_author_simple: "(تم حذف المنشور بواسطة الكاتب)" collapse: "طي" + sr_collapse_replies: "طي الردود المضمَّنة" + sr_date: "تاريخ المنشور" + sr_expand_replies: + zero: "هناك %{count} رد على هذا المنشور. انقر للتوسيع" + one: "هناك رد واحد (%{count}) على هذا المنشور. انقر للتوسيع" + two: "هناك ردَّان (%{count}) على هذا المنشور. انقر للتوسيع" + few: "هناك %{count} ردود على هذا المنشور. انقر للتوسيع" + many: "هناك %{count} ردًا على هذا المنشور. انقر للتوسيع" + other: "هناك %{count} رد على هذا المنشور. انقر للتوسيع" expand_collapse: "توسيع/طي" + sr_below_embedded_posts_description: "ردود المنشور #%{post_number}" + sr_embedded_reply_description: "رد بواسطة @%{username} على المنشور #%{post_number}" locked: "قفل أحد أعضاء فريق العمل تعديل هذه المشاركة" gap: zero: "عرض %{count} رد مخفي" @@ -3249,6 +3484,7 @@ ar: few: "عرض %{count} ردود مخفية" many: "عرض %{count} ردًا مخفيًا" other: "عرض %{count} رد مخفي" + sr_reply_to: "رد على المنشور #%{post_number} بواسطة @%{username}" notice: new_user: "هذه أول مرة ينشر فيها %{user} شيئًا؛ فلنرحِّب به في مجتمعنا!" returning_user: "مرَّ وقت طويل منذ أن قرأنا شيئًا من %{user}؛ إذ كان آخر منشور له في %{time}." @@ -3277,6 +3513,20 @@ ar: few: "لقد سجَّلت إعجابك أنت و%{count} أشخاص آخرين بهذا المنشور" many: "لقد سجَّلت إعجابك أنت و%{count} شخصًا آخر بهذا المنشور" other: "لقد سجَّلت إعجابك أنت و%{count} شخص آخر بهذا المنشور" + sr_post_like_count_button: + zero: "%{count} شخص أعجبه هذا المنشور. انقر للعرض" + one: "شخص واحد (%{count}) أعجبه هذا المنشور. انقر للعرض" + two: "شخصان (%{count}) أعجبهما هذا المنشور. انقر للعرض" + few: "%{count} أشخاص أعجبهم هذا المنشور. انقر للعرض" + many: "%{count} شخصًا أعجبه هذا المنشور. انقر للعرض" + other: "%{count} شخص أعجبه هذا المنشور. انقر للعرض" + sr_post_read_count_button: + zero: "%{count} شخص قرأ هذا المنشور. انقر للعرض" + one: "شخص واحد (%{count}) قرأ هذا المنشور. انقر للعرض" + two: "شخصان (%{count}) قرآ هذا المنشور. انقر للعرض" + few: "%{count} أشخاص قرؤوا هذا المنشور. انقر للعرض" + many: "%{count} شخصًا قرأ هذا المنشور. انقر للعرض" + other: "%{count} شخص قرأ هذا المنشور. انقر للعرض" filtered_replies_hint: zero: "عرض هذا المنشور وردوده %{count}" one: "عرض هذا المنشور والرد عليه" @@ -3298,7 +3548,8 @@ ar: edit: "عذرا، حدث خطأ في أثناء تعديل منشورك. يُرجى إعادة المحاولة." upload: "عذرًا، حدث خطأ في أثناء تحميل هذا الملف. يُرجى إعادة المحاولة." file_too_large: "عذرًا، لكن الملف كبير جدًا (أقصى حجم هو %{max_size_kb} ك.ب). لماذا لا تحمِّل ملفك الكبير إلى خدمة مشاركة سحابية، ثم تلصق الرابط؟" - file_too_large_humanized: "عذرًا، هذا الملف كبير جدًا (الحد الأقصى للحجم هو %{max_size}). لماذا لا تقوم بتحميل ملفك الكبير إلى خدمة مشاركة سحابية، ثم قم بلصق الرابط؟" + file_size_zero: "عذرًا، يبدو أنه حدث خطأ ما؛ فحجم الملف الذي تحاول تحميله هو 0 بايت. حاول مرة أخرى." + file_too_large_humanized: "عذرًا، هذا الملف كبير جدًا (الحد الأقصى للحجم هو %{max_size}). لماذا لا تقوم بتحميل ملفك الكبير إلى خدمة مشاركة سحابية، ثم تلصق الرابط؟" too_many_uploads: "عذرًا، يمكنك تحميل ملف واحد فقط في الوقت نفسه." too_many_dragged_and_dropped_files: zero: "عذرًا، يمكنك تحميل %{count} ملف فقط في الوقت نفسه." @@ -3355,7 +3606,7 @@ ar: just_the_post: "لا، هذا المنشور فقط" admin: "إجراءات المسؤول على المنشور" permanently_delete: "الحذف بشكلٍ دائم" - permanently_delete_confirmation: "هل أنت متأكد من أنك تريد حذف هذا المنشور بشكل دائم؟ لن تتمكن من استعادته." + permanently_delete_confirmation: "هل تريد بالتأكيد حذف هذا المنشور بشكلٍ دائم؟ لن تتمكن من استعادته." wiki: "التحويل إلى Wiki" unwiki: "إزالة Wiki" convert_to_moderator: "لون إضافة فريق العمل" @@ -3417,6 +3668,8 @@ ar: few: "و%{count} آخرين قرؤوا ذلك" many: "و%{count} آخرين قرؤوا ذلك" other: "و%{count} آخرين قرؤوا ذلك" + sr_post_likers_list_description: "المستخدمون الذين أعجبهم هذا المنشور" + sr_post_readers_list_description: "المستخدمون الذين قرؤوا هذا المنشور" by_you: off_topic: "لقد أبلغت عن هذا المنشور على أنه خارج الموضوع" spam: "لقد أبلغت عن هذا المنشور على أنه غير مرغوب فيه" @@ -3431,6 +3684,14 @@ ar: few: "هل تريد بالتأكيد حذف %{count} منشورات؟" many: "هل تريد بالتأكيد حذف %{count} منشورًا؟" other: "هل تريد بالتأكيد حذف %{count} منشور؟" + merge: + confirm: + zero: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" + one: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" + two: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" + few: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" + many: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" + other: "هل تريد بالتأكيد دمج تلك المنشورات البالغ عددها %{count}؟" revisions: controls: first: "أول مراجعة" @@ -3468,7 +3729,7 @@ ar: create: "إنشاء إشارة مرجعية" create_for_topic: "إنشاء إشارة مرجعية للموضوع" edit: "تعديل الإشارة المرجعية" - edit_for_topic: "تحرير الإشارة المرجعية للموضوع" + edit_for_topic: "تعديل الإشارة المرجعية للموضوع" created: "تاريخ الإنشاء" updated: "تاريخ التحديث" name: "الاسم" @@ -3483,6 +3744,7 @@ ar: name: "تعديل الإشارة المرجعية" description: "تعديل اسم الإشارة المرجعية أو تغيير تاريخ التذكير ووقته" clear_bookmark_reminder: + name: "مسح التذكير" description: "امسح تاريخ ووقت التذكير" pin_bookmark: name: "تثبيت الإشارة المرجعية" @@ -3504,6 +3766,7 @@ ar: all: "كل الفئات" choose: "الفئة…" edit: "تعديل" + edit_title: "تعديل هذه الفئة" edit_dialog_title: "تعديل: %{categoryName}" view: "عرض الموضوعات في الفئة" back: "الرجوع إلى الفئة" @@ -3519,10 +3782,10 @@ ar: manage_tag_groups_link: "إدارة مجموعات الوسوم" allow_global_tags_label: "السماح بالوسوم الأخرى أيضًا" required_tag_group: - description: "تتطلب موضوعات جديدة بها وسوم من مجموعات العلامات:" + description: "يلزم أن تحتوي الموضوعات الجديدة على وسوم من مجموعات الوسوم:" delete: "حذف" - add: "أضف مجموعة العلامات المطلوبة" - placeholder: "حدد مجموعة العلامات ..." + add: "إضافة مجموعة الوسوم المطلوبة" + placeholder: "تحديد مجموعة الوسوم..." topic_featured_link_allowed: "السماح بالروابط المميزة في هذه الفئة." delete: "حذف الفئة" create: "فئة جديدة" @@ -3535,6 +3798,7 @@ ar: name: "اسم الفئة" description: "الوصف" logo: "صورة شعار الفئة" + logo_dark: "صورة شعار فئة الوضع الداكن" background_image: "صورة خلفية الفئة" badge_colors: "ألوان الشارات" background_color: "لون الخلفية" @@ -3582,9 +3846,9 @@ ar: default_list_filter: "تصفية القائمة الافتراضية:" allow_badges_label: "السماح بمنح الشارات في هذه الفئة" edit_permissions: "تعديل الأذونات" - reviewable_by_group: "بالإضافة إلى فريق العمل، يمكن أيضًا مراجعة المحتوى في هذا التصنيف بواسطة:" + reviewable_by_group: "بالإضافة إلى فريق العمل، يمكن أيضًا مراجعة المحتوى في هذه الفئة من قِبل:" review_group_name: "اسم المجموعة" - require_topic_approval: "طلب موافقة المشرف على جميع الموضوعات الجديدة" + require_topic_approval: "طلب موافقة المشرف على كل الموضوعات الجديدة" require_reply_approval: "طلب موافقة المشرف على جميع الردود الجديدة" this_year: "هذا العام" position: "الترتيب في صفحة الفئات:" @@ -3597,16 +3861,16 @@ ar: num_auto_bump_daily: "عدد الموضوعات المفتوحة التي سيتم رفعها تلقائيًا بشكل يومي:" navigate_to_first_post_after_read: "الانتقال إلى أول منشور بعد قراءة الموضوعات" notifications: - title: "تغيير مستوى الإشعار لهذا التصنيف" + title: "تغيير مستوى الإشعار لهذه الفئة" watching: title: "المراقبة" - description: "ستراقب تلقائيًا جميع الموضوعات في هذه الفئة. وسيتم إرسال إشعار إليك بكل منشور جديد في كل موضوع، وسيتم عرض عدد الردود الجديدة." + description: "ستراقب تلقائيًا كل الموضوعات في هذه الفئة. وسيتم إرسال إشعار إليك بكل منشور جديد في كل موضوع، وسيتم عرض عدد الردود الجديدة." watching_first_post: title: "مراقبة أول منشور" description: "سنُرسل إليك إشعارًا بالموضوعات الجديدة في هذه الفئة، ولكن ليس الردود على الموضوعات." tracking: title: "التتبُّع" - description: "ستتتبَّع تلقائيًا جميع الموضوعات في هذه الفئة. وسيتم إرسال إشعار إليك إذا أشار إليك شخص ما @name أو ردَّ عليك، وسيتم عرض عدد الردود الجديدة." + description: "ستتتبَّع تلقائيًا كل الموضوعات في هذه الفئة. وسيتم إرسال إشعار إليك إذا أشار إليك شخص ما @name أو ردَّ عليك، وسيتم عرض عدد الردود الجديدة." regular: title: "عادية" description: "سنُرسل إليك إشعارًا إذا أشار أحد إلى اسمك باستخدام الرمز @ أو ردَّ عليك." @@ -3624,8 +3888,8 @@ ar: very_high: "مرتفعة جدًا" sort_options: default: "افتراضية" - likes: "الإعجابات" - op_likes: "الإعجابات على المنشور الأصلي" + likes: "تسجيلات الإعجاب" + op_likes: "تسجيلات الإعجاب على المنشور الأصلي" views: "مرات العرض" posts: "المنشورات" activity: "النشاط" @@ -3645,7 +3909,7 @@ ar: appearance: "الظهور" email: "البريد الإلكتروني" list_filters: - all: "جميع الموضوعات" + all: "كل الموضوعات" none: "لا توجد فئات فرعية" colors_disabled: "لا يمكنك تحديد الألوان لأنه ليس لديك نمط فئة." flagging: @@ -3764,6 +4028,7 @@ ar: other {}} original_post: "المنشور الأصلي" views: "مرات العرض" + sr_views: "الترتيب حسب المشاهدات" views_lowercase: zero: "مرة عرض" one: "مرة عرض واحدة" @@ -3772,6 +4037,7 @@ ar: many: "مرة عرض" other: "مرة عرض" replies: "الردود" + sr_replies: "الترتيب حسب الردود" views_long: zero: "لقد تم عرض هذا الموضوع %{number} مرة" one: "لقد تم عرض هذا الموضوع مرة واحدة (%{count})" @@ -3780,7 +4046,10 @@ ar: many: "لقد تم عرض هذا الموضوع %{number} مرة" other: "لقد تم عرض هذا الموضوع %{number} مرة" activity: "النشاط" - likes: "الإعجابات" + sr_activity: "الترتيب حسب النشاط" + likes: "تسجيلات الإعجاب" + sr_likes: "الترتيب حسب تسجيلات الإعجاب" + sr_op_likes: "الترتيب حسب تسجيلات الإعجاب على المنشور" likes_lowercase: zero: "مرة إعجاب" one: "مرة إعجاب واحدة" @@ -3798,7 +4067,7 @@ ar: other: "مستخدم" category_title: "الفئة" history_capped_revisions: "السجل، آخر 100 مراجعة" - history: "التاريخ" + history: "السجل" changed_by: "بواسطة %{author}" raw_email: title: "البريد الوارد" @@ -3823,7 +4092,7 @@ ar: categories: title: "الفئات" title_in: "الفئة - %{categoryName}" - help: "جميع الموضوعات مجمَّعة حسب الفئة" + help: "كل الموضوعات مجمَّعة حسب الفئة" unread: title: "غير المقروءة" title_with_count: @@ -3842,9 +4111,9 @@ ar: many: "%{count} غير مقروءة" other: "%{count} غير مقروءة" unseen: - title: "غير مرئي" - lower_title: "غير مرئي" - help: "الموضوعات والموضوعات الجديدة التي تشاهدها حاليًا أو تتبعها بمشاركات غير مقروءة" + title: "غير مقروءة" + lower_title: "غير مقروءة" + help: "الموضوعات الجديدة والموضوعات التي تراقبها حاليًا أو تتتبَّعها وبها منشورات غير مقروءة" new: lower_title_with_count: zero: "%{count} جديد" @@ -3901,6 +4170,8 @@ ar: this_week: "الأسبوع" today: "اليوم" other_periods: "رؤية الأكثر نشاطًا:" + browser_update: 'عذرًا، متصفحك غير مدعوم. يُرجى التبديل إلى متصفح مدعوم لعرض المحتوى الغني، وتسجيل الدخول والرد.' + safari_13_warning: سيزيل هذا الموقع الدعم لإصدارات 13 من iOS وSafari، والإصدارات الأقدم. ستظل هناك نسخة مبسَّطة متاحة للقراءة فقط. (المزيد من المعلومات) permission_types: full: "الإنشاء/الرد/العرض" create_post: "الرد/العرض" @@ -3908,6 +4179,7 @@ ar: preloader_text: "جارٍ التحميل" lightbox: download: "تنزيل" + open: "الصورة الأصلية" previous: "السابق (مفتاح السهم الأيسر)" next: "التالي (مفتاح السهم الأيمن)" counter: "%curr% من %total%" @@ -3922,6 +4194,7 @@ ar: shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}" shortcut_delimiter_space: "%{shortcut1} %{shortcut2}" title: "اختصارات لوحة المفاتيح" + short_title: "الاختصارات" jump_to: title: "الانتقال إلى" home: "%{shortcut} الصفحة الرئيسية" @@ -3989,6 +4262,7 @@ ar: edit: "%{shortcut} تعديل المنشور" delete: "%{shortcut} حذف المنشور" mark_muted: "%{shortcut} كتم الموضوع" + mark_regular: "%{shortcut} الموضوع العادي (الافتراضي)" mark_tracking: "%{shortcut} تتبُّع الموضوع" mark_watching: "%{shortcut} مراقبة الموضوع" print: "%{shortcut} طباعة الموضوع" @@ -3998,7 +4272,7 @@ ar: title: "قائمة البحث" prev_next: "%{shortcut} نقل التحديد لأعلى ولأسفل" insert_url: "%{shortcut} إدخال التحديد في أداة الإنشاء المفتوحة" - full_page_search: "%{shortcut} يطلق بحث كامل الصفحة" + full_page_search: "%{shortcut} يطلق بحثًا في كامل الصفحة" badges: earned_n_times: zero: "تم منحك هذه الشارة %{count} مرة" @@ -4055,14 +4329,15 @@ ar: save_ics: "تنزيل ملف .ics" save_google: "إضافة إلى تقويم Google" remember: "لا تسألني مرة أخرى" - remember_explanation: "(يمكنك تغيير هذا التفضيل في تفضيلات المستخدم الخاصة بك)" + remember_explanation: "(يمكنك تغيير هذا التفضيل في تفضيلات المستخدم لديك)" download: "تنزيل" default_calendar: "التقويم الافتراضي" - default_calendar_instruction: "تحديد التقويم الذي يجب استخدامه عند حفظ التواريخ" + default_calendar_instruction: "حدِّد التقويم الذي ينبغي استخدامه عند حفظ التواريخ" add_to_calendar: "إضافة إلى التقويم" - google: "تقويم جوجل" + google: "تقويم Google" ics: "ICS" tagging: + all_tags: "كل الوسوم" other_tags: "وسوم أخرى" selector_all_tags: "كل الوسوم" selector_no_tags: "لا توجد وسوم" @@ -4070,19 +4345,26 @@ ar: tags: "الوسوم" choose_for_topic: "الوسوم الاختيارية" choose_for_topic_required: - zero: "حدد وسم %{count} على الأقل..." - one: "يجب تحديد %{count} وسمًا على الأقل..." - two: "يجب تحديد وسمَين %{count} على الأقل..." - few: "يجب تحديد %{count} وسوم على الأقل..." - many: "يجب تحديد %{count} وسمًا على الأقل..." - other: "يجب تحديد %{count} وسمًا على الأقل..." + zero: "حدِّد %{count} وسم على الأقل..." + one: "حدِّد وسمًا واحدًا (%{count}) على الأقل..." + two: "حدِّد وسمَين (%{count}) على الأقل..." + few: "حدِّد %{count} وسوم على الأقل..." + many: "حدِّد %{count} وسمًا على الأقل..." + other: "حدِّد %{count} وسم على الأقل..." + choose_for_topic_required_group: + zero: "حدِّد %{count} وسم من '%{name}'..." + one: "حدِّد وسمًا واحدًا (%{count}) من '%{name}'..." + two: "حدِّد وسمين (%{count}) من '%{name}'..." + few: "حدِّد %{count} وسوم من '%{name}'..." + many: "حدِّد %{count} وسمًا من '%{name}'..." + other: "حدِّد %{count} وسم من '%{name}'..." info: "المعلومات" - default_info: "هذ الوسم ليس مقصورًا على أي تصنيف، وليس له مرادفات." - staff_info: "لإضافة قيود، ضع هذه العلامة في مجموعة العلامة." + default_info: "هذ الوسم ليس مقصورًا على أي فئة، وليس له مرادفات." + staff_info: "لإضافة قيود، ضَع هذا الوسم في مجموعة الوسوم." category_restricted: "هذا الوسم مقيَّد بالفئات التي ليس لديك إذن بالوصول إليها." synonyms: "المرادفات" synonyms_description: "عند استخدام الوسوم التالية، سيتم استبدالها بالوسم %{base_tag_name}." - save: "حفظ الاسم ووصف الوسم" + save: "حفظ اسم الوسم ووصفه" tag_groups_info: zero: "هذا الوسم ينتمي إلى المجموعة: %{tag_groups}." one: 'هذا الوسم ينتمي إلى المجموعة: %{tag_groups}.' @@ -4097,7 +4379,7 @@ ar: few: "لا يمكن استخدامه إلا في هذه الفئات:" many: "لا يمكن استخدامه إلا في هذه الفئات:" other: "لا يمكن استخدامه إلا في هذه الفئات:" - edit_synonyms: "تحرير المرادفات" + edit_synonyms: "تعديل المرادفات" add_synonyms_label: "إضافة المرادفات:" add_synonyms: "إضافة" add_synonyms_explanation: @@ -4126,7 +4408,7 @@ ar: few: "سيتم حذف مرادفاته (%{count}) أيضًا." many: "سيتم حذف مرادفاته (%{count}) أيضًا." other: "سيتم حذف مرادفاته (%{count}) أيضًا." - edit_tag: "تحرير اسم الوسم والوصف" + edit_tag: "تعديل اسم الوسم ووصفه" description: "الوصف" sort_by: "الترتيب حسب:" sort_by_count: "العدد" @@ -4163,13 +4445,13 @@ ar: notifications: watching: title: "المراقبة" - description: "ستراقب تلقائيًا جميع الموضوعات التي تحمل هذا الوسم. وسنُرسل إليك إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + description: "ستراقب تلقائيًا كل الموضوعات التي تحمل هذا الوسم. وسنُرسل إليك إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." watching_first_post: title: "مراقبة أول منشور" description: "سنُرسل إليك إشعارًا بالموضوعات الجديدة التي تحمل هذا الوسم، ولكن ليس الردود على الموضوعات." tracking: title: "التتبُّع" - description: "ستتتبَّع تلقائيًا جميع الموضوعات التي تحمل هذا الوسم. وسيظهر أيضًا عدد المنشورات غير المقروءة والجديدة بجانب الموضوع." + description: "ستتتبَّع تلقائيًا كل الموضوعات التي تحمل هذا الوسم. وسيظهر أيضًا عدد المنشورات غير المقروءة والجديدة بجانب الموضوع." regular: title: "عادية" description: "سنُرسل إليك إشعارًا إذا أشار أحد إلى اسمك باستخدام الرمز @ أو ردَّ على منشورك." @@ -4177,6 +4459,7 @@ ar: title: "الكتم" description: "لن تتلقى أي إشعارات أبدًا بخصوص الموضوعات الجديدة التي تحمل هذا الوسم، ولن تظهر في علامة تبويب الموضوعات غير المقروءة." groups: + back_btn: "العودة إلى كل الوسوم" title: "مجموعات الوسوم" about_heading: "حدِّد مجموعة وسوم أو أنشئ مجموعة جديدة" about_heading_empty: "أنشئ مجموعة وسوم جديدة للبدء" @@ -4204,7 +4487,7 @@ ar: topics: none: unread: "ليس لديك أي موضوع غير مقروء." - unseen: "ليس لديك مواضيع غير مقروءة." + unseen: "ليس لديك موضوعات غير مقروءة." new: "ليس لديك أي موضوع جديد." read: "لم تقرأ أي موضوع بعد." posted: "لم تنشر في أي موضوع بعد." @@ -4249,23 +4532,29 @@ ar: pick_files_button: unsupported_file_picked: "لقد اخترت ملفًا غير مدعوم. أنواع الملفات المدعومة - %{types}." user_activity: - no_activity_title: "لا يوجد نشاط." - no_replies_title: "لم ترد على أي مواضيع حتى الآن" + no_activity_title: "لا يوجد نشاط حتى الآن" + no_activity_body: "مرحبًا بك في مجتمعنا! أنت عضو جديد هنا ولم تساهم في المناقشات. كخطوة أولى، انتقل إلى الأعلى أو الفئات وابدأ القراءة! ضع %{heartIcon} على المنشورات التي تعجبك أو تريد معرفة المزيد عنها. وبينما تشارك، سيتم تسجيل نشاطك هنا." + no_replies_title: "لم ترد على أي موضوعات حتى الآن" + no_replies_title_others: "لم يرد %{username} على أي موضوعات بعد" + no_replies_body: "عندما تكتشف محادثة مثيرة للاهتمام ترغب في المساهمة فيها، اضغط على زر الرد تحت أي منشورة مباشرةً للبدء في الرد على هذا المنشور المعيَّن. أو، إذا كنت تفضل الرد على الموضوع العام بدلًا من الرد على منشور فردي أو شخص، فابحث عن زر الرد في أسفل الموضوع، أو تحت الجدول الزمني للموضوع." no_drafts_title: "لم تبدأ أي مسودات" - no_drafts_body: "لست مستعدًا للنشر؟ سنقوم تلقائيًا بحفظ مسودة جديدة وإدراجها هنا كلما بدأت في إنشاء موضوع أو رد أو رسالة شخصية. حدد زر الإلغاء لتجاهل المُسَوَّدَة أو حفظها للمتابعة لاحقًا." - no_likes_title: "لم بالإعجاب بأي مواضيع حتى الآن" - no_likes_body: "هناك طريقة رائعة للتقدّم والبدء في المساهمة هي البَدْء في قراءة المحادثات التي جرت بالفعل، واختيار %{heartIcon} على المشاركات التي تريدها." - no_topics_title: "لم تنشئ أي مواضيع حتى الآن" - no_read_topics_title: "لم تقرأ أي مواضيع حتى الآن" - no_read_topics_body: "بمجرد البَدْء في قراءة المناقشات، سترى قائمة هنا. لبدء القراءة، ابحث عن الموضوعات التي تهمك في أعلى أو فئات أو البحث عن طريق الكلمة الرئيسية %{searchIcon}" - no_group_messages_title: "لم يتم العثور على أي رسالة من المجموعة" + no_drafts_body: "لست مستعدًا للنشر؟ سنقوم بحفظ مسودة جديدة تلقائيًا وإدراجها هنا كلما بدأت في إنشاء موضوع أو رد أو رسالة شخصية. اضغط زر الإلغاء لتجاهل المسودة أو حفظها للمتابعة لاحقًا." + no_likes_title: "لم تسجِّل إعجابك بأي موضوعات حتى الآن" + no_likes_title_others: "لم يسجِّل %{username} إعجابه بأي موضوعات بعد" + no_likes_body: "إن البدء في قراءة المحادثات التي جرت بالفعل، وضغط %{heartIcon} على المشاركات التي تريدها، طريقة رائعة للانضمام والبدء في المساهمة." + no_topics_title: "لم تنشئ أي موضوعات حتى الآن" + no_topics_body: "من الأفضل دائمًا البحث في موضوعات المحادثة الحالية قبل بدء موضوع جديد، ولكن إذا كنت واثقًا من أن الموضوع الذي تريده ليس موجودًا بالفعل، فابدأ موضوعًا جديدًا. ابحث عن الزر + موضوع جديد في الجزء العلوي الأيمن من قائمة الموضوعات أو الفئة أو الوسم للبدء في إنشاء موضوع جديد في تلك المنطقة." + no_topics_title_others: "لم يبدأ %{username} أي موضوعات بعد" + no_read_topics_title: "لم تقرأ أي موضوعات حتى الآن" + no_read_topics_body: "بعد البدء في قراءة المناقشات، سترى قائمة هنا. لبدء القراءة، ابحث عن الموضوعات التي تهمك في الأعلى أو الفئات، أو عن طريق البحث بالكلمة الرئيسية %{searchIcon}" + no_group_messages_title: "لم يتم العثور على أي رسائل من المجموعة" topic_entrance: - sr_jump_top_button: "انتقل إلى المشاركة الأولى" - sr_jump_bottom_button: "انتقل إلى آخر مشاركة" + sr_jump_top_button: "الانتقال إلى المنشور الأول" + sr_jump_bottom_button: "الانتقال إلى المنشور الأخير" fullscreen_table: expand_btn: "توسيع الجدول" second_factor_auth: - redirect_after_success: "مصادقة العامل الثاني ناجحة. إعادة التوجيه إلى الصفحة السابقة…" + redirect_after_success: "نجحت المصادقة الثنائية. جارٍ إعادة التوجيه إلى الصفحة السابقة…" sidebar: unread_count: zero: "%{count} غير مقروء" @@ -4278,16 +4567,19 @@ ar: zero: "%{count} جديد" one: "%{count} جديد" two: "%{count} جديدان" - few: "%{count} جديد" + few: "%{count} جديدة" many: "%{count} جديدًا" other: "%{count} جديدة" + toggle_section: "تبديل القسم" more: "المزيد" all_categories: "كل الفئات" + all_tags: "كل الوسوم" sections: about: header_link_text: "نبذة" messages: header_link_text: "الرسائل" + header_action_title: "إنشاء رسالة شخصية" links: inbox: "صندوق الوارد" sent: "المُرسَلة" @@ -4297,9 +4589,17 @@ ar: unread_with_count: "غير المقروءة (%{count})" archive: "الأرشيف" tags: + none: "لم تضف أي وسوم." + click_to_get_started: "انقر هنا للبدء." header_link_text: "الوسوم" + header_action_title: "تعديل وسوم الشريط الجانبي" + configure_defaults: "ضبط الإعدادات الافتراضية" categories: + none: "لم تضف أي فئات." + click_to_get_started: "انقر هنا للبدء." header_link_text: "الفئات" + header_action_title: "تعديل فئات الشريط الجانبي" + configure_defaults: "ضبط الإعدادات الافتراضية" community: header_link_text: "المجتمع" header_action_title: "إنشاء موضوع جديد" @@ -4311,23 +4611,38 @@ ar: badges: content: "الشارات" everything: - content: "كل شىء" - title: "جميع الموضوعات" + content: "كل شيء" + title: "كل الموضوعات" faq: content: "الأسئلة الشائعة" tracked: content: "المتتبَّعة" - title: "جميع المواضيع المُتَعَقبة" + title: "كل الموضوعات المتتبَّعة" groups: content: "المجموعات" title: "كل المجموعات" users: content: "المستخدمون" + title: "جميع المستخدمين" my_posts: content: "منشوراتي" + title: "منشوراتي" + draft_count: + zero: "%{count} مسودة" + one: "مسودة واحدة (%{count})" + two: "مسودتان (%{count})" + few: "%{count} مسودات" + many: "%{count} مسودة" + other: "%{count} مسودة" review: content: "المراجعة" - title: "المراجعة" + title: "مراجعة" + pending_count: "بقي %{count}" + welcome_topic_banner: + title: "إنشاء موضوعك الترحيبي" + description: "إن موضوعك الترحيبي هو أول ما يقرأه الأعضاء الجُدد. انظر إليه على أنه \"عرض ترويجي\" أو \"بيان مهمة\". دَع الجميع يعرفون الجمهور المستهدف بهذا المجتمع، وما يتوقعون العثور عليه هنا، وما تريد منهم أن يفعلوه أولًا." + button_title: "بدء التعديل" + until: "حتى:" admin_js: type_to_filter: "اكتب للتصفية..." admin: @@ -4465,7 +4780,7 @@ ar: description: "يمكن للمسؤولين رؤية جميع المجموعات." members_visibility_levels: title: "من يمكنه رؤية أعضاء هذه المجموعة؟" - description: "يمكن للمشرفين رؤية أعضاء كل المجموعات. Flair مرئي لجميع المستخدمين." + description: "يمكن للمسؤولين رؤية أعضاء كل المجموعات. الطابع مرئي لجميع المستخدمين." publish_read_state: "نشر حالة قراءة المجموعة على رسائل المجموعة" membership: automatic: تلقائية @@ -4480,7 +4795,7 @@ ar: few: "يوجد %{count} مستخدمين لديهم نطاقات البريد الإلكتروني الجديدة وستتم إضافتهم إلى المجموعة." many: "يوجد %{count} مستخدمًا لديهم نطاقات البريد الإلكتروني الجديدة وستتم إضافته إلى المجموعة." other: "يوجد %{count} مستخدم لديه نطاقات البريد الإلكتروني الجديدة وستتم إضافته إلى المجموعة." - automatic_membership_associated_groups: "ستتم إضافة المستخدمين الأعضاء في مجموعة في إحدى الخدمات المدرجة هنا تلقائيًا إلى هذه المجموعة عند تسجيل الدخول باستخدام الخدمة." + automatic_membership_associated_groups: "ستتم إضافة المستخدمين الأعضاء في مجموعة لإحدى الخدمات المُدرَجة هنا تلقائيًا إلى هذه المجموعة عند تسجيل الدخول باستخدام الخدمة." primary_group: "الضبط تلقائيًا كمجموعة أساسية" name_placeholder: "اسم المجموعة بلا مسافات، على غرار قاعدة اسم المستخدم" primary: "المجموعة الأساسية" @@ -4532,7 +4847,7 @@ ar: no_description: (لا يوجد وصف) all_api_keys: جميع مفاتيح API user_mode: مستوى المستخدم - scope_mode: مجال + scope_mode: المجال impersonate_all_users: انتحال شخصية أي مستخدم single_user: "مستخدم فردي" user_placeholder: أدخِل اسم المستخدم @@ -4548,10 +4863,10 @@ ar: عند استخدام النطاقات، يمكنك تقييد مفتاح API على مجموعة محدَّدة من نقاط النهاية. يمكنك أيضًا تحديد المعلمات التي سيتم السماح بها. استخدم الفاصلات للفصل بين القيم المتعددة. title: النطاقات - granular: Granular - read_only: قراءة فقط - global: عام - global_description: مفتاح API ليس له قيود ويمكن الوصول إلى جميع endpoints. + granular: متعددة المستويات + read_only: للقراءة فقط + global: عامة + global_description: ليس هناك قيود مفروضة على مفتاح API ويمكن الوصول إلى كل نقاط النهاية. resource: المورد action: الإجراء allowed_parameters: المعلمات المسموح بها @@ -4560,19 +4875,19 @@ ar: allowed_urls: عناوين URL المسموح بها descriptions: global: - read: تقييد مفتاح API على endpoints للقراءة فقط. + read: تقييد مفتاح API على نقاط النهاية المخصَّصة للقراءة فقط. topics: read: قراءة موضوع أو منشور محدَّد فيه. يتم دعم RSS أيضًا. write: إنشاء موضوع جديد أو النشر في موضوع موجود - update: تحديث الموضوع. غَيّر العنوان والتصنيف والعلامات وما إلى ذلك. + update: تحديث الموضوع. غيِّر العنوان والفئة والوسوم، إلى آخره. read_lists: قراءة قوائم الموضوعات مثل الأكثر نشاطًا، والجديدة، والحديثة، وما إلى ذلك. يتم دعم RSS أيضًا. posts: edit: تعديل أي منشور أو منشور معيَّن. categories: - list: احصل على قائمة التصنيفات. - show: الحصول على تصنيف واحد عن طريق الهوية. + list: احصل على قائمة بالفئات. + show: احصل على فئة واحدة بالمعرِّف. uploads: - create: ارفع ملف جديد أو ابدأ الرفع مباشرة لملفات فردية أو متعددة الأجزاء إلى وحدة تخزين خارجية. + create: حمِّل ملفًا جديدًا أو ابدأ التحميل المباشر الفردي أو متعدد الأجزاء إلى وحدة تخزين خارجية. users: bookmarks: إدراج الإشارات المرجعية للمستخدم. تعرض تذكيرات بالإشارات المرجعية عند استخدام تنسيق ICS. sync_sso: مزامنة مستخدم باستخدام DiscourseConnect @@ -4586,13 +4901,18 @@ ar: email: receive_emails: ادمج هذا النطاق مع مستقبل البريد لمعالجة الرسائل الإلكترونية الواردة. badges: - create: إنشاء شارة جديدة. - show: الحصول على معلومات حول الشارة. - update: تحديث الشارة. - delete: حذف الشارة. - list_user_badges: قائمة شارات المستخدم. + create: أنشئ شارة جديدة. + show: الحصول على معلومات بشأن إحدى الشارات. + update: تحديث شارة. + delete: حذف شارة. + list_user_badges: إعداد قائمة بشارات المستخدم. assign_badge_to_user: تعيين شارة للمستخدم. revoke_badge_from_user: إلغاء شارة من المستخدم. + wordpress: + publishing: ضروري لميزات النشر في المكوِّن الإضافي WP Discourse (مطلوب). + commenting: ضروري لميزات التعليق في المكوِّن الإضافي WP Discourse. + discourse_connect: ضروري لميزات DiscourseConnect في المكوِّن الإضافي WP Discourse. + utilities: ضروري إذا كنت تستخدم خدمات المكوِّن الإضافي WP Discourse plugin. web_hooks: title: "خطافات الويب" none: "لا توجد خطافات ويب حاليًا." @@ -4607,7 +4927,6 @@ ar: go_back: "العودة إلي القائمة" payload_url: "عنوان URL للحمولة" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "يبدو أنك تحاول إعداد خطاف ويب على عنوان URL محلي. قد يتسبَّب تسليم الحدث إلى عنوان محلي في حدوث آثار جانبية أو سلوكيات غير متوقعة. هل تريد المتابعة؟" secret_invalid: "يجب ألا يحتوي الرمز السري على حروف فارغة." secret_too_short: "يجب ألا يقل الرمز السري عن 12 حرفًا." secret_placeholder: "سلسلة اختيارية، تُستخدَم في إنشاء توقيع إلكتروني" @@ -4651,6 +4970,9 @@ ar: notification_event: name: "حدث الإشعار" details: "عند تلقي المستخدم إشعار في موجزه." + user_promoted_event: + name: "حدث يروِّج له المستخدم" + details: "عندما تتم ترقية المستخدم من مستوى ثقة إلى آخر." user_badge_event: name: "حدث منح الشارة" details: "عند حصول المستخدم على شارة." @@ -4797,7 +5119,7 @@ ar: delete_confirm: 'هل تريد بالتأكيد حذف السمة "%{theme_name}"؟' color: "اللون" opacity: "الشفافية" - copy: "نسخة مكررة" + copy: "تكرار" copy_to_clipboard: "نسخ الى الحافظة" copied_to_clipboard: "تم النسخ إلى الحافظة" copy_to_clipboard_error: "حدث خطأ في نسخ البيانات إلى الحافظة" @@ -4897,9 +5219,10 @@ ar: import_web_advanced: "متقدم..." import_file_tip: "ملف .tar.gz, .zip, or .dcstyle.json الذي يحتوي على السمة" is_private: "السمة موجودة في مستودع Git خاص" + finish_install: "إنهاء تثبيت السمة" + last_attempt: "لم تنتهِ عملية التثبيت، آخر محاولة:" remote_branch: "اسم الفرع (اختياري)" public_key: "امنح المفتاح العام التالي إذن الوصول إلى المستودع:" - public_key_note: "بعد إدخال عنوان URL صالح لمستودع خاص أعلاه، سيتم إنشاء مفتاح SSH وعرضه هنا." install: "تثبيت" installed: "مثبَّت" install_popular: "الرائجة" @@ -4907,6 +5230,8 @@ ar: install_git_repo: "من مستودع Git" install_create: "إنشاء جديد" duplicate_remote_theme: "مكوِّن السمة \"%{name}\" مثبَّت بالفعل، هل تريد بالتأكيد تثبيت نسخة أخرى؟" + force_install: "لا يمكن تثبيت السمة نظرًا لتعذُّر الوصول إلى مستودع Git. هل تريد بالتأكيد مواصلة تثبيته؟" + create_placeholder: "إنشاء عنصر نائب" about_theme: "نبذة" license: "الترخيص" version: "الإصدار:" @@ -4929,6 +5254,7 @@ ar: has_overwritten_history: "لم يعُد إصدار السمة الحالي موجودًا لأن سجل Git قد تم استبداله إجباريًا." add: "إضافة" theme_settings: "إعدادات السمة" + overriden_settings_explanation: "يتم تمييز الإعدادات المُستبدَلة بنقطة ولون مميز. لإعادة تعيين تلك الإعدادات إلى القيمة الافتراضية، اضغط على زر إعادة التعيين بجوارها." no_settings: "لا توجد إعدادات لهذه السمة." theme_translations: "ترجمات السمة" empty: "لا توجد عناصر" @@ -4972,7 +5298,7 @@ ar: يوصى بشدة بوضع بادئة لأسماء الخصائص لتجنُّب تعارضها مع المكوِّنات الإضافية أو الأساسية. head_tag: text: "الرأس" - title: "HTML الذي سيتم إدراجه قبل رأس الوسم ‎‎" + title: "HTML الذي سيتم إدراجه قبل وسم الرأس‎‎" body_tag: text: "النص الأساسي" title: "HTML الذي سيتم إدراجه قبل وسم النص الأساسي" @@ -5073,7 +5399,7 @@ ar: time: "الوقت" user: "المستخدم" email_type: "نوع البريد الكتروني" - details_title: "إظهار تفاصيل البريد الإلكتروني" + details_title: "عرض تفاصيل البريد الإلكتروني" to_address: "إلى العنوان" test_email_address: "عنوان البريد الإلكتروني للاختبار" send_test: "إرسال رسالة إلكترونية للاختبار" @@ -5091,6 +5417,7 @@ ar: last_seen_user: "آخر ظهور لمستخدم:" no_result: "لم يتم العثور على نتائج للملخص." reply_key: "مفتاح الرد" + post_link_with_smtp: "تفاصيل الإرسال وSMTP" skipped_reason: "سبب التخطي" incoming_emails: from_address: "من" @@ -5120,6 +5447,7 @@ ar: address_placeholder: "name@example.com" type_placeholder: "الملخص، الاشتراك..." reply_key_placeholder: "مفتاح الرد" + smtp_transaction_response_placeholder: "معرِّف SMTP" moderation_history: performed_by: "أجراه" no_results: "لا يتوفَّر سجل للإشراف." @@ -5306,6 +5634,7 @@ ar: few: "إظهار %{count} كلمات" many: "إظهار %{count} كلمة" other: "إظهار %{count} كلمة" + case_sensitive: "(حساس لحالة الأحرف)" download: تنزيل clear_all: مسح الكل clear_all_confirm: "هل تريد بالتأكيد مسح جميع الكلمات المُراقَبة للإجراء %{action}؟" @@ -5343,6 +5672,8 @@ ar: exists: "موجودة بالفعل" upload: "الإضافة من ملف" upload_successful: "تم التحميل بنجاح. وتمت إضافة الكلمات." + case_sensitivity_label: "حساس لحالة الأحرف" + case_sensitivity_description: "الكلمات ذات حالة الأحرف المطابقة فقط" test: button_label: "اختبار" modal_title: "%{action}: اختبار الكلمات المُراقَبة" @@ -5443,6 +5774,7 @@ ar: suspended: "معلَّق؟" staged: "مؤقت؟" show_admin_profile: "مسؤول" + manage_user: "إدارة المستخدم" show_public_profile: "إظهار الملف الشخصي العام" impersonate: "انتحال" action_logs: "سجلات الإجراءات" @@ -5463,7 +5795,7 @@ ar: reputation: الشهرة permissions: الأذونات activity: النشاط - like_count: الإعجابات الممنوحة/المتلقاة + like_count: تسجيلات الإعجاب الممنوحة/المتلقاة last_100_days: "في آخر 100 يوم" private_topics_count: موضوع خاص posts_read_count: منشور مقروء @@ -5488,20 +5820,20 @@ ar: anonymize_failed: "حدثت مشكلة في أثناء إخفاء هوية الحساب." delete: "حذف المستخدم" delete_posts: - button: "حذف جميع المنشورات" + button: "حذف كل المنشورات" progress: title: "تقدُّم حذف المنشورات" description: "جارٍ حذف المنشورات..." confirmation: - title: "حذف جميع المشاركات بواسطة @%{username}" + title: "حذف كل المنشورات من @%{username}" description: | -

    هل أنت متأكد من رغبتك في حذف %{post_count} المشاركات من قبل @%{username}؟ +

    هل تريد بالتأكيد حذف %{post_count} من منشورات @%{username}؟

    لا يمكن التراجع عن هذا الإجراء

    -

    للتأكيد والمتابعة أكتب: %{text}

    - text: "حذف جميع المشاركات بواسطة @%{username}" - delete: "حذف جميع المشاركات بواسطة @%{username}" +

    للمتابعة، اكتب: %{text}

    + text: "حذف المنشورات من @%{username}" + delete: "حذف المنشورات من @%{username}" cancel: "إلغاء" merge: button: "دمج" @@ -5510,7 +5842,7 @@ ar: description: |

    يُرجى اختيار مالك جديد لمحتوى @%{username}.

    -

    سيتم نقل جميع الموضوعات والمنشورات والرسائل والمحتويات الأخرى التي تم إنشاؤها بواسطة @%{username}.

    +

    سيتم نقل كل الموضوعات والمنشورات والرسائل والمحتويات الأخرى التي تم إنشاؤها بواسطة @%{username}.

    target_username_placeholder: "اسم المستخدم للمالك الجديد" transfer_and_delete: "نقل @%{username} وحذفه" cancel: "إلغاء" @@ -5552,6 +5884,8 @@ ar: few: "يتعذَّر حذف جميع المنشورات لأن المستخدم لديه أكثر من %{count} منشورات. (delete_all_posts_max)" many: "يتعذَّر حذف جميع المنشورات لأن المستخدم لديه أكثر من %{count} منشورًا. (delete_all_posts_max)" other: "يتعذَّر حذف جميع المنشورات لأن المستخدم لديه أكثر من %{count} منشور. (delete_all_posts_max)" + delete_confirm_title: "هل تريد بالتأكيد حذف هذا المستخدم؟ لا يمكن التراجع عن هذا الإجراء!" + delete_confirm: "يُفضَّل عامةً إخفاء هوية المستخدمين بدلًا من حذفهم؛ لتجنُّب إزالة المحتوى من المناقشات الحالية." delete_and_block: "حذف هذا البريد الإلكتروني وعنوان IP وحظرهما" delete_dont_block: "الحذف فقط" deleting_user: "جارٍ حذف المستخدم..." @@ -5587,7 +5921,6 @@ ar: trust_level_2_users: "المستخدمون من مستوى الثقة 2." trust_level_3_requirements: "متطلبات مستوى الثقة 3" trust_level_locked_tip: "مستوى الثقة مقفل، لن يرقِّي النظام المستخدم أو يخفض رتبته" - trust_level_unlocked_tip: "تم إلغاء قفل مستوى الثقة، قد يرقِّي النظام المستخدم أو يخفض رتبته" lock_trust_level: "قفل مستوى الثقة" unlock_trust_level: "إلغاء قفل مستوى الثقة" silenced_count: "مكتوم" @@ -5613,10 +5946,10 @@ ar: posts_read_all_time: "المنشورات المقروءة (طوال الوقت)" flagged_posts: "المنشورات المُبلَغ عنها" flagged_by_users: "المستخدمون المُبلِغون" - likes_given: "الإعجابات الممنوحة" - likes_received: "الإعجابات المتلقاة" - likes_received_days: "الإعجابات المتلقاة: الأيام المميزة" - likes_received_users: "الإعجابات المتلقاة: الأعضاء المميزون" + likes_given: "تسجيلات الإعجاب الممنوحة" + likes_received: "تسجيلات الإعجاب المتلقاة" + likes_received_days: "تسجيلات الإعجاب المتلقاة: الأيام المميزة" + likes_received_users: "تسجيلات الإعجاب المتلقاة: الأعضاء المميزون" suspended: "معلَّق (آخر 6 أشهر)" silenced: "مكتوم (آخر 6 أشهر)" qualifies: "مؤهل لمستوى الثقة 3." @@ -5651,23 +5984,18 @@ ar: delete_confirm: "هل تريد بالتأكيد حذف حقل المستخدم هذا؟" options: "الخيارات" required: - title: "مطلوب عند الاشتراك؟" enabled: "مطلوب" disabled: "غير مطلوب" editable: - title: "يمكن تعديله بعد التسجيل؟" enabled: "يمكن تعديله" disabled: "لا يمكن تعديله" show_on_profile: - title: "العرض في الملف الشخصي العام؟" enabled: "معروض في الملف الشخصي" disabled: "غير معروض في الملف الشخصي" show_on_user_card: - title: "العرض على بطاقة المستخدم؟" enabled: "العرض على بطاقة المستخدم" disabled: "غير معروض على بطاقة المستخدم" searchable: - title: "قابل للبحث؟" enabled: "قابل للبحث" disabled: "غير قابل للبحث" field_types: @@ -5741,6 +6069,7 @@ ar: search: "البحث" groups: "المجموعات" dashboard: "لوحة المعلومات" + sidebar: "الشريط الجانبي" secret_list: invalid_input: "لا يمكن ترك حقول الإدخال فارغة أو أن تتضمَّن شريطًا عموديًا." default_categories: @@ -5847,9 +6176,9 @@ ar: grant_existing_holders: منح شارات إضافة لحاملي الشارات الحاليين emoji: title: "الرمز التعبيري" - help: "سيكون إضافة رمز تعبيري جديد متاحًا للجميع. اسحب واسقط ملفات متعددة في وقت واحد دون إدخال اسم لإنشاء رموز تعبيرية باستخدام أسماء الملفات الخاصة بهم. ستُستخدم المجموعة المحددة لجميع الملفات التي أُضيفت في نفس الوقت. يمكنك أيضًا النقر فوق \"إضافة رمز تعبيري جديد\" لفتح منتقي الملفات." + help: "ستكون إضافة رمز تعبيري جديد متاحة للجميع. اسحب وأفلت عدة ملفات في وقتٍ واحد دون إدخال اسم لإنشاء رموز تعبيرية باستخدام أسماء الملفات الخاصة بها. ستُستخدَم المجموعة المحدَّدة لجميع الملفات التي تتم إضافتها في الوقت نفسه. يمكنك أيضًا النقر على \"إضافة رمز تعبيري جديد\" لفتح منتقي الملفات." add: "إضافة رمز تعبيري جديد" - choose_files: "اختر الملفات" + choose_files: "اختيار الملفات" uploading: "جارٍ التحميل..." name: "الاسم" group: "المجموعة" @@ -5859,6 +6188,7 @@ ar: embedding: get_started: "إذا كنت تريد تضمين Discourse في موقع آخر، فابدأ بإضافة مضيفه." confirm_delete: "هل تريد بالتأكيد حذف هذا المضيف؟" + sample: "الصق رمز HTML التالي في موقعك لإنشاء موضوعات Discourse وتضمينها. استبدل REPLACE_ME بعنوان URL الأساسي للصفحة التي تضمِّنه فيها." title: "التضمين" host: "المضيفون المسموح بهم" class_name: "اسم الفئة" @@ -5909,9 +6239,12 @@ ar: replace: "استبدال" wizard_js: wizard: + jump_in: "ابدأ الآن!" + finish: "الخروج من الإعداد" back: "الرجوع" next: "التالي" - step-text: "المسافة" + configure_more: "إعداد المزيد..." + step-text: "الخطوة" step: "%{current} من %{total}" upload: "تحميل" uploading: "جارٍ التحميل..." diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index af5ea7c878..b25903fc6b 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -1039,7 +1039,6 @@ bg: experimental_sidebar: options: "Опции" navigation_section: "Навигация" - list_destination_default: "По подразбиране" change: "промени" featured_topic: "Представена тема" moderator: "%{user} е модератор" @@ -3620,7 +3619,6 @@ bg: trust_level_2_users: "Потребители с Ниво на доверие 2" trust_level_3_requirements: "Изисквания за Ниво на доверие 3" trust_level_locked_tip: "Нивото на доверие е заключено, системата не може да насърчава или понижава потребителя " - trust_level_unlocked_tip: "Нивото на доверие е отключено, системата може да насърчава или понижава потребителя " lock_trust_level: "Заключете системата с нива на доверие" unlock_trust_level: "Отклчете системата с нива" suspended_count: "Отстранени" @@ -3669,19 +3667,19 @@ bg: delete_confirm: "Сигурни ли сте, че искате да изтриете това потребителско поле?" options: "Опции" required: - title: "Да се изисква при регистрация?" + title: "Да се изисква при регистрация" enabled: "задължително" disabled: "по желание" editable: - title: "Да се редактира след регистрация?" + title: "Да се редактира след регистрация" enabled: "може да се редактира" disabled: "Текстово поле" show_on_profile: - title: "Покажи на публичния профил?" + title: "Покажи на публичния профил" enabled: "показано на профила" disabled: "не се показва на профила" show_on_user_card: - title: "Показване в потребителската карта?" + title: "Показване в потребителската карта" enabled: "показано в потребителската карта" disabled: "не е показано в потребителската карта" field_types: diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index f7e09dd544..31f8685952 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -950,7 +950,6 @@ bs_BA: experimental_sidebar: options: "Opcije" navigation_section: "Navigacija" - list_destination_default: "Uobičajen" change: "promjeni" featured_topic: "Istaknuta tema" moderator: "%{user} je moderator" @@ -4110,7 +4109,6 @@ bs_BA: trust_level_2_users: "Trust Level 2 Users" trust_level_3_requirements: "Trust Level 3 Requirements" trust_level_locked_tip: "trust level is locked, system will not promote or demote user" - trust_level_unlocked_tip: "trust level is unlocked, system will may promote or demote user" lock_trust_level: "Lock Trust Level" unlock_trust_level: "Unlock Trust Level" silenced_count: "Stišano" @@ -4166,19 +4164,19 @@ bs_BA: delete_confirm: "Are you sure you want to delete that user field?" options: "Opcije" required: - title: "Required at signup?" + title: "Required at signup" enabled: "required" disabled: "not required" editable: - title: "Editable after signup?" + title: "Editable after signup" enabled: "editable" disabled: "not editable" show_on_profile: - title: "Prikaži na javnom profilu?" + title: "Prikaži na javnom profilu" enabled: "prikazano na profilu" disabled: "nije prikazano na profilu" show_on_user_card: - title: "Prikaži na korisničkoj kartici?" + title: "Prikaži na korisničkoj kartici" enabled: "prikazano na korisničkoj kartici" disabled: "nije prikazano na korisničkoj kartici" field_types: diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index ef3bd29c81..053794f85e 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -924,7 +924,6 @@ ca: experimental_sidebar: options: "Opcions" navigation_section: "Navegació" - list_destination_default: "Per defecte" change: "canvia" featured_topic: "Tema destacat" moderator: "%{user} és un moderador" @@ -3944,7 +3943,6 @@ ca: trust_level_2_users: "Usuaris de nivell de confiança 2" trust_level_3_requirements: "Requisits del nivell de confiança 3" trust_level_locked_tip: "el nivell de confiança està blocat, el sistema no promourà o degradarà l'usuari" - trust_level_unlocked_tip: "el nivell de confiança està desblocat, el sistema pot promoure o degradar l'usuari" lock_trust_level: "Bloca el nivell de confiança" unlock_trust_level: "Desbloca el nivell de confiança" silenced_count: "Silenciat" @@ -3999,19 +3997,18 @@ ca: delete_confirm: "Esteu segur que voleu suprimir aquest camp d'usuari?" options: "Opcions" required: - title: "Necessari en identificar-se?" + title: "Necessari en identificar-se" enabled: "necessari" disabled: "no necessari" editable: - title: "¿És editable després del registre?" enabled: "editable" disabled: "no editable" show_on_profile: - title: "Voleu mostrar-ho en el perfil públic?" + title: "Voleu mostrar-ho en el perfil públic" enabled: "mostrat en el perfil" disabled: "no mostrat en el perfil" show_on_user_card: - title: "Mostra en la targeta d'usuari?" + title: "Mostra en la targeta d'usuari" enabled: "mostrat en la targeta d'usuari" disabled: "no mostrat en la targeta de l'usuari" field_types: diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index fa6db0dc85..2358a85bf5 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -930,7 +930,6 @@ cs: experimental_sidebar: options: "Možnosti" navigation_section: "Navigace" - list_destination_default: "Výchozí" change: "změnit" featured_topic: "Doporučené téma" moderator: "%{user} je moderátor" @@ -3721,7 +3720,6 @@ cs: trust_level_2_users: "Uživatelé důvěryhodnosti 2" trust_level_3_requirements: "Požadavky pro důvěryhodnost 3" trust_level_locked_tip: "úroveň důvěryhodnosti uzamčena. Systém nebude povyšovat ani degradovat uživatele" - trust_level_unlocked_tip: "úroveň důvěryhodnosti odemčena. Systém může povyšovat nebo degradovat uživatele" lock_trust_level: "Zamknout úroveň důvěryhodnosti" unlock_trust_level: "Odemknout úroveň důvěryhodnosti" silenced_count: "Ztišení" @@ -3776,19 +3774,19 @@ cs: delete_confirm: "Určitě chcete smazat toto rozšíření?" options: "Možnosti" required: - title: "Povinné pro registraci?" + title: "Povinné pro registraci" enabled: "povinné" disabled: "není povinné" editable: - title: "Editovatelné po registraci?" + title: "Editovatelné po registraci" enabled: "editovatelné" disabled: "není editovatelné" show_on_profile: - title: "Zveřejnit na uživatelském profilu?" + title: "Zveřejnit na uživatelském profilu" enabled: "zveřejněno na profilu" disabled: "nezveřejněno na profilu" show_on_user_card: - title: "Zobrazit na kartě uživatele?" + title: "Zobrazit na kartě uživatele" enabled: "zobrazeno na kartě uživatele" disabled: "Nezobrazeno na kartě uživatele" field_types: diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index 9363261ba3..74ac3dac2f 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -1056,7 +1056,6 @@ da: experimental_sidebar: options: "Indstillinger" navigation_section: "Navigation" - list_destination_default: "Standard" change: "skift" featured_topic: "Fremhævet emne" moderator: "%{user} er en moderator" @@ -3858,7 +3857,6 @@ da: go_back: "Tilbage til oversigt" payload_url: "Payload-URL" payload_url_placeholder: "https://example.dk/postreceive" - warn_local_payload_url: "Det ser ud til, at du prøver at konfigurere webhook til en lokal url. Begivenhed leveret til en lokal adresse vil formodentlig ikke fungere. Ønsker du at fortsætte?" secret_invalid: "Hemmelighed må ikke indeholde mellemrumstegn." secret_too_short: "Hemmeligheden bør have mindst 12 tegn." secret_placeholder: "En valgfri tekststreng, der bruges til at oprette signatur" @@ -4142,7 +4140,6 @@ da: is_private: "Temaet er i et privat git-repository" remote_branch: "Filialnavn (valgfrit)" public_key: "Giv følgende offentlig nøgle adgang til repoen:" - public_key_note: "Når du har indtastet en gyldig URL-adresse over, vil en SSH-nøgle blive genereret og vist her." install: "Installér" installed: "Installeret" install_popular: "Populære" @@ -4797,7 +4794,6 @@ da: trust_level_2_users: "Tillids niveau 2 brugere" trust_level_3_requirements: "Fortrolighedsniveau 3 påkrævet" trust_level_locked_tip: "tillidsniveau er låst, systemet kan ikke forfremme eller degradere bruger" - trust_level_unlocked_tip: "tillidsniveau er ulåst, systemet kan forfremme eller degradere bruger" lock_trust_level: "Lås tillidsniveau" unlock_trust_level: "Lås tillidsniveau op" silenced_count: "Mådeholdt (silenced)" @@ -4857,23 +4853,23 @@ da: delete_confirm: "Er du sikker på at du vil slette det brugerfelt?" options: "Indstillinger" required: - title: "Krævet ved registrering?" + title: "Krævet ved registrering" enabled: "krævet" disabled: "ikke krævet" editable: - title: "Kan rettes efter registrering?" + title: "Kan rettes efter registrering" enabled: "redigerbar" disabled: "ikke redigerbar" show_on_profile: - title: "Vis i offentlig profil?" + title: "Vis i offentlig profil" enabled: "vist i profil" disabled: "ikke vist i profil" show_on_user_card: - title: "Vis på brugers profilkort?" + title: "Vis på brugers profilkort" enabled: "vist på brugerens profilkort" disabled: "ikke vist på brugerens profilkort" searchable: - title: "Søgbar?" + title: "Søgbar" enabled: "søgbar" disabled: "ikke søgbar" field_types: diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index ceea169249..e5cb5ba9cf 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -970,7 +970,7 @@ de: user_fields: none: "(wähle eine Option aus)" required: 'Bitte gib einen Wert für „%{name}“ ein' - same_as_password: 'Dein Passwort sollte nicht in anderen Feldern wiederholt werden.' + same_as_password: "Dein Passwort sollte nicht in anderen Feldern wiederholt werden." user: said: "%{username}:" profile: "Profil" @@ -4165,7 +4165,6 @@ de: go_back: "Zurück zur Liste" payload_url: "Payload-URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Es scheint, dass du versuchst, den Webhook für eine lokale URL einzurichten. Ein Ereignis, das an eine lokale Adresse übermittelt wird, kann einen Nebeneffekt oder ein unerwartetes Verhalten verursachen. Möchtest du fortfahren?" secret_invalid: "Das Geheimnis darf keine Leerzeichen enthalten." secret_too_short: "Das Geheimnis sollte mindestens 12 Zeichen enthalten." secret_placeholder: "Eine optionale Zeichenkette für die Signatur" @@ -4454,7 +4453,6 @@ de: last_attempt: "Der Installationsvorgang wurde nicht abgeschlossen, letzter Versuch:" remote_branch: "Branch-Name (optional)" public_key: "Gewähre dem folgenden öffentlichen Schlüssel den Zugriff auf das Repository:" - public_key_note: "Nach Eingabe einer gültigen privaten Repository-URL wird ein SSH-Schlüssel generiert und hier angezeigt." install: "Installieren" installed: "Installiert" install_popular: "Beliebt" @@ -5132,7 +5130,6 @@ de: trust_level_2_users: "Benutzer mit Vertrauensstufe 2" trust_level_3_requirements: "Anforderungen für Vertrauensstufe 3" trust_level_locked_tip: "Die Vertrauensstufe ist gesperrt. Das System wird den Benutzer nicht befördern oder zurückstufen." - trust_level_unlocked_tip: "Vertrauensstufe ist nicht gesperrt. Das System kann den Benutzer befördern oder zurückstufen." lock_trust_level: "Vertrauensstufe sperren" unlock_trust_level: "Vertrauensstufe entsperren" silenced_count: "Stummgeschaltet" @@ -5192,23 +5189,18 @@ de: delete_confirm: "Bist du dir sicher, dass du dieses Benutzerfeld löschen möchtest?" options: "Optionen" required: - title: "Bei Registrierung erforderlich?" enabled: "erforderlich" disabled: "nicht erforderlich" editable: - title: "Nach der Registrierung editierbar?" enabled: "editierbar" disabled: "nicht editierbar" show_on_profile: - title: "Im öffentlichen Profil anzeigen?" enabled: "wird im Profil angezeigt" disabled: "wird im Profil nicht angezeigt" show_on_user_card: - title: "Auf Benutzerkarte anzeigen?" enabled: "wird auf Benutzerkarte angezeigt" disabled: "wird nicht auf Benutzerkarte angezeigt" searchable: - title: "In Suche einschließen?" enabled: "in Suche einschließen" disabled: "nicht in Suche einschließen" field_types: diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index be7886d000..fb72af4773 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -917,7 +917,6 @@ el: experimental_sidebar: options: "Επιλογές" navigation_section: "Πλοήγηση" - list_destination_default: "Προεπιλογή" change: "αλλαγή" featured_topic: "Επιλεγμένο θέμα" moderator: "Ο/Η %{user} είναι συντονιστής" @@ -4189,7 +4188,6 @@ el: trust_level_2_users: "Χρήστες Επιπέδου Εμπιστοσύνης 2" trust_level_3_requirements: "Προϋποθέσεις Επιπέδου Εμπιστοσύνης 3" trust_level_locked_tip: "το επίπεδο εμπιστοσύνης κλειδώθηκε, το σύστημα δεν θα προβιβάσει ή υποβιβάσει τον χρήστη" - trust_level_unlocked_tip: "το επίπεδο εμπιστοσύνης ξεκλειδώθηκε, το σύστημα μπορεί να προβιβάσει ή υποβιβάσει τον χρήστη" lock_trust_level: "Κλείδωσε το επίπεδο εμπιστοσύνης" unlock_trust_level: "Ξεκλείδωσε το επίπεδο εμπιστοσύνης" silenced_count: "Σιγήθηκαν" @@ -4244,19 +4242,15 @@ el: delete_confirm: "Είσαι σίγουρος πως θέλεις να διαγράψεις αυτό το πεδίο χρήστη;" options: "Επιλογές" required: - title: "Απαιτείται κατά την εγγραφή;" enabled: "απαραίτητο" disabled: "δεν είναι απαραίτητο" editable: - title: "Μπορεί να αλλάξει μετά την εγγραφή;" enabled: "επεξεργάσιμο" disabled: "μη επεξεργάσιμο" show_on_profile: - title: "Εμφάνιση στο δημόσιο προφίλ;" enabled: "εμφάνιση στο προφίλ" disabled: "να μην εμφανίζεται στο προφίλ" show_on_user_card: - title: "Εμφάνιση στην κάρτα του χρήστη;" enabled: "να εμφανίζεται στην κάρτα του χρήστη" disabled: "να μην εμφανίζεται στην κάρτα του χρήστη" field_types: diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index fe77f335f8..de0594db4b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -383,7 +383,6 @@ en: remove_confirmation: "Are you sure you want to delete this draft?" new_topic: "New topic draft" new_private_message: "New personal message draft" - topic_reply: "Draft reply" abandon: confirm: "You have a draft in progress for this topic. What would you like to do with it?" yes_value: "Discard" @@ -1043,7 +1042,7 @@ en: user_fields: none: "(select an option)" required: 'Please enter a value for "%{name}"' - same_as_password: 'Your password should not be repeated in other fields.' + same_as_password: "Your password should not be repeated in other fields." user: said: "%{username}:" @@ -1161,7 +1160,7 @@ en: not_first_time: "Not your first time?" skip_link: "Skip these tips" read_later: "I'll read it later." - reset_seen_popups: "Show onboarding tips again" + reset_seen_user_tips: "Show user tips again" theme_default_on_all_devices: "Make this the default theme on all my devices" color_scheme_default_on_all_devices: "Set default color scheme(s) on all my devices" color_scheme: "Color Scheme" @@ -1188,9 +1187,9 @@ en: tags_section: "Tags Section" tags_section_instruction: "Selected tags will be displayed under Sidebar's tags section." navigation_section: "Navigation" - list_destination_instruction: "When I click a topic list link in the sidebar with new or unread topics, take me to" - list_destination_default: "Default" - list_destination_unread_new: "New/Unread" + list_destination_instruction: "When there's new content in the sidebar..." + list_destination_default: "use the default link and show a badge for new items" + list_destination_unread_new: "link to unread/new and show a count of new items" change: "change" featured_topic: "Featured Topic" moderator: "%{user} is a moderator" @@ -1827,7 +1826,7 @@ en: what_are_you_doing: "What are you doing?" remove_status: "Remove status" - popup: + user_tips: primary: "Got it!" secondary: "don't show me these tips" @@ -2751,9 +2750,6 @@ en: move_to_inbox: title: "Move to Inbox" help: "Move message back to Inbox" - edit_message: - help: "Edit first post of the message" - title: "Edit" defer: help: "Mark as unread" title: "Defer" @@ -3545,8 +3541,7 @@ en: this_year: "this year" position: "Position on the categories page:" default_position: "Default Position" - position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, " - position_disabled_click: 'enable the "fixed category positions" setting.' + position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, enable the 'fixed category positions' setting." minimum_required_tags: "Minimum number of tags required in a topic:" default_slow_mode: 'Enable "Slow Mode" for new topics in this category.' parent: "Parent Category" @@ -4254,7 +4249,7 @@ en: welcome_topic_banner: title: "Create your Welcome Topic" - description: 'Your welcome topic is the first thing new members will read. Think of it as your “elevator pitch” or “mission statement.” Let everyone know who this community is for, what they can expect to find here, and what you’d like them to do first.' + description: "Your welcome topic is the first thing new members will read. Think of it as your “elevator pitch” or “mission statement.” Let everyone know who this community is for, what they can expect to find here, and what you’d like them to do first." button_title: "Start Editing" until: "Until:" @@ -4285,8 +4280,8 @@ en: critical_available: "A critical update is available." updates_available: "Updates are available." please_upgrade: "Please upgrade!" - no_check_performed: "A check for updates has not been performed. Ensure sidekiq is running." - stale_data: "A check for updates has not been performed lately. Ensure sidekiq is running." + no_check_performed: "A check for updates has not been performed. Ensure Sidekiq is running." + stale_data: "A check for updates has not been performed lately. Ensure Sidekiq is running." version_check_pending: "Looks like you upgraded recently. Fantastic!" installed_version: "Installed" latest_version: "Latest" @@ -4639,6 +4634,7 @@ en: change_settings_short: "Settings" howto: "How do I install plugins?" official: "Official Plugin" + broken_route: "Unable to configure link to '%{name}'. Ensure ad-blockers are disabled and try reloading the page." backups: title: "Backups" @@ -5593,23 +5589,23 @@ en: delete_confirm: "Are you sure you want to delete that user field?" options: "Options" required: - title: "Required at signup?" + title: "Required at signup" enabled: "required" disabled: "not required" editable: - title: "Editable after signup?" + title: "Editable after signup" enabled: "editable" disabled: "not editable" show_on_profile: - title: "Show on public profile?" + title: "Show on public profile" enabled: "shown on profile" disabled: "not shown on profile" show_on_user_card: - title: "Show on user card?" + title: "Show on user card" enabled: "shown on user card" disabled: "not shown on user card" searchable: - title: "Searchable?" + title: "Searchable" enabled: "searchable" disabled: "not searchable" @@ -5841,6 +5837,7 @@ en: destination: "Destination" copy_to_clipboard: "Copy Permalink to Clipboard" delete_confirm: Are you sure you want to delete this permalink? + no_permalinks: "You don't have any permalinks yet. Create a new permalink above to begin seeing a list of your permalinks here." form: label: "New:" add: "Add" diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index 66b6e916bd..f3ab2af3b6 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -970,7 +970,7 @@ es: user_fields: none: "(selecciona una opción)" required: 'Introduce un valor para «%{name}»' - same_as_password: 'Tu contraseña no debe repetirse en otros campos.' + same_as_password: "Tu contraseña no debe repetirse en otros campos." user: said: "%{username}:" profile: "Perfil" @@ -4164,7 +4164,6 @@ es: go_back: "Volver a la lista" payload_url: "URL a la que enviar el webhook" payload_url_placeholder: "https://ejemplo.com/postreceive" - warn_local_payload_url: "Parece que estás intentando configurar el webhook para una URL local. Los eventos enviados a direcciones locales pueden tener efectos colaterales o comportamientos inesperados. ¿Continuar?" secret_invalid: "El secreto no puede tener espacios en blanco." secret_too_short: "El secreto debería tener al menos 12 caracteres." secret_placeholder: "Una cadena opcional, utilizada para generar una firma" @@ -4453,7 +4452,6 @@ es: last_attempt: "La instalación no ha terminado, último intento:" remote_branch: "Nombre de la rama (opcional)" public_key: "Conceda la siguiente clave pública de acceso al repositorio:" - public_key_note: "Después de poner una URL de repositorio privado válida, se generará una clave SSH y se mostrará aquí." install: "Instalar" installed: "Instalado" install_popular: "Popular" @@ -5131,7 +5129,6 @@ es: trust_level_2_users: "Usuarios con nivel de confianza 2" trust_level_3_requirements: "Requisitos para el nivel de confianza 3" trust_level_locked_tip: "el nivel de confianza está bloqueado, el sistema no promoverá o degradará al usuario." - trust_level_unlocked_tip: "el nivel de confianza está desbloqueado, el sistema podrá promoverá o degradará al usuario." lock_trust_level: "Bloquear el nivel de confianza" unlock_trust_level: "Desbloquear el nivel de confianza" silenced_count: "Silenciado" @@ -5191,23 +5188,18 @@ es: delete_confirm: "¿Seguro que quieres eliminar este campo de usuario?" options: "Opciones" required: - title: "¿Obligatorio rellenarlo al registrarse?" enabled: "obligatorio" disabled: "no requerido" editable: - title: "¿Editable después del registro?" enabled: "editable" disabled: "no editable" show_on_profile: - title: "¿Se muestra públicamente en el perfil?" enabled: "Mostrado en el perfil" disabled: "No mostrado en el perfil" show_on_user_card: - title: "¿Mostrar en las tarjetas de usuario?" enabled: "mostrado en las tarjetas de usuario" disabled: "no mostrado en las tarjetas de usuario" searchable: - title: "¿Se puede buscar?" enabled: "se puede buscar" disabled: "no se puede buscar" field_types: diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 0626494d76..656bb0844a 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -789,7 +789,6 @@ et: experimental_sidebar: options: "Võimalused" navigation_section: "Navigatsioon" - list_destination_default: "Vaikimisi" change: "muuda" featured_topic: "Esile tõstetud teema" moderator: "%{user} on moderaator" @@ -3316,7 +3315,6 @@ et: trust_level_2_users: "Kasutajad usaldustasemel 2" trust_level_3_requirements: "Usaldustaseme 3 nõuded" trust_level_locked_tip: "usaldustase on lukustatud, süsteem ei eduta ega alanda kasutajat." - trust_level_unlocked_tip: "usaldustase on lukustamata, süsteem saab kasutajat edutada või alandada." lock_trust_level: "Lukusta usaldustase" unlock_trust_level: "Eemalda usaldustaseme lukustus" silenced_count: "Vaigistatud" @@ -3369,19 +3367,19 @@ et: delete_confirm: "Oled Sa kindel, et soovid kustutada selle kasutajavälja?" options: "Suvandid" required: - title: "Nõutud registreerumisel?" + title: "Nõutud registreerumisel" enabled: "nõutud" disabled: "ei ole nõutud" editable: - title: "Muudetav pärast registreerumist?" + title: "Muudetav pärast registreerumist" enabled: "muudetav" disabled: "ei ole muudetav" show_on_profile: - title: "Näidata avalikul profiilil?" + title: "Näidata avalikul profiilil" enabled: "näidatav profiilil" disabled: "ei ole näidatav profiilil" show_on_user_card: - title: "Kuva kasutaja profiilil?" + title: "Kuva kasutaja profiilil" enabled: "kuvatud kasutaja profiilil" disabled: "ei kuvata kasutaja profiilil" field_types: diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index c338a8c52b..f8c40f64cb 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -207,6 +207,7 @@ fa_IR: delete: "حذف" generic_error: "متأسفیم، خطایی روی داده." generic_error_with_reason: "خطایی روی داد: %{error}" + multiple_errors: "خطاهای متعددی رخ داده است: %{errors}" sign_up: "ثبت نام" log_in: "ورود" age: "سن" @@ -913,7 +914,7 @@ fa_IR: copied: "کپی شده" user_fields: none: "(یک گزینه انتخاب کنید)" - same_as_password: 'رمز عبور شما نباید در قسمت‌های دیگر تکرار شود.' + same_as_password: "رمز عبور شما نباید در قسمت‌های دیگر تکرار شود." user: said: "%{username}:" profile: "نمایه" @@ -1037,8 +1038,9 @@ fa_IR: tags_section: "بخش برچسب‌ها" tags_section_instruction: "برچسب‌های انتخاب شده در بخش برچسب‌ها نوار کناری نمایش داده می‌شود." navigation_section: "ناوبری" - list_destination_default: "پیش‌فرض" - list_destination_unread_new: "جدید/خوانده نشده" + list_destination_instruction: "وقتی محتوای جدیدی در نوار کناری وجود دارد..." + list_destination_default: "از پیوند پیش‌فرض استفاده کنید و یک نشان برای موارد جدید نشان دهید" + list_destination_unread_new: "پیوند به خوانده نشده/جدید و نمایش تعداد موارد جدید" change: "تغییر" featured_topic: "مبحث برجسته" moderator: "%{user} یک مدیر است" @@ -2784,6 +2786,7 @@ fa_IR: none: "«بدون دسته‌بندی»" all: "همه‌ی دسته‌بندی‌ها" edit: "ویرایش" + edit_title: "ویرایش این دسته‌بندی" edit_dialog_title: "ویرایش: %{categoryName}" view: "نمایش موضوعات در دسته‌بندی" back: "بازگشت به دسته‌بندی" @@ -4241,7 +4244,6 @@ fa_IR: trust_level_2_users: "کاربران سطح اعتماد 2" trust_level_3_requirements: "موارد مورد نیاز برای سطح اعتماد 3" trust_level_locked_tip: "سطح اعتماد بسته شده است. سیستم قادر به ترفیع/تنزل درجه‌ی کاربر نیست." - trust_level_unlocked_tip: "سطح اعتماد باز شده است. سیستم قادر به ترفیع/تنزل درجه‌ی کاربر خواهد بود." lock_trust_level: "قفل سطح اعتماد" unlock_trust_level: "باز کردن سطح اعتماد" suspended_count: "تعلیق شد " @@ -4293,23 +4295,18 @@ fa_IR: delete_confirm: "آیا از حذف این زمینه کاربری مطمئن هستید؟" options: "گزینه‌ها" required: - title: "مورد نیاز هنگام ثبت نام؟" enabled: "مورد نیاز " disabled: "مورد نیاز نیست " editable: - title: "قابل ویرایش بعد از ثبت نام؟" enabled: "قابل ویرایش" disabled: "غیر قابل ویرایش" show_on_profile: - title: "در نمایه عمومی نمایش داده شود؟" enabled: "نمایش در نمایه" disabled: "در نمایه نشان ندهد" show_on_user_card: - title: "در کارت کاربر نمایش داده شود؟" enabled: "در کارت کاربر نمایش داده شده" disabled: "در کارت کاربر نمایش داده نشده" searchable: - title: "قابل جستجو؟" enabled: "قابل جستجو" disabled: "قابل جستجو نیست" field_types: diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index b72ba022cd..62138e4d32 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -193,7 +193,7 @@ fi: themes: default_description: "Oletus" broken_theme_alert: "Sivustosi ei välttämättä toimi, koska teemassa tai komponentissa on virheitä." - error_caused_by: "Aiheuttaja: '%{name}'. Päivitä, määritä uudelleen tai poista se käytöstä napsauttamalla tätä." + error_caused_by: "Aiheuttaja: '%{name}'. Päivitä, määritä uudelleen tai poista se käytöstä klikkaamalla tätä." only_admins: "(tämä viesti näytetään vain sivuston ylläpitäjille)" broken_decorator_alert: "Viestit eivät välttämättä näy oikein, koska yksi sivustosi viestien sisällön somistajista ilmoitti virheestä." s3: @@ -974,7 +974,7 @@ fi: user_fields: none: "(valitse vaihtoehto)" required: '"%{name}" on pakollinen' - same_as_password: 'Salasanaasi ei tulisi toistaa muissa kentissä.' + same_as_password: "Salasanaasi ei tulisi toistaa muissa kentissä." user: said: "%{username}:" profile: "Profiili" @@ -1065,7 +1065,7 @@ fi: dismiss_messages_tooltip: "Merkitse kaikki lukemattomien yksityisviestien ilmoitukset luetuiksi" no_messages_title: "Sinulla ei ole viestejä" no_messages_body: > - Haluatko suoraa henkilökohtaista keskustelua jonkun kanssa normaalin keskusteluvirran ulkopuolella? Lähetä hänelle viesti napsauttamalla hänen profiilikuvaansa ja käyttämällä viestipainiketta %{icon}.

    Jos tarvitset apua, voit lähettää viestin henkilökunnan jäsenelle. + Haluatko suoraa henkilökohtaista keskustelua jonkun kanssa normaalin keskusteluvirran ulkopuolella? Lähetä hänelle viesti klikkaamalla hänen profiilikuvaansa ja käyttämällä viestipainiketta %{icon}.

    Jos tarvitset apua, voit lähettää viestin henkilökunnan jäsenelle. no_bookmarks_title: "Et ole vielä lisännyt mitään kirjanmerkkeihin" no_bookmarks_body: > Aloita kirjanmerkkien lisääminen painikkeella %{icon} ja ne listataan tähän helposti löydettäväksi. Voit ajastaa myös muistutuksen! @@ -1078,7 +1078,7 @@ fi: Saat tässä paneelissa ilmoituksen muunlaisesta toiminnasta, joka voi olla sinulle merkityksellistä – esimerkiksi kun joku linkittää johonkin viesteistäsi tai muokkaa jotakin niistä. no_notifications_page_title: "Sinulla ei ole vielä ilmoituksia" no_notifications_page_body: > - Saat ilmoituksia sinulle relevantista toiminnasta, kuten omien ketjujesi viesteistä ja vastauksista, kun joku @mainitsee nimesi tai lainaa sinua tai vastaa tarkkailemiisi ketjuihin. Ilmoitukset lähetetään sinulle sähköpostitse silloin, kun et ole kirjautunut hetkeen.

    Napsauta painiketta %{icon}, kun haluat ilmoituksia tietystä ketjusta, alueista tai tunnisteista. Lisää valintoja löydät ilmoitusasetuksista. + Saat ilmoituksia sinulle relevantista toiminnasta, kuten omien ketjujesi viesteistä ja vastauksista, kun joku @mainitsee nimesi tai lainaa sinua tai vastaa tarkkailemiisi ketjuihin. Ilmoitukset lähetetään sinulle sähköpostitse silloin, kun et ole kirjautunut hetkeen.

    Klikkaa painiketta %{icon}, kun haluat ilmoituksia tietystä ketjusta, alueista tai tunnisteista. Lisää valintoja löydät ilmoitusasetuksista. first_notification: "Ensimmäinen ilmoitus sinulle! Valitse se aloittaaksesi." dynamic_favicon: "Näytä määrät selaimen kuvakkeessa" skip_new_user_tips: @@ -1113,9 +1113,6 @@ fi: tags_section: "Tunnisteet-osio" tags_section_instruction: "Valitut tunnisteet näkyvät sivupalkin tunnisteet-osiossa." navigation_section: "Navigointi" - list_destination_instruction: "Kun napsautan sivupalkin ketjuluettelon linkkiä, jossa on uusia tai lukemattomia ketjuja, siirry kohtaan" - list_destination_default: "Oletus" - list_destination_unread_new: "Uudet/lukemattomat" change: "vaihda" featured_topic: "Valikoitu ketju" moderator: "%{user} on valvoja" @@ -2006,7 +2003,7 @@ fi: drafts_offline: "offline-luonnokset" edit_conflict: "muokkauskonflikti" esc: "esc" - esc_label: "Kuittaa napsauttamalla tai painamalla Esc-näppäintä" + esc_label: "Kuittaa klikkaamalla tai painamalla Esc-näppäintä" ok_proceed: "Ok, jatka" group_mentioned_limit: one: "Varoitus! Olet maininnut ryhmän %{group}, mutta ryhmässä on yli %{count} käyttäjä, mikä on ylläpitäjän asettama rajoitus maininnoille. Kukaan ei saa ilmoitusta." @@ -2425,8 +2422,8 @@ fi: other: "Kirjanmerkit – %{count} lukematonta kirjanmerkkiä" review_queue: "Tarkastusjono" review_queue_with_unread: - one: "Tarkistusjono – %{count} kohde vaatii tarkistuksen" - other: "Tarkistusjono – %{count} kohdetta vaatii tarkistuksen" + one: "Tarkastusjono – %{count} kohde vaatii tarkistuksen" + other: "Tarkastusjono – %{count} kohdetta vaatii tarkistuksen" other_notifications: "Muut ilmoitukset" other_notifications_with_unread: one: "Muut ilmoitukset – %{count} lukematon ilmoitus" @@ -2941,8 +2938,8 @@ fi: sr_collapse_replies: "Kutista upotetut vastaukset" sr_date: "Viestin päivämäärä" sr_expand_replies: - one: "Tällä viestillä on %{count} vastaus. Laajenna napsauttamalla." - other: "Tällä viestillä on %{count} vastausta. Laajenna napsauttamalla." + one: "Tällä viestillä on %{count} vastaus. Laajenna klikkaamalla." + other: "Tällä viestillä on %{count} vastausta. Laajenna klikkaamalla." expand_collapse: "laajenna/kutista" sr_below_embedded_posts_description: "viestin %{post_number} vastaukset" sr_embedded_reply_description: "käyttäjän %{username} vastaus viestiin %{post_number}" @@ -3600,7 +3597,7 @@ fi: edit: "%{shortcut} Muokkaa viestiä" delete: "%{shortcut} Poista viesti" mark_muted: "%{shortcut} Vaimenna ketju" - mark_regular: "%{shortcut} Normaali ketju (oletus)" + mark_regular: "%{shortcut} Tavallinen ketju (oletus)" mark_tracking: "%{shortcut} Seuraa ketjua" mark_watching: "%{shortcut} Tarkkaile ketjua" print: "%{shortcut} Tulosta ketju" @@ -3868,13 +3865,13 @@ fi: archive: "Arkisto" tags: none: "Et ole lisännyt tunnisteita." - click_to_get_started: "Aloita napsauttamalla tätä." + click_to_get_started: "Aloita klikkaamalla tätä." header_link_text: "Tunnisteet" header_action_title: "muokkaa sivupalkin tunnisteitasi" configure_defaults: "Määritä oletukset" categories: none: "Et ole lisännyt alueita." - click_to_get_started: "Aloita napsauttamalla tätä." + click_to_get_started: "Aloita klikkaamalla tätä." header_link_text: "Alueet" header_action_title: "muokkaa sivupalkin alueitasi" configure_defaults: "Määritä oletukset" @@ -3913,8 +3910,8 @@ fi: title: "käsittele" pending_count: "%{count} odottaa" welcome_topic_banner: - title: "Luo tervetuloaiheesi" - description: 'Tervetuloaiheesi on ensimmäinen asia, jonka uudet jäsenet lukevat. Ajattele sitä "hissipuheenasi" tai "tavoitelausumanasi". Kerro kaikille, kenelle tämä yhteisö on tarkoitettu, mitä he voivat odottaa löytävänsä täältä ja mitä haluat heidän tekevän ensin.' + title: "Luo tervetuloketjusi" + description: "Tervetuloketjusi on ensimmäinen asia, jonka uudet jäsenet lukevat. Ajattele sitä \"hissipuheenasi\" tai \"tavoitelausumanasi\". Kerro kaikille, kenelle tämä yhteisö on tarkoitettu, mitä he voivat odottaa löytävänsä täältä ja mitä haluat heidän tekevän ensin." button_title: "Aloita muokkaaminen" until: "Asti:" admin_js: @@ -4188,7 +4185,6 @@ fi: go_back: "Takaisin luetteloon" payload_url: "Tietosisällön URL" payload_url_placeholder: "https://esimerkki.fi/saapuva" - warn_local_payload_url: "Näyttäisi siltä, että yrität asettaa webhookin paikalliseen URL-osoitteeseen. Paikalliseen osoitteeseen toimitetulla tapahtumalla voi olla sivuvaikutuksia tai se voi käyttäytyä odottamattomasti. Jatketaanko?" secret_invalid: "Salausavaimessa ei voi olla tyhjiä merkkejä." secret_too_short: "Salausavaimessa täytyy olla ainakin 12 merkkiä." secret_placeholder: "Valinnainen merkkijono, käytetään luotaessa salausallekirjoitusta" @@ -4477,7 +4473,6 @@ fi: last_attempt: "Asennusprosessi ei päättynyt, viimeisin yritys:" remote_branch: "Haaran nimi (valinnainen)" public_key: "Anna tietovaraston käyttöoikeus seuraavalle julkiselle avaimelle:" - public_key_note: "Kun olet syöttänyt kelvollisen yksityisen tietovaraston URL-osoitteen yllä, SSH-avain luodaan ja näytetään tässä." install: "Asenna" installed: "Asennettu" install_popular: "Suosittuja" @@ -5021,7 +5016,7 @@ fi: suspended: "Hyllytetty?" staged: "Esikäyttäjä?" show_admin_profile: "Ylläpito" - manage_user: "Hallinnoi käyttäjää" + manage_user: "Hallitse käyttäjää" show_public_profile: "Näytä julkinen profiili" impersonate: "Esiinny käyttäjänä" action_logs: "Toimintaloki" @@ -5156,7 +5151,6 @@ fi: trust_level_2_users: "Käyttäjät luottamustasolla 2" trust_level_3_requirements: "Luottamustason 3 vaatimukset" trust_level_locked_tip: "luottamustaso on lukittu, järjestelmä ei ylennä tai alenna käyttäjää" - trust_level_unlocked_tip: "luottamustason lukitus on poistettu, järjestelmä voi ylentää tai alentaa käyttäjän" lock_trust_level: "Lukitse luottamustaso" unlock_trust_level: "Poista luottamustason lukitus" silenced_count: "Hiljennetty" @@ -5216,23 +5210,18 @@ fi: delete_confirm: "Oletko varma, että haluat poistaa tämän käyttäjäkentän?" options: "Vaihtoehdot" required: - title: "Pakollinen rekisteröidyttäessä?" enabled: "pakollinen" disabled: "ei pakollinen" editable: - title: "Muokattavissa rekisteröitymisen jälkeen?" enabled: "muokattavissa" disabled: "ei muokattavissa" show_on_profile: - title: "Näytetään julkisessa profiilissa?" enabled: "näytetään profiilissa" disabled: "ei näytetä profiilissa" show_on_user_card: - title: "Näytetään käyttäjäkortilla?" enabled: "näytetään käyttäjäkortilla" disabled: "ei näytetä käyttäjäkortilla" searchable: - title: "Haettavissa?" enabled: "haettavissa" disabled: "ei haettavissa" field_types: @@ -5403,13 +5392,13 @@ fi: upload_csv: Lataa CSV, jossa on joko käyttäjien sähköpostiosoitteita tai käyttäjätunnuksia aborted: Lataa CSV, joka sisältää joko käyttäjien sähköpostiosoitteita tai käyttäjätunnuksia success: CSV tuli perille ja %{count} käyttäjää saavat kunniamerkkinsä pian. - csv_has_unmatched_users: "Seuraavat merkinnät ovat CSV-tiedostossa, mutta niitä ei voitu yhdistää olemassa oleviin käyttäjiin, joten he eivät saa merkkiä:" - csv_has_unmatched_users_truncated_list: "CSV-tiedostossa oli %{count} merkintää, joita ei voitu yhdistää olemassa oleviin käyttäjiin, joten he eivät saa merkkiä. Koska yhdistämättömiä merkintöjä on paljon, vain 100 ensimmäistä näytetään:" + csv_has_unmatched_users: "Seuraavat merkinnät ovat CSV-tiedostossa, mutta niitä ei voitu yhdistää olemassa oleviin käyttäjiin, joten he eivät saa kunniamerkkiä:" + csv_has_unmatched_users_truncated_list: "CSV-tiedostossa oli %{count} merkintää, joita ei voitu yhdistää olemassa oleviin käyttäjiin, joten he eivät saa kunniamerkkiä. Koska yhdistämättömiä merkintöjä on paljon, vain 100 ensimmäistä näytetään:" replace_owners: Poista kunniamerkki aiemmilta omistajilta grant_existing_holders: Myönnä lisämerkkejä nykyisille kunniamerkkien haltijoille emoji: title: "Emoji" - help: "Lisää uusi emoji, joka on kaikkien käytettävissä. Vedä ja pudota useita tiedostoja kerralla antamatta nimeä luodaksesi emojeja niiden tiedostonimillä. Valittua ryhmää käytetään kaikille tiedostoille, jotka lisätään samaan aikaan. Voit myös avata tiedostovalitsimen napsauttamalla Lisää uusi hymiö." + help: "Lisää uusi emoji, joka on kaikkien käytettävissä. Vedä ja pudota useita tiedostoja kerralla antamatta nimeä luodaksesi emojeja niiden tiedostonimillä. Valittua ryhmää käytetään kaikille tiedostoille, jotka lisätään samaan aikaan. Voit myös avata tiedostovalitsimen klikkaamalla Lisää uusi hymiö." add: "Lisää uusi emoji" choose_files: "Valitse tiedostot" uploading: "Ladataan..." diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index fd5842eca2..cd3bd2c6e1 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -142,7 +142,7 @@ fr: close: "fermer" twitter: "Partager sur Twitter" facebook: "Partager sur Facebook" - email: "Envoyer par courriel" + email: "Envoyer par e-mail" url: "Copier et partager l'URL" action_codes: public_topic: "a rendu ce sujet public %{when}" @@ -176,17 +176,17 @@ fr: banner: enabled: "a mis à la une %{when}. Il sera affiché en haut de chaque page jusqu'à ce qu'il soit ignoré par un utilisateur." disabled: "a supprimé de la une %{when}. Il ne sera plus affiché en haut de chaque page." - forwarded: "a transmis le courriel ci-dessus" + forwarded: "a transmis l'e-mail ci-dessus" topic_admin_menu: "actions du sujet" skip_to_main_content: "Passer au contenu principal" - emails_are_disabled: "Le courriel sortant a été désactivé par un administrateur. Aucune notification par courriel ne sera envoyée." + emails_are_disabled: "L'e-mail sortant a été désactivé par un administrateur. Aucune notification par e-mail ne sera envoyée." emails_are_disabled_non_staff: "L'envoi d'e-mail est désactivé pour les utilisateurs ne faisant pas partie des responsables." software_update_prompt: message: "Nous avons fait une mise à jour du site, nous vous invitons donc à actualiser cette page pour éviter d'éventuels dysfonctionnements." dismiss: "Ignorer" bootstrap_mode_enabled: - one: "Pour faciliter son lancement, votre nouveau site se trouve en mode d'amorçage. Chaque nouvel utilisateur se verra accorder le niveau de confiance 1 et recevra des résumés quotidiens par courriel. Ce mode sera automatiquement désactivé dès que votre site possédera %{count} utilisateur." - other: "Pour faciliter son lancement, votre nouveau site se trouve en mode d'amorçage. Chaque nouvel utilisateur se verra accorder le niveau de confiance 1 et recevra des résumés quotidiens par courriel. Ce mode sera automatiquement désactivé dès que votre site possédera %{count} utilisateurs." + one: "Pour faciliter son lancement, votre nouveau site se trouve en mode d'amorçage. Chaque nouvel utilisateur se verra accorder le niveau de confiance 1 et recevra des résumés quotidiens par e-mail. Ce mode sera automatiquement désactivé dès que votre site possédera %{count} utilisateur." + other: "Pour faciliter son lancement, votre nouveau site se trouve en mode d'amorçage. Chaque nouvel utilisateur se verra accorder le niveau de confiance 1 et recevra des résumés quotidiens par e-mail. Ce mode sera automatiquement désactivé dès que votre site possédera %{count} utilisateurs." bootstrap_mode_disabled: "Le mode d'amorçage sera désactivé dans les prochaines 24 heures." bootstrap_invite_button_title: "Envoyer des invitations" bootstrap_wizard_link_title: "Terminer l'assistant de configuration" @@ -469,7 +469,7 @@ fr: bio: "Biographie" website: "Site Web" username: "Nom d'utilisateur" - email: "Courriel" + email: "E-mail" name: "Nom" fields: "Champs" reject_reason: "Raison" @@ -570,7 +570,7 @@ fr: example_username: "nom d'utilisateur" reject_reason: title: "Pourquoi refusez-vous cet utilisateur ?" - send_email: "Envoyer le courriel de refus" + send_email: "Envoyer l'e-mail de refus" relative_time_picker: minutes: one: "minute" @@ -674,7 +674,7 @@ fr: title: "Ajouter des utilisateurs à %{group_name}" description: "Saisissez une liste d'utilisateurs que vous souhaitez inviter dans le groupe ou collez-la dans une liste séparée par des virgules :" usernames_placeholder: "noms d'utilisateur" - usernames_or_emails_placeholder: "noms d'utilisateur ou courriels" + usernames_or_emails_placeholder: "noms d'utilisateur ou adresses e-mail" notify_users: "Notifications aux utilisateurs" set_owner: "Définir les utilisateurs comme propriétaires de ce groupe" requests: @@ -700,8 +700,8 @@ fr: posting: Contribution notification: Notification email: - title: "Courriel" - status: "%{old_emails}/%{total_emails} courriels synchronisés via IMAP." + title: "E-mail" + status: "%{old_emails}/%{total_emails} e-mails synchronisés via IMAP." enable_smtp: "Activer le SMTP" enable_imap: "Activer l'IMAP" test_settings: "Tester les paramètres" @@ -711,10 +711,10 @@ fr: settings_required: "Tous les paramètres sont requis. Veuillez renseigner chaque champ avant de valider." smtp_settings_valid: "Paramètres SMTP valides." smtp_title: "SMTP" - smtp_instructions: "Quand le SMTP est activé au niveau du groupe, tous les courriels envoyés au nom de ce groupe seront expédiés en utilisant les paramètres SMTP indiqués ici plutôt qu'avec les paramètres SMTP globaux du site." + smtp_instructions: "Quand le SMTP est activé au niveau du groupe, tous les e-mails envoyés au nom de ce groupe seront expédiés en utilisant les paramètres SMTP indiqués ici plutôt qu'avec les paramètres SMTP globaux du site." imap_title: "IMAP" imap_additional_settings: "Paramètres additionnels" - imap_instructions: 'Quand l''IMAP est activé au niveau du groupe, les courriels sont synchronisés entre la boîte de réception de ce groupe et la boîte de réception IMAP indiquée ici. Avant de pouvoir activer l''IMAP, le SMTP doit d''abord être activé, et des paramètres SMTP valides doivent être renseignés. L''identifiant et le mot de passe du serveur SMTP seront utilisés pour l''authentification auprès du serveur IMAP. Pour en savoir plus, consultez l''annonce de cette fonctionnalité sur Discourse Meta (en anglais).' + imap_instructions: 'Quand l''IMAP est activé au niveau du groupe, les e-mails sont synchronisés entre la boîte de réception de ce groupe et la boîte de réception IMAP indiquée ici. Avant de pouvoir activer l''IMAP, le SMTP doit d''abord être activé, et des paramètres SMTP valides doivent être renseignés. L''identifiant et le mot de passe du serveur SMTP seront utilisés pour l''authentification auprès du serveur IMAP. Pour en savoir plus, consultez l''annonce de cette fonctionnalité sur Discourse Meta (en anglais).' imap_alpha_warning: "Attention : cette fonctionnalité est en test alpha. Seul Gmail est pris en charge officiellement. Son utilisation se fait à vos risques et périls !" imap_settings_valid: "Paramètres IMAP valides." smtp_disable_confirm: "En désactivant le SMTP, tous les paramètres SMTP et IMAP seront réinitialisés et ces fonctionnalités seront désactivées. Souhaitez-vous continuer ?" @@ -736,12 +736,12 @@ fr: settings: title: "Paramètres" allow_unknown_sender_topic_replies: "Autoriser les réponses par un expéditeur inconnu." - allow_unknown_sender_topic_replies_hint: "Permet aux expéditeurs inconnus de répondre aux sujets du groupe. Si cette option n'est pas activée, les réponses provenant d'adresses courriel qui ne sont pas encore invitées dans le sujet créeront un nouveau sujet." + allow_unknown_sender_topic_replies_hint: "Permet aux expéditeurs inconnus de répondre aux sujets du groupe. Si cette option n'est pas activée, les réponses provenant d'adresses e-mail qui ne sont pas encore invitées dans le sujet créeront un nouveau sujet." from_alias: "Alias de l'expéditeur" from_alias_hint: "Alias à utiliser comme adresse d'expédition lors de l'envoi d'e-mails SMTP de groupe. Veuillez noter que cela peut ne pas être pris en charge par tous les prestataires de messagerie, nous vous invitons à consulter la documentation de votre prestataire." mailboxes: synchronized: "Boîte de réception à synchroniser" - none_found: "Aucune boîte de réception n'a été trouvée pour ce compte de courriel." + none_found: "Aucune boîte de réception n'a été trouvée pour ce compte d'e-mail." disabled: "Désactivée" membership: title: Adhésion @@ -831,7 +831,7 @@ fr: activity: "Activité" members: title: "Membres" - filter_placeholder_admin: "nom d'utilisateur ou courriel" + filter_placeholder_admin: "nom d'utilisateur ou adresse e-mail" filter_placeholder: "nom d'utilisateur" remove_member: "Supprimer le membre" remove_member_description: "Supprimer %{username} de ce groupe" @@ -974,7 +974,7 @@ fr: user_fields: none: "(choisir une option)" required: 'Veuillez saisir une valeur pour « %{name} »' - same_as_password: 'Votre mot de passe ne doit pas être répété dans d''autres champs.' + same_as_password: "Votre mot de passe ne doit pas être répété dans d'autres champs." user: said: "%{username} :" profile: "Profil" @@ -1072,7 +1072,7 @@ fr: no_bookmarks_search: "Aucun signet trouvé avec la requête de recherche fournie." no_notifications_title: "Vous n'avez pas encore reçu de notifications" no_notifications_body: > - Des notifications s'afficheront ici pour vous informer de l'activité qui vous concerne directement sur le forum, telle que les réponses qui vous sont adressées, les personnes qui citent vos messages ou qui mentionnent votre pseudo, et les nouveaux messages publiés dans les sujets que vous suivez. Si vous ne vous êtes pas connecté(e) au forum depuis un moment, vous recevrez aussi ces notifications par courriel.

    Le bouton %{icon} vous permettra de choisir les sujets, les catégories et les étiquettes pour lesquels vous souhaitez recevoir des notifications. Pour en savoir plus, consultez également vos préférences de notification. + Des notifications s'afficheront ici pour vous informer de l'activité qui vous concerne directement sur le forum, telle que les réponses qui vous sont adressées, les personnes qui citent vos messages ou qui mentionnent votre pseudo, et les nouveaux messages publiés dans les sujets que vous suivez. Si vous ne vous êtes pas connecté(e) au forum depuis un moment, vous recevrez aussi ces notifications par e-mail.

    Le bouton %{icon} vous permettra de choisir les sujets, les catégories et les étiquettes pour lesquels vous souhaitez recevoir des notifications. Pour en savoir plus, consultez également vos préférences de notification. no_other_notifications_title: "Vous n'avez pas encore reçu d'autres notifications" no_other_notifications_body: > Vous serez informé(e) dans ce panneau au sujet des autres types d'activité qui peuvent vous intéresser. Par exemple, lorsque quelqu'un crée un lien ou modifie un de vos messages. @@ -1113,9 +1113,6 @@ fr: tags_section: "Section Étiquettes" tags_section_instruction: "Les étiquettes sélectionnées seront affichées dans la section Étiquettes de la barre latérale." navigation_section: "Navigation" - list_destination_instruction: "Lorsque je clique sur le lien d'une liste de sujets dans la barre latérale contenant des sujets nouveaux ou non lus, j'accède à" - list_destination_default: "Par défaut" - list_destination_unread_new: "Nouveau/Non lu" change: "modifier" featured_topic: "Sujet vedette" moderator: "%{user} a le rôle de modérateur(rice)" @@ -1133,12 +1130,12 @@ fr: enabled: "Activer la liste de diffusion" instructions: | Ce paramètre remplace le résumé d'activité.
    - Les sujets et catégories mis en sourdine ne sont pas inclus dans ces courriels. - individual: "Envoyer un courriel pour chaque nouveau message" - individual_no_echo: "Envoyer un courriel pour chaque nouveau message sauf les miens" - many_per_day: "M'envoyer un courriel pour chaque nouveau message (environ %{dailyEmailEstimate} par jour)" - few_per_day: "M'envoyer un courriel pour chaque nouveau message (environ 2 par jour)" - warning: "Mode liste de diffusion activé. Les paramètres de notification par courriel sont remplacés." + Les sujets et catégories mis en sourdine ne sont pas inclus dans ces e-mails. + individual: "Envoyer un e-mail pour chaque nouveau message" + individual_no_echo: "Envoyer un e-mail pour chaque nouveau message sauf les miens" + many_per_day: "M'envoyer un e-mail pour chaque nouveau message (environ %{dailyEmailEstimate} par jour)" + few_per_day: "M'envoyer un e-mail pour chaque nouveau message (environ 2 par jour)" + warning: "Mode liste de diffusion activé. Les paramètres de notification par e-mail sont remplacés." tag_settings: "Étiquettes" watched_tags: "Surveillées" watched_tags_instructions: "Vous surveillerez automatiquement tous les sujets marqués par ces étiquettes. Vous recevrez des notifications pour tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à côté du sujet." @@ -1222,7 +1219,7 @@ fr: account: "Compte" security: "Sécurité" profile: "Profil" - emails: "Courriels" + emails: "E-mails" notifications: "Notifications" categories: "Catégories" users: "Utilisateurs" @@ -1231,11 +1228,11 @@ fr: apps: "Applications" sidebar: "Barre latérale" change_password: - success: "(courriel envoyé)" - in_progress: "(courriel en cours d'envoi)" + success: "(e-mail envoyé)" + in_progress: "(e-mail en cours d'envoi)" error: "(erreur)" emoji: "émoji de cadenas" - action: "Envoyer un courriel de réinitialisation du mot de passe" + action: "Envoyer un e-mail de réinitialisation du mot de passe" set_password: "Définir le mot de passe" choose_new: "Choisissez un nouveau mot de passe" choose: "Choisissez un mot de passe" @@ -1315,20 +1312,20 @@ fr: taken: "Nous sommes désolés, ce nom d'utilisateur est déjà utilisé." invalid: "Ce nom d'utilisateur est invalide. Il ne doit être composé que de lettres et de chiffres." add_email: - title: "Ajouter une adresse courriel" + title: "Ajouter une adresse e-mail" add: "ajouter" change_email: - title: "Modifier l'adresse courriel" - taken: "Nous sommes désolés, cette adresse courriel est indisponible." - error: "Une erreur est survenue lors de la modification de l'adresse courriel. Cette adresse est peut-être déjà utilisée ?" - success: "Nous avons envoyé un courriel à cette adresse. Merci de suivre les instructions." - success_via_admin: "Nous avons envoyé un courriel à cette adresse. L'utilisateur devra suivre les instructions de confirmation qui y sont indiquées." - success_staff: "Nous avons envoyé un courriel à votre adresse actuelle. Merci de suivre les instructions qui y figurent." + title: "Modifier l'adresse e-mail" + taken: "Nous sommes désolés, cette adresse e-mail est indisponible." + error: "Une erreur est survenue lors de la modification de l'adresse e-mail. Cette adresse est peut-être déjà utilisée ?" + success: "Nous avons envoyé un e-mail à cette adresse. Merci de suivre les instructions." + success_via_admin: "Nous avons envoyé un e-mail à cette adresse. L'utilisateur devra suivre les instructions de confirmation qui y sont indiquées." + success_staff: "Nous avons envoyé un e-mail à votre adresse actuelle. Merci de suivre les instructions qui y figurent." change_avatar: title: "Modifier votre image de profil" gravatar: "%{gravatarName}, associé à" gravatar_title: "Modifier votre avatar sur le site de %{gravatarName}" - gravatar_failed: "Nous n'avons pas trouvé de %{gravatarName} associé à cette adresse courriel." + gravatar_failed: "Nous n'avons pas trouvé de %{gravatarName} associé à cette adresse e-mail." refresh_gravatar_title: "Actualiser votre %{gravatarName}" letter_based: "Image de profil attribuée par le système" uploaded_avatar: "Avatar personnalisé" @@ -1347,32 +1344,32 @@ fr: title: "Sujet vedette" instructions: "Un lien vers ce sujet sera ajouté à votre carte d'utilisateur et votre profil." email: - title: "Courriel" - primary: "Adresse courriel principale" - secondary: "Adresses courriel secondaires" + title: "E-mail" + primary: "Adresse e-mail principale" + secondary: "Adresses e-mail secondaires" primary_label: "principale" unconfirmed_label: "non confirmée" - resend_label: "renvoyer le courriel de confirmation" + resend_label: "renvoyer l'e-mail de confirmation" resending_label: "envoi en cours…" - resent_label: "courriel envoyé" - update_email: "Modifier l'adresse courriel" - set_primary: "Définir comme adresse courriel principale" - destroy: "Supprimer l'adresse courriel" - add_email: "Ajouter une adresse courriel alternative" - auth_override_instructions: "Le courriel peut être mis à jour à partir du fournisseur d'authentification." - no_secondary: "Aucune adresse courriel secondaire" + resent_label: "e-mail envoyé" + update_email: "Modifier l'adresse e-mail" + set_primary: "Définir comme adresse e-mail principale" + destroy: "Supprimer l'adresse e-mail" + add_email: "Ajouter une adresse e-mail alternative" + auth_override_instructions: "L'e-mail peut être mis à jour à partir du fournisseur d'authentification." + no_secondary: "Aucune adresse e-mail secondaire" instructions: "Ne sera pas visible publiquement." - admin_note: "Remarque : un utilisateur administrateur modifiant l'adresse courriel d'un autre utilisateur non administrateur indique que l'utilisateur a perdu l'accès à son compte de messagerie d'origine. Un courriel de réinitialisation du mot de passe sera donc envoyé à sa nouvelle adresse. L'adresse courriel de l'utilisateur ne changera pas tant qu'il n'aura pas terminé le processus de réinitialisation du mot de passe." - ok: "Nous vous enverrons un courriel de confirmation" - required: "Veuillez saisir une adresse courriel" - invalid: "Veuillez saisir une adresse courriel valide" - authenticated: "Votre adresse courriel a été authentifiée par %{provider}" + admin_note: "Remarque : un utilisateur administrateur modifiant l'adresse e-mail d'un autre utilisateur non administrateur indique que l'utilisateur a perdu l'accès à son compte de messagerie d'origine. Un e-mail de réinitialisation du mot de passe sera donc envoyé à sa nouvelle adresse. L'adresse e-mail de l'utilisateur ne changera pas tant qu'il n'aura pas terminé le processus de réinitialisation du mot de passe." + ok: "Nous vous enverrons un e-mail de confirmation" + required: "Veuillez saisir une adresse e-mail" + invalid: "Veuillez saisir une adresse e-mail valide" + authenticated: "Votre adresse e-mail a été authentifiée par %{provider}" invite_auth_email_invalid: "Votre e-mail d'invitation ne correspond pas à l'e-mail authentifié par %{provider}" - authenticated_by_invite: "Votre adresse de courriel a été authentifiée par l'invitation" - frequency_immediately: "Nous vous enverrons un courriel immédiatement si vous n'avez pas lu le contenu en question." + authenticated_by_invite: "Votre adresse e-mail a été authentifiée par l'invitation" + frequency_immediately: "Nous vous enverrons un e-mail immédiatement si vous n'avez pas lu le contenu en question." frequency: - one: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu(e) sur le site au cours de la dernière minute." - other: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu(e) sur le site au cours des %{count} dernières minutes." + one: "Nous vous enverrons des e-mails seulement si nous ne vous avons pas vu(e) sur le site au cours de la dernière minute." + other: "Nous vous enverrons des e-mails seulement si nous ne vous avons pas vu(e) sur le site au cours des %{count} dernières minutes." associated_accounts: title: "Comptes associés" connect: "Connecter" @@ -1401,7 +1398,7 @@ fr: too_short: "Votre nom d'utilisateur est trop court" too_long: "Votre nom d'utilisateur est trop long" checking: "Vérification de la disponibilité du nom d'utilisateur…" - prefilled: "L'adresse courriel correspond à ce nom d'utilisateur enregistré" + prefilled: "L'adresse e-mail correspond à ce nom d'utilisateur enregistré" required: "Veuillez entrer un nom d'utilisateur" edit: "Modifier le nom d'utilisateur" locale: @@ -1435,7 +1432,7 @@ fr: log_out: "Se déconnecter" location: "Localisation" website: "Site Web" - email_settings: "Courriel" + email_settings: "E-mail" hide_profile_and_presence: "Cacher mon profil public et mes statistiques" enable_physical_keyboard: "Activer le support du clavier physique sur iPad" text_size: @@ -1456,12 +1453,12 @@ fr: first_time: "La première fois qu'un message reçoit un « J'aime »" never: "Jamais" email_previous_replies: - title: "Inclure les réponses précédentes en bas des courriels" + title: "Inclure les réponses précédentes en bas des e-mails" unless_emailed: "sauf si déjà envoyées" always: "toujours" never: "jamais" email_digests: - title: "Lorsque je ne visite pas le site, m'envoyer un courriel avec un résumé des sujets et réponses populaires" + title: "Lorsque je ne visite pas le site, m'envoyer un e-mail avec un résumé des sujets et réponses populaires" every_30_minutes: "toutes les 30 minutes" every_hour: "toutes les heures" daily: "tous les jours" @@ -1474,8 +1471,8 @@ fr: only_when_away: "seulement en cas d'absence" never: "jamais" email_messages_level: "Envoyez-moi un e-mail lorsque je reçois un message personnel" - include_tl0_in_digests: "Inclure les contributions des nouveaux utilisateurs dans les résumés par courriel" - email_in_reply_to: "En plus du message notifié par courriel, inclure un extrait du message auquel il répond" + include_tl0_in_digests: "Inclure les contributions des nouveaux utilisateurs dans les résumés par e-mail" + email_in_reply_to: "En plus du message notifié par e-mail, inclure un extrait du message auquel il répond" other_settings: "Autre" categories_settings: "Catégories" new_topic_duration: @@ -1515,7 +1512,7 @@ fr: edit: "Modifier" remove: "Supprimer" copy_link: "Obtenir le lien" - reinvite: "Renvoyer un courriel" + reinvite: "Renvoyer un e-mail" reinvited: "Invitation renvoyée" removed: "Supprimé" search: "commencez votre saisie pour rechercher vos invitations…" @@ -1542,8 +1539,8 @@ fr: create: "Inviter" generate_link: "Créer un lien d'invitation" link_generated: "Voici votre lien d'invitation !" - valid_for: "Le lien d'invitation est seulement valide pour cette adresse courriel : %{email}" - single_user: "Inviter par courriel" + valid_for: "Le lien d'invitation est seulement valide pour cette adresse e-mail : %{email}" + single_user: "Inviter par e-mail" multiple_user: "Inviter par lien" invite_link: title: "Lien d'invitation" @@ -1567,7 +1564,7 @@ fr: invite_to_topic: "Diriger vers ce sujet" expires_at: "Ce lien expirera dans" custom_message: "Message personnel (facultatif)" - send_invite_email: "Enregistrer et envoyer le courriel" + send_invite_email: "Enregistrer et envoyer l'e-mail" send_invite_email_instructions: "Restreindre l'invitation à l'adresse email pour envoyer un email d'invitation" save_invite: "Enregistrer l'invitation" invite_saved: "Invitation enregistrée." @@ -1577,7 +1574,7 @@ fr: instructions: |

    Pour mettre votre communauté sur pied rapidement, invitez une liste d'utilisateurs : composez un fichier CSV contenant l'adresse de chaque personne à inviter, en disposant une adresse par ligne. Pour ajouter certaines personnes à des groupes particuliers, ou pour les diriger automatiquement vers un sujet particulier lors de leur première connexion, vous pouvez faire figurer les éléments suivants.

    jean@dupont.fr,nom_dun_groupe;nom_dun_autre_groupe,identifiant_numérique_du_sujet
    -

    Un courriel d'invitation sera envoyé à chaque adresse reprise dans ce fichier CSV, et vous pourrez modifier son contenu après l'avoir envoyé sur le serveur.

    +

    Un e-mail d'invitation sera envoyé à chaque adresse reprise dans ce fichier CSV, et vous pourrez modifier son contenu après l'avoir envoyé sur le serveur.

    progress: "Envoi en cours : %{progress} %…" success: "Le fichier a été envoyé avec succès. Vous recevrez un message de notification lorsque le processus sera terminé." error: "Nous sommes désolés, le fichier doit être au format CSV." @@ -1586,7 +1583,7 @@ fr: too_short: "Votre mot de passe est trop court." common: "Ce mot de passe est trop commun." same_as_username: "Votre mot de passe est identique à votre nom d'utilisateur." - same_as_email: "Votre mot de passe est identique à votre adresse courriel." + same_as_email: "Votre mot de passe est identique à votre adresse e-mail." ok: "Votre mot de passe semble correct." instructions: "au moins %{count} caractères" required: "Veuillez saisir un mot de passe" @@ -1776,44 +1773,44 @@ fr: leave: "Quitter" remove_group: "Supprimer le groupe" remove_user: "Supprimer l'utilisateur" - email: "Courriel" + email: "E-mail" username: "Nom d'utilisateur" last_seen: "Vu" created: "Créé" created_lowercase: "créé" trust_level: "Niveau de confiance" - search_hint: "nom d'utilisateur, courriel ou adresse IP" + search_hint: "nom d'utilisateur, adresse e-mail ou adresse IP" create_account: header_title: "Bienvenue !" subheader_title: "Créons votre compte" disclaimer: "En vous inscrivant, vous acceptez la politique de confidentialité et les conditions générales d'utilisation." title: "Créer votre compte" - failed: "Un problème est survenu. Cette adresse courriel est peut-être déjà enregistrée. Essayez le lien d'oubli du mot de passe." + failed: "Un problème est survenu. Cette adresse e-mail est peut-être déjà enregistrée. Essayez le lien d'oubli du mot de passe." associate: "Vous avez déjà un compte ? Connectez-vous pour lier votre compte %{provider}." forgot_password: title: "Réinitialisation du mot de passe" action: "J'ai oublié mon mot de passe" - invite: "Saisissez votre nom d'utilisateur ou votre adresse courriel et vous recevrez un nouveau mot de passe par courriel." + invite: "Saisissez votre nom d'utilisateur ou votre adresse e-mail et vous recevrez un nouveau mot de passe par e-mail." invite_no_username: "Saisissez votre adresse e-mail et nous vous enverrons un e-mail de réinitialisation du mot de passe." reset: "Réinitialiser votre mot de passe" - complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." - complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." - complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}. Vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." - complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}. Vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." + complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un e-mail contenant les instructions permettant de réinitialiser votre mot de passe." + complete_email: "Si un compte correspond à l'adresse e-mail %{email}, vous devriez recevoir rapidement un e-mail contenant les instructions permettant de réinitialiser votre mot de passe." + complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}. Vous devriez recevoir rapidement un e-mail contenant les instructions permettant de réinitialiser votre mot de passe." + complete_email_found: "Nous avons trouvé un compte correspondant à l'adresse e-mail %{email}. Vous devriez recevoir rapidement un e-mail contenant les instructions permettant de réinitialiser votre mot de passe." complete_username_not_found: "Aucun compte ne correspond au nom d'utilisateur %{username}" complete_email_not_found: "Aucun compte ne correspond à %{email}" - help: "Le courriel n'est pas arrivé ? N'oubliez pas de consulter votre dossier de courrier indésirable.

    Vous avez des doutes concernant l'adresse courriel que vous avez utilisée ? Saisissez une adresse courriel et nous vous dirons si elle existe ici.

    Si vous n'avez plus accès à l'adresse courriel de votre compte, merci de contacter nos responsables serviables.

    " + help: "L'e-mail n'est pas arrivé ? N'oubliez pas de consulter votre dossier de courrier indésirable.

    Vous avez des doutes concernant l'adresse e-mail que vous avez utilisée ? Saisissez une adresse e-mail et nous vous dirons si elle existe ici.

    Si vous n'avez plus accès à l'adresse e-mail de votre compte, merci de contacter nos responsables serviables.

    " button_ok: "OK" button_help: "Aide" email_login: - link_label: "M'envoyer un lien de connexion par courriel" - button_label: "par courriel" + link_label: "M'envoyer un lien de connexion par e-mail" + button_label: "par e-mail" login_link: "Ignorez le mot de passe ; envoyez-moi un lien de connexion" emoji: "émoji de cadenas" - complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." - complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." - complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." - complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." + complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un e-mail contenant un lien pour vous connecter." + complete_email: "Si un compte correspond à l'adresse e-mail %{email}, vous devriez recevoir rapidement un e-mail contenant un lien pour vous connecter." + complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}, vous devriez recevoir rapidement un e-mail contenant un lien pour vous connecter." + complete_email_found: "Nous avons trouvé un compte correspondant à l'adresse e-mail %{email}, vous devriez recevoir rapidement un e-mail contenant un lien pour vous connecter." complete_username_not_found: "Aucun compte ne correspond au nom d'utilisateur %{username}" complete_email_not_found: "Aucun compte ne correspond à %{email}" confirm_title: Continuer vers %{site_name} @@ -1837,34 +1834,34 @@ fr: security_key_not_allowed_error: "La procédure d'authentification de la clé de sécurité a expiré ou a été annulée." security_key_no_matching_credential_error: "Aucun identifiant correspondant n'a pu être trouvé dans la clé de sécurité donnée." security_key_support_missing_error: "Votre appareil ou navigateur actuel ne prend pas en charge l'utilisation des clés de sécurité. Veuillez utiliser une autre méthode." - email_placeholder: "Courriel ou nom d'utilisateur" + email_placeholder: "Adresse e-mail ou nom d'utilisateur" caps_lock_warning: "Majuscules verrouillées" error: "Erreur inconnue" cookies_error: "Les cookies de votre navigateur semblent désactivées. Vous ne pourrez pas vous connecter sans les activer." rate_limit: "Merci de patienter avant de vous reconnecter." - blank_username: "Veuillez saisir votre courriel ou votre nom d'utilisateur." - blank_username_or_password: "Veuillez saisir votre courriel ou votre nom d'utilisateur et votre mot de passe." + blank_username: "Veuillez saisir votre adresse e-mail ou votre nom d'utilisateur." + blank_username_or_password: "Veuillez saisir votre adresse e-mail ou votre nom d'utilisateur et votre mot de passe." reset_password: "Réinitialiser le mot de passe" logging_in: "Connexion en cours…" or: "ou" authenticating: "Authentification…" - awaiting_activation: "Votre compte est en attente d'activation, utilisez le lien de mot de passe oublié pour envoyer un autre courriel d'activation." - awaiting_approval: "Votre compte n'a pas encore été approuvé par un modérateur. Vous recevrez une confirmation par courriel lors de l'activation." + awaiting_activation: "Votre compte est en attente d'activation, utilisez le lien de mot de passe oublié pour envoyer un autre e-mail d'activation." + awaiting_approval: "Votre compte n'a pas encore été approuvé par un modérateur. Vous recevrez une confirmation par e-mail lors de l'activation." requires_invite: "Nous sommes désolés, l'accès à ce forum est réservé sur invitation seulement." - not_activated: "Vous ne pouvez pas encore vous connecter. Nous avons envoyé un courriel d'activation à %{sentTo}. Veuillez suivre les instructions qui y figurent afin d'activer votre compte." + not_activated: "Vous ne pouvez pas encore vous connecter. Nous avons envoyé un e-mail d'activation à %{sentTo}. Veuillez suivre les instructions qui y figurent afin d'activer votre compte." not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter à partir de cette adresse IP." admin_not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter en tant qu'administrateur à partir de cette adresse IP." - resend_activation_email: "Cliquez ici pour renvoyer le courriel d'activation." + resend_activation_email: "Cliquez ici pour renvoyer l'e-mail d'activation." omniauth_disallow_totp: "L'authentification à deux facteurs est activée sur votre compte. Veuillez vous connecter avec votre mot de passe." - resend_title: "Renvoyer le courriel d'activation" - change_email: "Changer l'adresse courriel" - provide_new_email: "Fournissez une nouvelle adresse et nous renverrons votre courriel de confirmation." - submit_new_email: "Mettre à jour l'adresse courriel" - sent_activation_email_again: "Nous venons d'envoyer un nouveau courriel d'activation à %{currentEmail}. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." - sent_activation_email_again_generic: "Nous avons envoyé un autre courriel d'activation. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." + resend_title: "Renvoyer l'e-mail d'activation" + change_email: "Changer l'adresse e-mail" + provide_new_email: "Fournissez une nouvelle adresse et nous renverrons votre e-mail de confirmation." + submit_new_email: "Mettre à jour l'adresse e-mail" + sent_activation_email_again: "Nous venons d'envoyer un nouveau e-mail d'activation à %{currentEmail}. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." + sent_activation_email_again_generic: "Nous avons envoyé un autre e-mail d'activation. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." to_continue: "Veuillez vous connecter" preferences: "Vous devez être connecté(e) pour modifier vos préférences utilisateur." - not_approved: "Votre compte n'a pas encore été approuvé. Vous recevrez une notification par courriel lorsque vous pourrez vous connecter." + not_approved: "Votre compte n'a pas encore été approuvé. Vous recevrez une notification par e-mail lorsque vous pourrez vous connecter." google_oauth2: name: "Google" title: "Se connecter avec Google" @@ -1899,7 +1896,7 @@ fr: welcome_to: "Bienvenue sur %{site_name} !" invited_by: "Vous avez été invité(e) par :" social_login_available: "Vous pourrez aussi vous connecter avec un compte de réseau social utilisant cette adresse." - your_email: "L'adresse courriel de votre compte est %{email}." + your_email: "L'adresse e-mail de votre compte est %{email}." accept_invite: "Accepter l'invitation" success: "Votre compte a été créé et vous êtes maintenant connecté(e)." name_label: "Nom" @@ -2017,7 +2014,7 @@ fr: cannot_see_mention: category: "Vous avez mentionné @%{username}, mais aucune notification ne lui sera envoyée, car la catégorie ne lui est pas accessible. Vous devez l'ajouter à un groupe ayant accès à cette catégorie." private: "Vous avez mentionné @%{username}, mais aucune notification ne lui sera envoyée, car ce message direct ne lui est pas visible. Vous devez l'inviter à rejoindre ce message direct." - muted_topic: "Vous avez mentionné @%{username}, mais aucune notification ne lui sera envoyée, car il ou elle a mis ce sujet en sourdine." + muted_topic: "Vous avez mentionné @%{username}, mais aucune notification ne lui sera envoyée, car il ou elle a désactivé ce sujet." not_allowed: "Vous avez mentionné @%{username}, mais aucune notification ne lui sera envoyée, car il ou elle n'a pas été invité(e) à rejoindre ce sujet." here_mention: one: "En mentionnant @%{here}, vous êtes sur le point de notifier %{count} utilisateur. Voulez-vous continuer ?" @@ -2669,7 +2666,7 @@ fr: notifications: title: modifier la fréquence des notifications concernant ce sujet reasons: - mailing_list_mode: "Vous avez activé la liste de diffusion, vous serez donc informé(e) des réponses à ce sujet par courriel." + mailing_list_mode: "Vous avez activé la liste de diffusion, vous serez donc informé(e) des réponses à ce sujet par e-mail." "3_10": "Vous recevrez des notifications car vous surveillez une étiquette de ce sujet." "3_10_stale": "Vous recevrez des notifications car vous surveilliez déjà une étiquette attribuée à ce sujet." "3_6": "Vous recevrez des notifications car vous surveillez cette catégorie." @@ -2791,8 +2788,8 @@ fr: automatically_add_to_groups: "Cette invitation inclut également l'accès aux groupes suivants :" invite_private: title: "Inviter dans la discussion" - email_or_username: "Adresse courriel ou nom d'utilisateur de l'invité(e)" - email_or_username_placeholder: "adresse courriel ou nom d'utilisateur" + email_or_username: "Adresse e-mail ou nom d'utilisateur de l'invité(e)" + email_or_username_placeholder: "adresse e-mail ou nom d'utilisateur" action: "Inviter" success: "Nous avons invité cet utilisateur à participer à cette discussion." success_group: "Nous avons invité ce groupe à participer à cette discussion." @@ -2804,18 +2801,18 @@ fr: title: "Inviter" username_placeholder: "nom d'utilisateur" action: "Envoyer une invitation" - help: "inviter d'autres personnes sur ce sujet par courriel ou notifications" - to_forum: "Votre ami(e) recevra un courriel qui lui permettra de participer avec un simple lien." + help: "inviter d'autres personnes sur ce sujet par e-mail ou notifications" + to_forum: "Votre ami(e) recevra un e-mail qui lui permettra de participer avec un simple lien." discourse_connect_enabled: "Saisissez le nom d'utilisateur de la personne que vous souhaitez inviter à participer." - to_topic_blank: "Entrez le nom d'utilisateur ou l'adresse courriel de la personne que vous souhaitez inviter sur ce sujet." - to_topic_email: "Vous avez saisi une adresse courriel. Nous allons envoyer une invitation à votre ami(e) pour lui permettre de répondre immédiatement à ce sujet." + to_topic_blank: "Entrez le nom d'utilisateur ou l'adresse e-mail de la personne que vous souhaitez inviter sur ce sujet." + to_topic_email: "Vous avez saisi une adresse e-mail. Nous allons envoyer une invitation à votre ami(e) pour lui permettre de répondre immédiatement à ce sujet." to_topic_username: "Vous avez saisi un nom d'utilisateur. Nous allons lui envoyer une notification avec un lien d'invitation dans ce sujet." to_username: "Saisissez le nom d'utilisateur de la personne que vous souhaitez inviter. Nous enverrons une notification avec un lien d'invitation dans ce sujet." email_placeholder: "nom@exemple.com" success_email: "Une invitation a été envoyée à %{invitee}. Vous recevrez une notification si cette invitation est acceptée. Pour faire le suivi de vos invitations, rendez-vous dans l'onglet « invitations » de votre page de profil." success_username: "Nous avons invité cet utilisateur à participer à ce sujet." error: "Nous sommes désolés, nous n'avons pas pu inviter cette personne. Peut-être a-t-elle déjà été invitée ? (Le nombre d'invitations est limité)" - success_existing_email: "Un utilisateur avec le courriel %{emailOrUsername} existe déjà. Nous avons invité cet utilisateur à participer à ce sujet." + success_existing_email: "Un utilisateur avec l'e-mail %{emailOrUsername} existe déjà. Nous avons invité cet utilisateur à participer à ce sujet." login_reply: "Se connecter pour répondre" filters: n_posts: @@ -3001,8 +2998,8 @@ fr: discard: "Abandonner" save_draft: "Enregistrer le brouillon pour plus tard" keep_editing: "Continuer à rédiger" - via_email: "ce message est arrivé par courriel" - via_auto_generated_email: "ce message est arrivé via un courriel généré automatiquement" + via_email: "ce message est arrivé par e-mail" + via_auto_generated_email: "ce message est arrivé via un e-mail généré automatiquement" whisper: "ce message est un murmure privé pour les modérateurs" wiki: about: "ce message est un wiki" @@ -3115,13 +3112,13 @@ fr: raw_email: displays: raw: - title: "Afficher le courriel brut" + title: "Afficher l'e-mail brut" button: "Brut" text_part: - title: "Afficher le contenu texte du courriel" + title: "Afficher le contenu texte de l'e-mail" button: "Texte" html_part: - title: "Afficher le contenu HTML du courriel" + title: "Afficher le contenu HTML de l'e-mail" button: "HTML" bookmarks: create: "Ajouter un signet" @@ -3226,9 +3223,9 @@ fr: uncategorized_general_warning: 'Cette catégorie est spéciale. Elle sert de catégorie par défaut pour les nouveaux sujets qui ne sont pas liés à une catégorie. Si vous souhaitez modifier ce comportement et forcer la sélection de catégorie, veuillez désactiver ce paramètre. Si vous voulez modifier son nom ou sa description, allez dans Personnaliser/contenu.' pending_permission_change_alert: "Vous n'avez pas ajouté %{group} à cette catégorie ; cliquez sur ce bouton pour l'ajouter." images: "Images" - email_in: "Adresse courriel entrant personnalisée :" - email_in_allow_strangers: "Accepter les courriels d'utilisateurs anonymes sans compte" - email_in_disabled: "La création de nouveaux sujets par courriel est désactivée dans les paramètres. Pour activer la création de nouveaux sujets par courriel, " + email_in: "Adresse e-mail entrant personnalisée :" + email_in_allow_strangers: "Accepter les e-mails d'utilisateurs anonymes sans compte" + email_in_disabled: "La création de nouveaux sujets par e-mail est désactivée dans les paramètres. Pour activer la création de nouveaux sujets par e-mail, " email_in_disabled_click: 'activez le paramètre « e-mail entrant ».' mailinglist_mirror: "La catégorie reflète une liste de diffusion" show_subcategory_list: "Afficher la liste des sous-catégories au-dessus des sujets dans cette catégorie." @@ -3305,7 +3302,7 @@ fr: general: "Général" moderation: "Modération" appearance: "Apparence" - email: "Courriel" + email: "E-mail" list_filters: all: "tous les sujets" none: "aucune sous-catégorie" @@ -3328,7 +3325,7 @@ fr: official_warning: "Avertissement officiel" delete_spammer: "Supprimer le spammeur" flag_for_review: "Mettre en file d'attente pour examen" - delete_confirm_MF: "Vous êtes sur le point de supprimer {POSTS, plural, one {# message} other {# messages}} et {TOPICS, plural, one {# sujet} other {# sujets}} de cet utilisateur, supprimer son compte, bloquer les inscriptions depuis son adresse IP {ip_address} et ajouter son adresse courriel {email} à une liste de blocage permanent. Êtes-vous sûr(e) que cet utilisateur est un spammeur ?" + delete_confirm_MF: "Vous êtes sur le point de supprimer {POSTS, plural, one {# message} other {# messages}} et {TOPICS, plural, one {# sujet} other {# sujets}} de cet utilisateur, supprimer son compte, bloquer les inscriptions depuis son adresse IP {ip_address} et ajouter son adresse e-mail {email} à une liste de blocage permanent. Êtes-vous sûr(e) que cet utilisateur est un spammeur ?" yes_delete_spammer: "Oui, supprimer le spammeur" ip_address_missing: "(S.O.)" hidden_email_address: "(masqué)" @@ -3432,7 +3429,7 @@ fr: history: "Historique" changed_by: "par %{author}" raw_email: - title: "Courriel entrant" + title: "E-mail entrant" not_available: "Indisponible !" categories_list: "Liste des catégories" filters: @@ -3914,7 +3911,7 @@ fr: pending_count: "%{count} en attente" welcome_topic_banner: title: "Créez votre sujet de bienvenue" - description: 'Votre sujet de bienvenue est la première chose que les nouveaux membres liront. Considérez-le comme votre « argumentaire » ou votre « énoncé de mission ». Faites savoir à tout le monde à qui est destinée cette communauté, ce qu''ils peuvent s''attendre à y trouver et ce que vous aimeriez qu''ils fassent en premier.' + description: "Votre sujet de bienvenue est la première chose que les nouveaux membres liront. Considérez-le comme votre « argumentaire » ou votre « énoncé de mission ». Faites savoir à tout le monde à qui est destinée cette communauté, ce qu'ils peuvent s'attendre à y trouver et ce que vous aimeriez qu'ils fassent en premier." button_title: "Commencer l'édition" until: "Jusqu'à :" admin_js: @@ -4036,9 +4033,9 @@ fr: blank: "Le nom du groupe ne peut pas être vide" manage: interaction: - email: Courriel - incoming_email: "Adresse courriel entrante personnalisée" - incoming_email_placeholder: "saisissez une adresse courriel" + email: E-mail + incoming_email: "Adresse e-mail entrante personnalisée" + incoming_email_placeholder: "saisissez une adresse e-mail" visibility: Visibilité visibility_levels: title: "Qui peut voir ce groupe ?" @@ -4057,10 +4054,10 @@ fr: trust_levels_title: "Niveau de confiance automatiquement attribué lorsque les membres sont ajoutés :" effects: Effets trust_levels_none: "Aucun" - automatic_membership_email_domains: "Les utilisateurs qui s'enregistrent avec un domaine courriel qui correspond exactement à un élément de cette liste seront automatiquement ajoutés à ce groupe :" + automatic_membership_email_domains: "Les utilisateurs qui s'enregistrent avec un domaine de messagerie qui correspond exactement à un élément de cette liste seront automatiquement ajoutés à ce groupe :" automatic_membership_user_count: - one: "%{count} utilisateur a les nouveaux domaines de courriel et sera ajouté au groupe." - other: "%{count} utilisateurs ont les nouveaux domaines de courriel et seront ajoutés au groupe." + one: "%{count} utilisateur a les nouveaux domaines de messagerie et sera ajouté au groupe." + other: "%{count} utilisateurs ont les nouveaux domaines de messagerie et seront ajoutés au groupe." automatic_membership_associated_groups: "Les utilisateurs qui sont membres d'un groupe d'un fournisseur d'identité repris ici seront automatiquement ajoutés à ce groupe lorsqu'ils s'authentifieront avec ce fournisseur." primary_group: "Définir comme groupe principal automatiquement" name_placeholder: "Nom du groupe (sans espaces, mêmes règles que pour les noms d'utilisateurs)" @@ -4154,14 +4151,14 @@ fr: bookmarks: Lister les signets des utilisateurs. Les rappels de signet sont retournés lorsque le format ICS est utilisé. sync_sso: Synchroniser un utilisateur en utilisant DiscourseConnect. show: Obtenir des informations sur un utilisateur. - check_emails: Lister les courriels d'utilisateurs. + check_emails: Lister les e-mails d'utilisateurs. update: Mettre à jour les informations du profil utilisateur. log_out: Déconnecter toutes les sessions pour un utilisateur. anonymize: Anonymiser les comptes utilisateurs. delete: Supprimer les comptes utilisateurs. list: Obtenir une liste d'utilisateurs. email: - receive_emails: Combiner ces permissions au service de réception de courriel pour traiter les courriels entrants. + receive_emails: Combiner ces permissions au service de réception d'e-mail pour traiter les e-mails entrants. badges: create: Créer un nouveau badge. show: Obtenir des informations sur un badge. @@ -4189,7 +4186,6 @@ fr: go_back: "Retour à la liste" payload_url: "URL cible" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Il semble que vous essayiez de configurer le Webhook avec une URL locale. Les événements transmis à une adresse locale peuvent déclencher des effets de bord ou un fonctionnement indésirable. Souhaitez-vous continuer ?" secret_invalid: "La clé secrète ne doit pas contenir d'espaces." secret_too_short: "La clé secrète doit contenir au moins 12 caractères." secret_placeholder: "Une chaîne de caractères facultative pour générer la signature" @@ -4217,7 +4213,7 @@ fr: details: "Lorsqu'une réponse est publiée, modifiée, supprimée ou restaurée." user_event: name: "Événement d'utilisateur" - details: "Lorsqu'un utilisateur se connecte, se déconnecte, confirme son adresse de courriel, est créé, approuvé ou quand ses informations sont mises à jour." + details: "Lorsqu'un utilisateur se connecte, se déconnecte, confirme son adresse e-mail, est créé, approuvé ou quand ses informations sont mises à jour." group_event: name: "Événement de groupe" details: "Lorsqu'un groupe est créé, mis à jour ou supprimé." @@ -4328,8 +4324,8 @@ fr: without_uploads: "Oui (ne pas inclure les fichiers envoyés)" download: label: "Télécharger" - title: "Envoyer un courriel avec un lien de téléchargement" - alert: "Un lien pour télécharger la sauvegarde vous a été envoyé par courriel." + title: "Envoyer un e-mail avec un lien de téléchargement" + alert: "Un lien pour télécharger la sauvegarde vous a été envoyé par e-mail." destroy: title: "Supprimer la sauvegarde" confirm: "Voulez-vous vraiment supprimer cette sauvegarde ?" @@ -4353,7 +4349,7 @@ fr: button_title: user: "Exporter la liste des utilisateurs dans un fichier CSV." staff_action: "Exporter la liste des actions des responsables dans un fichier CSV." - screened_email: "Exporter la liste des adresses courriel sous surveillance dans un fichier CSV." + screened_email: "Exporter la liste des adresses e-mail sous surveillance dans un fichier CSV." screened_ip: "Exporter la liste des adresses IP sous surveillance dans un fichier CSV." screened_url: "Exporter toutes les URL sous surveillance vers un fichier CSV" export_json: @@ -4380,9 +4376,9 @@ fr: copy_to_clipboard_error: "Erreur en copiant les données dans le presse-papier" theme_owner: "Non modifiable, appartient à :" email_templates: - title: "Courriel" + title: "E-mail" subject: "Objet" - multiple_subjects: "Ce modèle de courriel a plusieurs objets." + multiple_subjects: "Ce modèle d'e-mail a plusieurs objets." body: "Corps" revert: "Annuler les changements" revert_confirm: "Voulez-vous vraiment annuler vos changements ?" @@ -4478,7 +4474,6 @@ fr: last_attempt: "Le processus d'installation ne s'est pas terminé, dernière tentative :" remote_branch: "Nom de branche (optionnel)" public_key: "Accorder un accès au dépôt à la clé publique suivante :" - public_key_note: "Une clé SSH sera générée et affichée ici après qu'une URL valide pointant vers un dépôt logiciel privé ait été saisie ci-dessus." install: "Installer" installed: "Installé" install_popular: "Populaire" @@ -4619,29 +4614,29 @@ fr: warning: "Cela remplacera définitivement tous les paramètres associés." overridden: Le fichier robots.txt par défaut de votre site est remplacé. email_style: - title: "Style du courriel" - heading: "Personnaliser le style du courriel" + title: "Style de l'e-mail" + heading: "Personnaliser le style de l'e-mail" html: "Modèle HTML" css: "CSS" reset: "Rétablir les valeurs par défaut" reset_confirm: "Voulez-vous vraiment rétablir la valeur par défaut de %{fieldName} et perdre toutes vos modifications ?" save_error_with_reason: "Vos modifications n'ont pas pu être sauvegardées. %{error}" - instructions: "Personnalisez le modèle depuis lequel tous les courriels HTML sont générés et stylés à l'aide de CSS." + instructions: "Personnalisez le modèle depuis lequel tous les e-mails HTML sont générés et stylés à l'aide de CSS." email: - title: "Courriels" + title: "E-mails" settings: "Paramètres" templates: "Modèles" - preview_digest: "Prévisualisation du courriel" + preview_digest: "Prévisualisation de l'e-mail" advanced_test: title: "Test avancé" - desc: "Vérifiez comment Discourse gère les courriels reçus. Pour que le courriel soit géré correctement, veuillez coller ci-dessous la totalité du courriel original." + desc: "Vérifiez comment Discourse gère les e-mails reçus. Pour que l'e-mail soit géré correctement, veuillez coller ci-dessous la totalité de l'e-mail original." email: "Message d'origine" run: "Lancer le test" text: "Corps de texte sélectionné" elided: "Texte élidé" - sending_test: "Envoi du courriel de test…" + sending_test: "Envoi de l'e-mail de test…" error: "ERREUR - %{server_error}" - test_error: "Un problème est survenu lors de l'envoi du courriel de test. Veuillez vérifier vos paramètres et que votre hébergeur ne bloque pas les connexions aux courriels, puis réessayez." + test_error: "Un problème est survenu lors de l'envoi de l'e-mail de test. Veuillez vérifier vos paramètres et que votre hébergeur ne bloque pas les connexions aux e-mails, puis réessayez." sent: "Envoyés" skipped: "Ignorés" bounced: "Non délivrés" @@ -4650,22 +4645,22 @@ fr: sent_at: "Envoyé à" time: "Heure" user: "Utilisateur" - email_type: "Type de courriel" + email_type: "Type d'e-mail" details_title: "Afficher les détails de l'e-mail" to_address: "À l'adresse" - test_email_address: "adresse courriel à tester" - send_test: "Envoyer un courriel de test" + test_email_address: "adresse e-mail à tester" + send_test: "Envoyer un e-mail de test" sent_test: "envoyé !" delivery_method: "Méthode d'envoi" - preview_digest_desc: "Prévisualiser le contenu des résumés par courriel envoyés aux utilisateurs inactifs." + preview_digest_desc: "Prévisualiser le contenu des résumés par e-mail envoyés aux utilisateurs inactifs." refresh: "Actualiser" send_digest_label: "Envoyer ce résultat à :" send_digest: "Envoyer" - sending_email: "Courriel en cours d'envoi…" + sending_email: "E-mail en cours d'envoi…" format: "Format" html: "html" text: "texte" - html_preview: "Aperçu du contenu du courriel" + html_preview: "Aperçu du contenu de l'e-mail" last_seen_user: "Dernier utilisateur vu :" no_result: "Aucun résultat trouvé pour le résumé." reply_key: "Clé de réponse" @@ -4677,14 +4672,14 @@ fr: cc_addresses: "Cc" subject: "Objet" error: "Erreur" - none: "Aucun courriel reçu." + none: "Aucun e-mail reçu." modal: - title: "Détails du courriel entrant" + title: "Détails de l'e-mail entrant" error: "Erreur" headers: "En-têtes" subject: "Objet" body: "Corps" - rejection_message: "Courriel de refus" + rejection_message: "E-mail de refus" filters: from_placeholder: "from@example.com" to_placeholder: "to@example.com" @@ -4757,7 +4752,7 @@ fr: removed_unsuspend_user: "annuler la suspension de l'utilisateur (supprimé)" grant_badge: "attribuer le badge" revoke_badge: "retirer le badge" - check_email: "vérifier l'adresse courriel" + check_email: "vérifier l'adresse e-mail" delete_topic: "supprimer le sujet" recover_topic: "annuler la suppression du sujet" delete_post: "supprimer le message" @@ -4779,7 +4774,7 @@ fr: deleted_tag: "étiquette supprimée" deleted_unused_tags: "étiquettes inutilisées supprimées" renamed_tag: "étiquette renommée" - revoke_email: "révoquer le courriel" + revoke_email: "révoquer l'e-mail" lock_trust_level: "verrouiller le niveau de confiance" unlock_trust_level: "déverrouiller le niveau de confiance" activate_user: "activer l'utilisateur" @@ -4823,9 +4818,9 @@ fr: override_upload_secure_status: "passer outre le statut de téléchargement sécurisé" page_published: "page publiée" page_unpublished: "publication de la page annulée" - add_email: "ajouter une adresse courriel" - update_email: "mettre à jour une adresse courriel" - destroy_email: "supprimer une adresse courriel" + add_email: "ajouter une adresse e-mail" + update_email: "mettre à jour une adresse e-mail" + destroy_email: "supprimer une adresse e-mail" topic_closed: "sujet fermé" topic_opened: "sujet ouvert" topic_archived: "sujet archivé" @@ -4836,9 +4831,9 @@ fr: watched_word_create: "ajouter un mot surveillé" watched_word_destroy: "supprimer le mot surveillé" screened_emails: - title: "Courriels sous surveillance" - description: "Lorsque quelqu'un essaye de créer un nouveau compte, les adresses courriel suivantes seront vérifiées et l'inscription sera bloquée, ou une autre action sera réalisée." - email: "Adresse courriel" + title: "E-mails filtrés" + description: "Lorsque quelqu'un essaye de créer un nouveau compte, les adresses e-mail suivantes seront vérifiées et l'inscription sera bloquée, ou une autre action sera réalisée." + email: "Adresse e-mail" actions: allow: "Autoriser" screened_urls: @@ -4936,12 +4931,12 @@ fr: users: title: "Utilisateurs" create: "Ajouter un administrateur" - last_emailed: "Dernier courriel reçu" + last_emailed: "Dernier e-mail reçu" not_found: "Nous sommes désolés, ce nom d'utilisateur n'existe pas dans notre système." id_not_found: "Désolé cet identifiant d'utilisateur n'existe pas dans notre système." active: "Activé" - show_emails: "Afficher les adresses courriel" - hide_emails: "Masquer les adresses courriel" + show_emails: "Afficher les adresses e-mail" + hide_emails: "Masquer les adresses e-mail" nav: new: "Nouveau" active: "Actifs" @@ -4967,7 +4962,7 @@ fr: staged: "Utilisateurs distants" not_verified: "Non vérifié" check_email: - title: "Afficher l'adresse courriel de cet utilisateur" + title: "Afficher l'adresse e-mail de cet utilisateur" text: "Afficher" check_sso: title: "Révéler la charge utile SSO" @@ -4987,8 +4982,8 @@ fr: in_wrong_place: "Au mauvais endroit" no_constructive_purpose: "Aucun but constructif à ses actions autre que de créer une dissidence au sein de la communauté" custom: "Personnalisé…" - suspend_message: "Message courriel" - suspend_message_placeholder: "Si besoin, donnez plus d'informations au sujet de cette suspension qui seront envoyées à l'utilisateur par courriel." + suspend_message: "E-mail" + suspend_message_placeholder: "Si besoin, donnez plus d'informations au sujet de cette suspension qui seront envoyées à l'utilisateur par e-mail." suspended_by: "Suspendu par" silence_reason: "Raison" silenced_by: "Mis en sourdine par" @@ -4996,7 +4991,7 @@ fr: silence_duration: "Combien de temps l'utilisateur sera-t-il mis en sourdine ?" silence_reason_label: "Pourquoi mettez-vous cet utilisateur en sourdine ?" silence_reason_placeholder: "Raison de la mise en sourdine" - silence_message: "Message courriel" + silence_message: "E-mail" silence_message_placeholder: "(laissez vide pour envoyer un message par défaut)" suspended_until: "(jusqu'à %{until})" cant_suspend: "Cet utilisateur ne peut pas être suspendu." @@ -5032,7 +5027,7 @@ fr: revoke_admin: "Révoquer les droits d'administration" grant_admin: "Accorder les droits d'administration" grant_admin_success: "Le nouvel administrateur a été confirmé." - grant_admin_confirm: "Nous vous avons envoyé un courriel pour vérifier le nouvel administrateur. Veuillez le lire et suivre les instructions." + grant_admin_confirm: "Nous vous avons envoyé un e-mail pour vérifier le nouvel administrateur. Veuillez le lire et suivre les instructions." revoke_moderation: "Révoquer les droits de modération" grant_moderation: "Accorder les droits de modération" unsuspend: "Annuler la suspension" @@ -5058,12 +5053,12 @@ fr: flags_given_received_count: "Signalements effectués/reçus" approve: "Approuver" approved_by: "approuvé par" - approve_success: "Utilisateur approuvé et un courriel avec les instructions d'activation a été envoyé." + approve_success: "Utilisateur approuvé et un e-mail avec les instructions d'activation a été envoyé." approve_bulk_success: "Bravo ! Tous les utilisateurs sélectionnés ont été approuvés et notifiés." time_read: "Temps de lecture" post_edits_count: "Modifications de message" anonymize: "Rendre l'utilisateur anonyme" - anonymize_confirm: "Voulez-vous vraiment rendre ce compte anonyme ? Cela entraînera la modification du nom d'utilisateur et de l'adresse courriel, et réinitialisera les informations du profil." + anonymize_confirm: "Voulez-vous vraiment rendre ce compte anonyme ? Cela entraînera la modification du nom d'utilisateur et de l'adresse e-mail, et réinitialisera les informations du profil." anonymize_yes: "Oui, rendre ce compte anonyme" anonymize_failed: "Un problème est survenu lors de l'anonymisation du compte." delete: "Supprimer l'utilisateur" @@ -5122,13 +5117,13 @@ fr: other: "Impossible de supprimer tous les messages parce que l'utilisateur a plus de %{count} messages. (delete_all_posts_max)" delete_confirm_title: "Voulez-vous VRAIMENT supprimer cet utilisateur ? Cette action est définitive !" delete_confirm: "Il est généralement préférable d'anonymiser les utilisateurs plutôt que de les supprimer, afin d'éviter de supprimer du contenu dans les discussions existantes." - delete_and_block: "Supprimer et bloquer cette adresse courriel et adresse IP." + delete_and_block: "Supprimer et bloquer cette adresse e-mail et adresse IP." delete_dont_block: "Supprimer uniquement" deleting_user: "Suppression de l'utilisateur…" deleted: "L'utilisateur a été supprimé." delete_failed: "Une erreur s'est produite lors de la suppression de cet utilisateur. Assurez-vous que tous les messages sont supprimés avant d'essayer de supprimer l'utilisateur." - send_activation_email: "Envoyer le courriel d'activation" - activation_email_sent: "Un courriel d'activation a été envoyé." + send_activation_email: "Envoyer l'e-mail d'activation" + activation_email_sent: "Un e-mail d'activation a été envoyé." send_activation_email_failed: "Un problème est survenu lors de l'envoi d'un autre e-mail d'activation. %{error}" activate: "Activer le compte" activate_failed: "Un problème est survenu lors de l'activation de l'utilisateur." @@ -5138,26 +5133,25 @@ fr: silence_failed: "Un problème est survenu lors de la mise en sourdine de l'utilisateur." silence_confirm: "Voulez-vous vraiment mettre cet utilisateur(rice) en sourdine ? Il ou elle ne pourra plus créer de nouveaux sujets ou messages." silence_accept: "Oui, mettre cet utilisateur en sourdine" - bounce_score: "Taux de courriels non délivrés" + bounce_score: "Taux d'e-mails non délivrés" reset_bounce_score: label: "Réinitialiser" - title: "Réinitialiser le taux de courriels non délivrés à 0" + title: "Réinitialiser le taux d'e-mails non délivrés à 0" visit_profile: "Visiter la page de préférence de cet utilisateur pour modifier son profil" - deactivate_explanation: "Un utilisateur désactivé doit revalider son adresse courriel." + deactivate_explanation: "Un utilisateur désactivé doit revalider son adresse e-mail." suspended_explanation: "Un utilisateur suspendu ne peut pas se connecter." silence_explanation: "Un utilisateur mis en sourdine ne peut pas créer des messages ou sujets." - staged_explanation: "Un utilisateur distant ne peut envoyer des messages que par courriel et pour des sujets spécifiques." + staged_explanation: "Un utilisateur distant ne peut envoyer des messages que par e-mail et pour des sujets spécifiques." bounce_score_explanation: - none: "Tous les courriels envoyés à cette adresse ont été délivrés." - some: "Quelques courriels envoyés à cette adresse n'ont pas été délivrés." - threshold_reached: "Trop de courriels envoyés à cette adresse n'ont pas été délivrés." + none: "Tous les e-mails envoyés à cette adresse ont été délivrés." + some: "Quelques e-mails envoyés à cette adresse n'ont pas été délivrés." + threshold_reached: "Trop d'e-mails envoyés à cette adresse n'ont pas été délivrés." trust_level_change_failed: "Il y a eu un problème lors de la modification du niveau de confiance de l'utilisateur." suspend_modal_title: "Suspendre l'utilisateur" confirm_cancel_penalty: "Voulez-vous vraiment annuler la pénalité ?" trust_level_2_users: "Utilisateurs de niveau de confiance 2" trust_level_3_requirements: "Prérequis du niveau de confiance 3" trust_level_locked_tip: "le niveau de confiance est verrouillé, le système ne changera plus le niveau de confiance de cet utilisateur" - trust_level_unlocked_tip: "le niveau de confiance est déverrouillé, le système pourra changer le niveau de confiance de cet utilisateur" lock_trust_level: "Verrouiller le niveau de confiance" unlock_trust_level: "Déverrouiller le niveau de confiance" silenced_count: "En sourdine" @@ -5197,7 +5191,7 @@ fr: external_id: "ID externe" external_username: "Nom d'utilisateur" external_name: "Nom" - external_email: "Courriel" + external_email: "E-mail" external_avatar_url: "URL de l'image de profil" last_payload: "Dernière charge utile" delete_sso_record: "Supprimer l'enregistrement SSO" @@ -5217,23 +5211,18 @@ fr: delete_confirm: "Voulez-vous vraiment supprimer ce champ utilisateur ?" options: "Options" required: - title: "Obligatoire à l'inscription ?" enabled: "obligatoire" disabled: "facultatif" editable: - title: "Modifiable après l'inscription ?" enabled: "modifiable" disabled: "non modifiable" show_on_profile: - title: "Afficher sur le profil public ?" enabled: "affiché dans le profil" disabled: "non affiché dans le profil" show_on_user_card: - title: "Afficher sur la carte de l'utilisateur ?" enabled: "affiché sur la carte de l'utilisateur" disabled: "non affiché sur la carte de l'utilisateur" searchable: - title: "Indexable ?" enabled: "indexable" disabled: "non indexable" field_types: @@ -5285,7 +5274,7 @@ fr: basic: "Général" users: "Utilisateurs" posting: "Contribution" - email: "Courriel" + email: "E-mail" files: "Fichiers" trust: "Niveaux de confiance" security: "Sécurité" @@ -5401,8 +5390,8 @@ fr: description: Attribuez le même badge à plusieurs utilisateurs à la fois. no_badge_selected: Veuillez sélectionner un badge pour commencer. perform: "Attribuer ce badge à ces utilisateurs" - upload_csv: Envoyez un fichier CSV contenant les adresses courriel ou les noms d'utilisateur - aborted: Envoyez d'abord un fichier CSV contenant des adresses de courriel ou des noms d'utilisateur + upload_csv: Envoyez un fichier CSV contenant les adresses e-mail ou les noms d'utilisateur + aborted: Envoyez d'abord un fichier CSV contenant des adresses e-mail ou des noms d'utilisateur success: Votre fichier CSV a été envoyé et %{count} utilisateurs obtiendront bientôt leurs badges. csv_has_unmatched_users: "Les entrées suivantes se trouvent dans le fichier CSV, mais elles n'ont pas pu être associées à des utilisateurs existants et ne recevront donc pas le badge :" csv_has_unmatched_users_truncated_list: "Il y avait %{count} entrées dans le fichier CSV qui n'ont pas pu être associées à des utilisateurs existants et qui ne recevront donc pas le badge. En raison du grand nombre d'entrées sans correspondance, seules les 100 premières sont affichées :" diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index 71b7a52499..b1d9564324 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -992,7 +992,6 @@ gl: experimental_sidebar: options: "Opcións" navigation_section: "Navegación" - list_destination_default: "Predeterminado" change: "cambiar" featured_topic: "Tema de actualidade" moderator: "%{user} é moderador" @@ -4548,7 +4547,6 @@ gl: trust_level_2_users: "Usuarios con nivel 2 de confianza" trust_level_3_requirements: "Requirimentos para o nivel de confianza 3" trust_level_locked_tip: "o nivel de confianza está bloqueado, o sistema non promocionará nin rebaixará o usuario" - trust_level_unlocked_tip: "o nivel de confianza está desbloqueado, o sistema pode promocionar ou rebaixar un usuario" lock_trust_level: "Bloquear o nivel de confianza" unlock_trust_level: "Desbloquear o nivel de confianza" silenced_count: "Silenciado" @@ -4607,19 +4605,19 @@ gl: delete_confirm: "Confirma a eliminación deste campo do usuario?" options: "Opcións" required: - title: "Requirido para rexistrarse?" + title: "Requirido para rexistrarse" enabled: "obrigatorio" disabled: "non obrigatorio" editable: - title: "Editábel despois do rexistro?" + title: "Editábel despois do rexistro" enabled: "editábel" disabled: "non editábel" show_on_profile: - title: "Amosar no perfil público?" + title: "Amosar no perfil público" enabled: "amosado no perfil" disabled: "non amosado no perfil" show_on_user_card: - title: "Amosar na tarxeta de usuario?" + title: "Amosar na tarxeta de usuario" enabled: "amosado na tarxeta de usuario" disabled: "non amosado na tarxeta de usuario" field_types: diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index c151011828..8d2fe3c623 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -290,6 +290,7 @@ he: delete: "מחיקה" generic_error: "ארעה שגיאה, עמך הסליחה." generic_error_with_reason: "ארעה שגיאה: %{error}" + multiple_errors: "אירעו מספר שגיאות: %{errors}" sign_up: "הרשמה" log_in: "כניסה" age: "גיל" @@ -1088,7 +1089,7 @@ he: user_fields: none: "(יש לבחור אפשרות)" required: 'נא למלא ערך עבור „%{name}”' - same_as_password: 'אין לחזור על הסיסמה שלך בשדות אחרים.' + same_as_password: "אין לחזור על הסיסמה שלך בשדות אחרים." user: said: "%{username}:" profile: "פרופיל" @@ -1188,6 +1189,8 @@ he: no_notifications_body: > בלוח הזה תופענה התראות על פעילות שקשורה ישירות אליך, כולל תגובות לנושאים ולפוסטים שלך, כש@מאזכרים או מצטטים אותך או מגיבים לנושאים ברשימת המעקב שלך. כמו כן, התראות תישלחנה לכתובת הדוא״ל שלך כשלא נכנסת למערכת למשך זמן מה.

    יש לחפש אחר %{icon} כדי להחליט עבור אילו נושאים, קטגוריות ותגיות ברצונך לקבל התראות. למידע נוסף, ניתן לגשת אל העדפות ההתראות שלך. no_other_notifications_title: "אין לך התראות נוספות עדיין" + no_other_notifications_body: > + בחלונית זו יופיע התראות על סוגים שונים של פעילויות שיכולות להתאים לך - למשל, כשמישהו מקשר או עורך או אחד הפוסטים שלך. no_notifications_page_title: "אין לך התראות עדיין" no_notifications_page_body: > תישלחנה אליך התראות על פעילות שקשורה ישירות אליך, כולל תגובות לנושאים ולפוסטים שלך, כש@מאזכרים או מצטטים אותך או מגיבים לנושאים ברשימת המעקב שלך. כמו כן, התראות תישלחנה לכתובת הדוא״ל שלך כשלא נכנסת למערכת למשך זמן מה.

    יש לחפש אחר %{icon} כדי להחליט עבור אילו נושאים, קטגוריות ותגיות ברצונך לקבל התראות. למידע נוסף, ניתן לגשת אל העדפות ההתראות שלך. @@ -1225,9 +1228,9 @@ he: tags_section: "סעיף תגיות" tags_section_instruction: "התגיות הנבחרות תוצגנה תחת סעיף התגיות של סרגל הצד." navigation_section: "ניווט" - list_destination_instruction: "לחיצה על קישור רשימת נושאים עם נושאים חדשים או כאלו שלא נקראו בסרגל הצד, לקחת אותי אל" - list_destination_default: "ברירת מחדל" - list_destination_unread_new: "חדש/לא נקרא" + list_destination_instruction: "כשיש תוכן חדש בסרגל הצד…" + list_destination_default: "להשתמש בקישור ברירת המחדל ולהציג עיטור לפריטים חדשים" + list_destination_unread_new: "קישור לכאלו שלא נקראו/חדשים ולהציג את מניין הפריטים החדשים" change: "שנה" featured_topic: "נושא מומלץ" moderator: "ל־%{user} יש תפקיד פיקוח" @@ -3749,6 +3752,8 @@ he: many: "לא-נקראו %{count} " other: "לא-נקראו %{count} " unseen: + title: "לא נראו" + lower_title: "לא נראו" help: "נושאים חדשים ונושאים שאתם כרגע צופים או עוקבים אחריהם עם פוסטים שלא נקראו" new: lower_title_with_count: @@ -4238,7 +4243,7 @@ he: pending_count: "%{count} ממתינים" welcome_topic_banner: title: "כאן ניתן ליצור את נושא קבלת הפנים שלך" - description: 'נושא קבלת הפנים שלך הוא הדבר הראשון שחברים חדשים יקראו. אפשר לחשוב על זה כמו על „נאום מעלית” או „הצהרת כוונות”. כדאי לתאר למי מיועדת הקהילה הזאת, מה אפשר לצפות למצוא כאן ומה מומלץ לעשות בהתחלה.' + description: "נושא קבלת הפנים שלך הוא הדבר הראשון שחברים חדשים יקראו. אפשר לחשוב על זה כמו על „נאום מעלית” או „הצהרת כוונות”. כדאי לתאר למי מיועדת הקהילה הזאת, מה אפשר לצפות למצוא כאן ומה מומלץ לעשות בהתחלה." button_title: "להתחיל לערוך" until: "עד:" admin_js: @@ -4519,7 +4524,6 @@ he: go_back: "חזרה לרשימה" payload_url: "URL של התוכן" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "נראה שניסית להגדיר את ההתליה (webhook) לכתובת מקומית. אירוע שנשלח לכתובת מקומית עשוי לגרום להשפעות חריגות או בלתי צפויות. להמשיך?" secret_invalid: "אסור שהסוד יכיל תווי רווח כלשהם." secret_too_short: "הסוד אמור להכיל לפחות 12 תווים." secret_placeholder: "מחרוזת רשות, משמשת ליצירת חתימה" @@ -4812,7 +4816,6 @@ he: last_attempt: "תהליך ההתקנה לא הסתיים, ניסיון אחרון:" remote_branch: "שם ענף (רשות)" public_key: "להעניק למפתח הציבורי הבא גישה למאגר:" - public_key_note: "לאחר מילוי כתובת אתר מאגר פרטית תקפה לעיל, מפתח SSH ייווצר ויוצג כאן." install: "התקנה" installed: "הותקן" install_popular: "פופלארי" @@ -5501,7 +5504,7 @@ he: trust_level_2_users: "משתמשים בדרגת אמון 2" trust_level_3_requirements: "דרישות דרגת אמון 3" trust_level_locked_tip: "רמות האמון נעולה, המערכת לא תקדם או או תנמיך משתמשים" - trust_level_unlocked_tip: "דרגת האמון אינה נעולה, המערכת תקדם ותנמיך דרגות של משתמשים" + trust_level_unlocked_tip: "דרגת האמון אינה נעולה, המערכת עלולה לקדם או להנמיך דרגות של משתמשים" lock_trust_level: "נעילת דרגת אמון" unlock_trust_level: "שחרור דרגת אמון מנעילה" silenced_count: "מושתק" @@ -5563,23 +5566,23 @@ he: delete_confirm: "האם ברצונכם להסיר את שדה המשתמש הזה?" options: "אפשרויות" required: - title: "נדרש בעת הרשמה?" + title: "נדרש בעת הרשמה" enabled: "נדרש" disabled: "לא נדרש" editable: - title: "ניתן לערוך לאחר הרשמה?" + title: "ניתן לערוך לאחר הרשמה" enabled: "ניתן לערוך" disabled: "לא ניתן לערוך" show_on_profile: - title: "להצגה בפרופיל הפומבי?" + title: "להצגה בפרופיל הפומבי" enabled: "הצגה בפרופיל" disabled: "לא מוצג בפרופיל" show_on_user_card: - title: "הצגה על כרטיס משתמש?" + title: "הצגה על כרטיס משתמש" enabled: "מוצג על כרטיס משתמש" disabled: "לא מוצג על כרטיס משתמש" searchable: - title: "זמין לחיפוש?" + title: "זמין לחיפוש" enabled: "זמין לחיפוש" disabled: "לא זמין לחיפוש" field_types: diff --git a/config/locales/client.hr.yml b/config/locales/client.hr.yml index 6153c0a313..fc09369315 100644 --- a/config/locales/client.hr.yml +++ b/config/locales/client.hr.yml @@ -1151,7 +1151,6 @@ hr: experimental_sidebar: options: "Mogućnosti" navigation_section: "Navigacija" - list_destination_default: "Zadano" change: "promijeni" featured_topic: "Istaknuta tema" moderator: "%{user} je moderator" @@ -4216,7 +4215,6 @@ hr: go_back: "Povratak na popis" payload_url: "URL nosivosti" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Čini se da pokušavate postaviti webhook na lokalni url. Događaj dostavljen na lokalnu adresu može uzrokovati nuspojave ili neočekivana ponašanja. Nastaviti?" secret_invalid: "Tajna ne smije imati praznih znakova." secret_too_short: "Tajna treba sadržavati najmanje 12 znakova." secret_placeholder: "Izborni niz koji se koristi za generiranje potpisa" @@ -4505,7 +4503,6 @@ hr: is_private: "Tema je u privatnom git repozitoriju" remote_branch: "Naziv podružnice (izborno)" public_key: "Omogućite pristup sljedećem javnom ključu repo:" - public_key_note: "Nakon unosa važećeg URL-a privatnog spremišta gore, ovdje će se generirati i prikazati SSH ključ." install: "Instaliraj" installed: "Instalirano" install_popular: "Popularno" @@ -5178,7 +5175,6 @@ hr: trust_level_2_users: "Korisnici na razini povjerenja 2" trust_level_3_requirements: "Predispozicije za razinu povjerenja 3" trust_level_locked_tip: "razina povjerenja zaključana, sistem neće promovirati ili demotirati korisnika" - trust_level_unlocked_tip: "razina povjerenja odključana, sistem će promovirati ili demotirati korisnika" lock_trust_level: "Zaključaj razinu povjerenja" unlock_trust_level: "Odključaj razinu povjerenja" silenced_count: "Utišano" @@ -5239,23 +5235,23 @@ hr: delete_confirm: "Jeste li sigurni da želite obrisati to korisničko polje?" options: "Mogućnosti" required: - title: "Potrebno pri registraciji?" + title: "Potrebno pri registraciji" enabled: "potrebno" disabled: "nije potrebno" editable: - title: "Izmijenjivo nakon registracije?" + title: "Izmijenjivo nakon registracije" enabled: "izmijenjivo" disabled: "nije izmijenjivo" show_on_profile: - title: "Prikaži na javnom profilu?" + title: "Prikaži na javnom profilu" enabled: "prikazano na profilu" disabled: "nije prikazano na profilu" show_on_user_card: - title: "Prikaži na korisničkoj kartici?" + title: "Prikaži na korisničkoj kartici" enabled: "prikaži na korisničkoj kartici" disabled: "nije prikazano na korisničkoj kartici" searchable: - title: "Može se pretraživati?" + title: "Može se pretraživati" enabled: "može se pretraživati" disabled: "nije moguće pretraživati" field_types: diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index 46a05fefc8..30091a2456 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -1097,7 +1097,6 @@ hu: experimental_sidebar: options: "Beállítások" navigation_section: "Navigáció" - list_destination_default: "Alapértelmezett" change: "módosítás" featured_topic: "Kiemelt téma" moderator: "%{user} egy moderátor" @@ -4224,7 +4223,7 @@ hu: enabled: "szerkeszthető" disabled: "nem szerkeszthető" show_on_profile: - title: "Megjelenjen a nyilvános profilon?" + title: "Megjelenjen a nyilvános profilon" enabled: "látható a profiljában" disabled: "nem látható a profiljában" field_types: diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index 2ed5cf8d24..b08417c590 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -840,7 +840,6 @@ hy: experimental_sidebar: options: "Տարբերակներ" navigation_section: "Նավիգացիա" - list_destination_default: "Լռելյայն" change: "փոխել" featured_topic: "Հանրահայտ թեմա" moderator: "%{user}-ը մոդերատոր է" @@ -3971,7 +3970,6 @@ hy: trust_level_2_users: "Վստահության 2-րդ Մակարդակի Օգտատերեր" trust_level_3_requirements: "Վստահության 3 Մակարդակի Պահանջներ" trust_level_locked_tip: "վստահության մակարդակը արգելափակված է, համակարգը չի խթանի կամ խանգարի օգտատիրոջը" - trust_level_unlocked_tip: "վստահության մակարդակը արգելաբացված է, համակարգը կարող է խթանել կամ խանգարել օգտատիրոջը" lock_trust_level: "Արգելափակել Վստահության Մակարդակը" unlock_trust_level: "Արգելաբացել Վստահության Մակարդակը" silenced_count: "Լռեցված" @@ -4026,19 +4024,15 @@ hy: delete_confirm: "Դուք համոզվա՞ծ եք, որ ցանկանում եք ջնջել օգտատիրոջ այդ դաշտը:" options: "Տարբերակներ" required: - title: "Պարտադի՞ր է գրանցվելիս:" enabled: "պարտադիր է" disabled: "պարտադիր չէ" editable: - title: "Խմբագրելի՞ է գրանցումից հետո:" enabled: "խմբագրելի է" disabled: "խմբագրելի չէ" show_on_profile: - title: "Ցու՞յց տալ հրապարակային պրոֆիլում" enabled: "ցուցադրել պրոֆիլում" disabled: "չցուցադրել պրոֆիլում" show_on_user_card: - title: "Ցուցադրե՞լ օգտատիրոջ քարտի վրա:" enabled: "ցուցադրել օգտատիրոջ քարտի վրա" disabled: "չցուցադրել օգտատիրոջ քարտի վրա" field_types: diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index ef3d911995..c6aee8929e 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -936,7 +936,6 @@ id: experimental_sidebar: options: "Pilihan" navigation_section: "Navigasi" - list_destination_default: "Asal" change: "ubah" featured_topic: "Topik Unggulan" moderator: "%{user} adalah moderator" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 706bc921ac..9f5a38e73d 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -970,7 +970,7 @@ it: user_fields: none: "(scegli un'opzione)" required: 'Inserisci un valore per "%{name}"' - same_as_password: 'La tua password non deve essere ripetuta in altri campi.' + same_as_password: "La tua password non deve essere ripetuta in altri campi." user: said: "%{username}:" profile: "Profilo" @@ -4153,7 +4153,6 @@ it: go_back: "Torna all'elenco" payload_url: "URL di Payload" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Stai impostando un webhook che punta ad un indirizzo locale. Eventi inviati ad un indirizzo locale possono causare effetti collaterali o risultati inaspettati. Vuoi continuare?" secret_invalid: "La chiave segreta non può contenere spazi vuoti." secret_too_short: "La chiave segreta deve contenere almeno 12 caratteri." secret_placeholder: "Una stringa facoltativa, usata per generare una firma" @@ -4442,7 +4441,6 @@ it: last_attempt: "Il processo di installazione non si è concluso, ultimo tentativo:" remote_branch: "Nome del branch (opzionale)" public_key: "Concedi l'accesso all'archivio con la seguente chiave pubblica:" - public_key_note: "Dopo l’inserimento di un URL valido di archivio riservato, verrà generata e qui visualizzata una corrispondente chiave SSH." install: "Installa" installed: "Installato" install_popular: "Popolare" @@ -5120,7 +5118,6 @@ it: trust_level_2_users: "Utenti con livello di attendibilità 2" trust_level_3_requirements: "Requisiti per livello di attendibilità 3" trust_level_locked_tip: "il livello di attendibilità è bloccato, il sistema non promuoverà né degraderà l'utente" - trust_level_unlocked_tip: "il livello di attendibilità è sbloccato, il sistema può promuovere o degradare l'utente" lock_trust_level: "Blocca livello di attendibilità" unlock_trust_level: "Sblocca livello di attendibilità" silenced_count: "Silenziati" @@ -5180,23 +5177,18 @@ it: delete_confirm: "Sicuro di voler cancellare il campo utente?" options: "Opzioni" required: - title: "Obbligatorio durante l'iscrizione?" enabled: "obbligatorio" disabled: "non obbligatorio" editable: - title: "Modificabile dopo l'iscrizione?" enabled: "modificabile" disabled: "non modificabile" show_on_profile: - title: "Mostrare nel profilo pubblico?" enabled: "mostrato nel profilo" disabled: "non mostrato nel profilo" show_on_user_card: - title: "Mostrare sulla scheda utente?" enabled: "mostrato sulla scheda utente" disabled: "non mostrato sulla scheda utente" searchable: - title: "Ricercabile?" enabled: "ricercabile" disabled: "non ricercabile" field_types: diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index 9af4023440..c4c7345ea9 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -289,7 +289,7 @@ ja: confirm_delete: "このブックマークを削除してもよろしいですか?リマインダーも削除されます。" confirm_clear: "このトピックのすべてのブックマークをクリアしてもよろしいですか?" save: "保存" - no_timezone: 'まだタイムゾーンを設定していないためリマインダーを設定することはできません。プロフィールで設定してください。' + no_timezone: 'まだタイムゾーンを設定していないためリマインダーを設定することはできません。プロファイルで設定してください。' invalid_custom_datetime: "入力された日時が無効です。もう一度お試しください。" list_permission_denied: "このユーザーのブックマークを表示する権限がありません。" no_user_bookmarks: "ブックマークした投稿はありません。ブックマークを使用すると、特定の投稿を素早く参照できます。" @@ -643,7 +643,7 @@ ja: invite_members: "招待" delete_member_confirm: "%{username} を %{group} グループから削除しますか?" profile: - title: プロフィール + title: プロファイル interaction: title: 交流 posting: 投稿 @@ -917,12 +917,12 @@ ja: user_fields: none: "(オプションを選択)" required: '"%{name}" の値を入力してください' - same_as_password: '他のフィールドでパスワードを繰り返し入力してはいけません。' + same_as_password: "他のフィールドでパスワードを繰り返し入力してはいけません。" user: said: "%{username}:" - profile: "プロフィール" + profile: "プロファイル" mute: "ミュート" - edit: "プロフィールを編集" + edit: "プロファイルを編集" download_archive: button_text: "すべてダウンロード" confirm: "投稿をダウンロードしてもよろしいですか?" @@ -979,7 +979,7 @@ ja: title: "クリア" warning: "注目のトピックをクリアしてもよろしいですか?" use_current_timezone: "現在のタイムゾーンを使用" - profile_hidden: "このユーザーの公開プロフィールは非公開です。" + profile_hidden: "このユーザーの公開プロファイルは非公開です。" expand_profile: "展開" sr_expand_profile: "プロファイルの詳細を展開する" collapse_profile: "折りたたむ" @@ -1056,9 +1056,6 @@ ja: tags_section: "タグセクション" tags_section_instruction: "選択されたタグは、サイドバーのタグセクションに表示されます。" navigation_section: "ナビゲーション" - list_destination_instruction: "新規トピックまたは未読のトピックが含まれるサイドバーのトピックリストリンクをクリックすると、次に移動します:" - list_destination_default: "デフォルト" - list_destination_unread_new: "新規/未読" change: "変更" featured_topic: "注目のトピック" moderator: "%{user} はモデレーターです" @@ -1162,7 +1159,7 @@ ja: preferences_nav: account: "アカウント" security: "セキュリティ" - profile: "プロフィール" + profile: "プロファイル" emails: "メール" notifications: "通知" categories: "カテゴリ" @@ -1264,12 +1261,12 @@ ja: success_via_admin: "このアドレスにメールを送信しました。ユーザーはメールに記載の確認手順に従う必要があります。" success_staff: "現在のメールアドレスにメールを送信しました。確認手順に従ってください。" change_avatar: - title: "プロフィール画像を変更する" + title: "プロファイル画像を変更する" gravatar: "%{gravatarName} 取得場所:" gravatar_title: "%{gravatarName} のウェブサイトでアバターを変更する" gravatar_failed: "このメールアドレスの %{gravatarName} は見つかりませんでした。" refresh_gravatar_title: "%{gravatarName} を更新" - letter_based: "システムプロフィール画像" + letter_based: "システムプロファイル画像" uploaded_avatar: "カスタム画像" uploaded_avatar_empty: "カスタム画像を追加する" upload_title: "写真をアップロードする" @@ -1277,14 +1274,14 @@ ja: logo_small: "サイトの小さなロゴ。デフォルトで使用されます。" use_custom: "またはカスタムアバターをアップロード:" change_profile_background: - title: "プロフィールヘッダー" - instructions: "プロフィールヘッダーは中央揃えで、デフォルトの幅は 1110 px です。" + title: "プロファイルヘッダー" + instructions: "プロファイルヘッダーは中央揃えで、デフォルトの幅は 1110 px です。" change_card_background: title: "ユーザーカードの背景" instructions: "背景画像は中央揃えで、デフォルトの幅は 590 px です。" change_featured_topic: title: "注目のトピック" - instructions: "このトピックへのリンクは、あなたのユーザーカードとプロフィールに表示されます。" + instructions: "このトピックへのリンクは、あなたのユーザーカードとプロファイルに表示されます。" email: title: "メールアドレス" primary: "プライマリーメールアドレス" @@ -1374,7 +1371,7 @@ ja: location: "場所" website: "ウェブサイト" email_settings: "メール" - hide_profile_and_presence: "公開プロフィールとプレゼンス機能を非表示にする" + hide_profile_and_presence: "公開プロファイルとプレゼンス機能を非表示にする" enable_physical_keyboard: "iPad で物理キーボードのサポートを有効にする" text_size: title: "テキストサイズ" @@ -1512,7 +1509,7 @@ ja: none: "このページに表示する招待はありません。" text: "一括招待" instructions: | -

    コミュニティをすばやく拡大するには、ユーザーのリストを招待します。招待するユーザーのメールアドレスごとに少なくとも 1 行を含む CSV ファイルを準備してください。カンマ区切りの情報は、グループに人を追加したい場合、またはそれらのユーザーが初めてサインインしたときに特定のトピックに移動させる場合に利用できます。

    +

    コミュニティーをすばやく拡大するには、ユーザーのリストを招待します。招待するユーザーのメールアドレスごとに少なくとも 1 行を含む CSV ファイルを準備してください。カンマ区切りの情報は、グループに人を追加したい場合、またはそれらのユーザーが初めてサインインしたときに特定のトピックに移動させる場合に利用できます。

    john@smith.com,first_group_name;second_group_name,topic_id

    アップロードされる CSV ファイルに含まれるメールアドレスに招待が送られ、後で管理することができます。

    progress: "%{progress}% アップロード済み..." @@ -1573,10 +1570,10 @@ ja: registration_ip_address: title: "登録時の IP アドレス" avatar: - title: "プロフィール画像" - header_title: "プロフィール、メッセージ、ブックマーク、設定" + title: "プロファイル画像" + header_title: "プロファイル、メッセージ、ブックマーク、設定" name_and_description: "%{name} - %{description}" - edit: "プロフィール画像を編集" + edit: "プロファイル画像を編集" title: title: "タグライン" none: "(なし)" @@ -1584,7 +1581,7 @@ ja: flair: title: "フレア" none: "(なし)" - instructions: "プロフィール写真の横に表示されるアイコン" + instructions: "プロファイル写真の横に表示されるアイコン" status: title: "カスタムステータス" not_set: "未設定" @@ -1608,7 +1605,7 @@ ja: secondary: "これらのヒントを表示しない" first_notification: title: "はじめての通知です!" - content: "通知は、コミュニティで起きていることの最新情報を提供するために使用されます。" + content: "通知は、コミュニティーで起きていることの最新情報を提供するために使用されます。" topic_timeline: title: "トピックのタイムライン" content: "トピックタイムラインを使って投稿を素早くスクロールします。" @@ -1645,7 +1642,7 @@ ja: login_disabled: "閲覧専用モードのため、ログインできません。" logout_disabled: "閲覧専用モードのため、ログアウトできません。" staff_writes_only_mode: - enabled: "このサイトはスタッフ専用モードになっています。このまま閲覧できますが、返信や「いいね」、その他の操作はスタッフメンバーのみに制限されています。" + enabled: "このサイトはスタッフ専用モードになっています。このまま閲覧できますが、返信や「いいね!」、その他の操作はスタッフメンバーのみに制限されています。" too_few_topics_and_posts_notice_MF: >- ディスカッションを開始しましょう! 現在、{currentTopics, plural, one {# 件のトピック} other {# 件のトピック}}と{currentPosts, plural, one {# 件の投稿} other {# 件の投稿}}があります。訪問者が読んで返信できる項目がもっと必要です。少なくとも {requiredTopics, plural, one {# 件のトピック} other {# 件のトピック}}と{requiredPosts, plural, one {# 件の投稿} other {# 件の投稿}}を作成することをお勧めします。このメッセージはスタッフのみに表示されます。 too_few_topics_notice_MF: >- @@ -1676,16 +1673,16 @@ ja: hide_forever: "いいえ、結構です" hidden_for_session: "了解です。明日お尋ねします。'ログイン' からでもアカウントを作成できます。" intro: "こんにちは! ディスカッションを楽しんでいるようですね。ですが、アカウント登録はまだのようです。" - value_prop: "同じ投稿をスクロールするのにうんざりしていませんか?アカウントを作成すると、中断した場所にいつでも戻ることができます。アカウントを使うと、新しい返信の通知を受け取ったり、「いいね!」を使って感謝の気持ちを伝えたりすることも可能です。このコミュニティを一緒に盛り上げていきましょう。:heart:" + value_prop: "同じ投稿をスクロールするのにうんざりしていませんか?アカウントを作成すると、中断した場所にいつでも戻ることができます。アカウントを使うと、新しい返信の通知を受け取ったり、ブックマークを保存したり、「いいね!」を使って感謝の気持ちを伝えたりすることも可能です。このコミュニティーを一緒に盛り上げていきましょう。:heart:" summary: - enabled_description: "このトピックの要約を閲覧しています。コミュニティが最も面白いとした投稿のまとめです。" + enabled_description: "このトピックの要約を閲覧しています。コミュニティーが最も面白いとした投稿のまとめです。" description: other: "%{count} 件の返信があります。" description_time_MF: "{replyCount, plural, other {# 件の返信}}があります。読了目安時間は {readingTime, plural, other {# 分}}です。" enable: "このトピックを要約する" disable: "すべての投稿を表示" short_label: "要約" - short_title: "このトピックの要約を表示: コミュニティが最も面白いと判断した投稿" + short_title: "このトピックの要約を表示: コミュニティーが最も面白いと判断した投稿" deleted_filter: enabled_description: "削除された投稿は非表示になっています。" disabled_description: "削除された投稿は表示されています。" @@ -2218,10 +2215,10 @@ ja: topic: "このトピックを検索" private_messages: "メッセージを検索" tips: - category_tag: "カテゴリまたはタグでフィルタ" - author: "投稿者でフィルタ" - in: "メタデータでフィルタ (例: in:title、in:personal、in:pinned)" - status: "トピックのステータスでフィルタ" + category_tag: "カテゴリまたはタグでフィルタします" + author: "投稿者でフィルタします" + in: "メタデータでフィルタします (例: in:title、in:personal、in:pinned)" + status: "トピックのステータスでフィルタします" full_search: "全ページ検索を開始します" full_search_key: "%{modifier} + Enter" advanced: @@ -2818,7 +2815,7 @@ ja: other: "%{count} 件の非表示の返信を表示する" sr_reply_to: "投稿 #%{post_number} への @%{username} の返信" notice: - new_user: "%{user} が投稿するのはこれが初めてです。コミュニティで歓迎しましょう!" + new_user: "%{user} が投稿するのはこれが初めてです。コミュニティーで歓迎しましょう!" returning_user: "%{user} を見かけてからしばらく経ちました。最後の投稿は %{time} でした。" unread: "投稿は未読です" has_replies: @@ -2986,7 +2983,7 @@ ja: actions: delete_bookmark: name: "ブックマークを削除" - description: "プロフィールからブックマークを削除し、ブックマークのすべてのリマインダーを停止します" + description: "プロファイルからブックマークを削除し、ブックマークのすべてのリマインダーを停止します" edit_bookmark: name: "ブックマークを編集" description: "ブックマーク名を編集するか、リマインダーの日時を変更します" @@ -3376,7 +3373,7 @@ ja: categories: "%{shortcut} カテゴリ" top: "%{shortcut} トップへ" bookmarks: "%{shortcut} ブックマーク" - profile: "%{shortcut} プロフィール" + profile: "%{shortcut} プロファイル" messages: "%{shortcut} メッセージ" drafts: "%{shortcut} 下書き" next: "%{shortcut} 次のトピック" @@ -3466,7 +3463,7 @@ ja: getting_started: name: はじめの一歩 community: - name: コミュニティ + name: コミュニティー trust_level: name: 信頼レベル other: @@ -3640,7 +3637,7 @@ ja: unsupported_file_picked: "サポートされていないファイルを選択しました。サポートされているファイルタイプ – %{types}。" user_activity: no_activity_title: "まだアクティビティがありません" - no_activity_body: "コミュニティへようこそ!初めてアクセスしたため、ディスカッションへの貢献がまだありません。最初のステップとして、トップまたはカテゴリにアクセスし、コンテンツを読み始めましょう!気に入った投稿や詳しく知りたい投稿には %{heartIcon} を選択してください。参加すると、あなたのアクティビティがここに表示されます。" + no_activity_body: "コミュニティーへようこそ!初めてアクセスしたため、ディスカッションへの貢献がまだありません。最初のステップとして、トップまたはカテゴリにアクセスし、コンテンツを読み始めましょう!気に入った投稿や詳しく知りたい投稿には %{heartIcon} を選択してください。参加すると、あなたのアクティビティがここに表示されます。" no_replies_title: "まだトピックに返信していません。" no_replies_title_others: "%{username} はまだトピックに返信していません" no_replies_body: "貢献したい興味深い会話を見つけたら、投稿のすぐ下にある返信ボタンを押せば、その投稿に返信することができます。また、個々の投稿やユーザーではなく、全般トピックに返信したい場合は、トピックの一番下かトピックのタイムラインの下にある返信ボタンを使用してください。" @@ -3698,7 +3695,7 @@ ja: header_action_title: "サイドバーのカテゴリを編集" configure_defaults: "デフォルトの構成" community: - header_link_text: "コミュニティ" + header_link_text: "コミュニティー" header_action_title: "新しいトピックを作成" links: about: @@ -3732,7 +3729,7 @@ ja: pending_count: "保留中 %{count}" welcome_topic_banner: title: "ウェルカムトピックを作成しましょう" - description: 'ウェルカムトピックは、新しいメンバーが最初に読むものです。「エレベーターピッチ」や「ミッションステートメント」と考えると良いでしょう。このコミュニティの対象者、ここで期待できること、最初にしてほしいことを伝えましょう。' + description: "ウェルカムトピックは、新しいメンバーが最初に読むものです。「エレベーターピッチ」や「ミッションステートメント」と考えると良いでしょう。このコミュニティーの対象者、ここで期待できること、最初にしてほしいことを伝えましょう。" button_title: "編集を開始" until: "終了日:" admin_js: @@ -3788,7 +3785,7 @@ ja: page_views: "ページビュー" page_views_short: "ページビュー" show_traffic_report: "詳細なトラフィックレポートを表示" - community_health: コミュニティの健全性 + community_health: コミュニティーの健全性 moderators_activity: モデレーターのアクティビティ whats_new_in_discourse: Discourse の新機能 activity_metrics: アクティビティのメトリクス @@ -3970,7 +3967,7 @@ ja: sync_sso: DiscourseConnect を使用してユーザーを同期します。 show: ユーザーに関する情報を取得します。 check_emails: ユーザーのメールを一覧表示します。 - update: ユーザープロフィール情報を更新します。 + update: ユーザープロファイル情報を更新します。 log_out: ユーザーのすべてのセッションをログアウトします。 anonymize: ユーザーのアカウントを匿名化します。 delete: ユーザーのアカウントを削除します。 @@ -4004,7 +4001,6 @@ ja: go_back: "リストに戻る" payload_url: "ペイロード URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Webhook をローカル URL にセットアップしようとしているようです。ローカルアドレスに配信されるイベントによって、副作用または予期しない動作が発生する可能性があります。続行しますか?" secret_invalid: "シークレットには空白文字を使用できません。" secret_too_short: "シークレットは 12 文字以上です。" secret_placeholder: "署名の生成に使用されるオプションの文字列" @@ -4210,7 +4206,7 @@ ja: themes_intro_emoji: "女性芸術家の絵文字" beginners_guide_title: "Discourse テーマの使用に関する初心者ガイド" developers_guide_title: "Discourse テーマの開発者ガイド" - browse_themes: "コミュニティのテーマを閲覧" + browse_themes: "コミュニティーのテーマを閲覧" customize_desc: "カスタマイズ:" title: "テーマ" create: "作成" @@ -4291,7 +4287,6 @@ ja: last_attempt: "インストールプロセスが終了しませんでした。最終試行:" remote_branch: "ブランチ名 (オプション)" public_key: "次の公開鍵アクセスをリポジトリに許可する:" - public_key_note: "有効なプライベートリポジトリ URL を入力すると、SSH キーが生成されて、ここに表示されます。" install: "インストール" installed: "インストール済み" install_popular: "人気" @@ -4796,7 +4791,7 @@ ja: consuming_staff_time: "スタッフの時間を不当に消耗したため" combative: "好戦的過ぎるため" in_wrong_place: "場所が誤っているため" - no_constructive_purpose: "コミュニティ内で異議を唱える以外に建設的な目的がないため" + no_constructive_purpose: "コミュニティー内で異議を唱える以外に建設的な目的がないため" custom: "カスタム..." suspend_message: "メールメッセージ" suspend_message_placeholder: "必要に応じて、凍結に関する詳細な情報を入力し、ユーザーにメールで送信することができます。" @@ -4834,7 +4829,7 @@ ja: staged: "ステージング?" show_admin_profile: "管理者" manage_user: "ユーザーの管理" - show_public_profile: "公開プロフィールを表示" + show_public_profile: "公開プロファイルを表示" impersonate: "代理操作" action_logs: "操作ログ" ip_lookup: "IP アドレスを検索" @@ -4874,7 +4869,7 @@ ja: time_read: "閲覧時間" post_edits_count: "投稿の編集" anonymize: "ユーザーを匿名化" - anonymize_confirm: "このアカウントを匿名化してよろしいですか?ユーザー名とメールアドレスが変更され、すべてのプロフィール情報がリセットされます。" + anonymize_confirm: "このアカウントを匿名化してよろしいですか?ユーザー名とメールアドレスが変更され、すべてのプロファイル情報がリセットされます。" anonymize_yes: "はい。このアカウントを匿名化する" anonymize_failed: "アカウントの匿名化中に問題が発生しました。" delete: "ユーザーを削除" @@ -4950,7 +4945,7 @@ ja: reset_bounce_score: label: "リセット" title: "バウンススコアを 0 にリセット" - visit_profile: "このユーザーのプロフィールは、このユーザーの設定ページで編集します" + visit_profile: "このユーザーのプロファイルは、このユーザーの設定ページで編集します" deactivate_explanation: "アクティベーションを解除されたユーザーは、メールアドレスを確認し直す必要があります。" suspended_explanation: "凍結中のユーザーはログインできません。" silence_explanation: "投稿禁止になったユーザーは、投稿したり、トピックを開始することはできません。" @@ -4965,7 +4960,6 @@ ja: trust_level_2_users: "信頼レベル 2 のユーザー" trust_level_3_requirements: "信頼レベル 3 の要件" trust_level_locked_tip: "信頼レベルはロックされています。システムがユーザーを昇格または降格することはありません" - trust_level_unlocked_tip: "信頼レベルのロックは解除されています。システムがユーザーを昇格または降格することがあります" lock_trust_level: "信頼レベルをロック" unlock_trust_level: "信頼レベルをロック解除" silenced_count: "投稿禁止" @@ -5005,7 +4999,7 @@ ja: external_username: "ユーザー名" external_name: "名前" external_email: "メール" - external_avatar_url: "プロフィール画像 URL" + external_avatar_url: "プロファイル画像URL" last_payload: "最後のペイロード" delete_sso_record: "SSO レコードを削除" confirm_delete: "この DiscourseConnect レコードを削除してもよろしいですか?" @@ -5024,23 +5018,18 @@ ja: delete_confirm: "このユーザーフィールドを削除してもよろしいですか?" options: "オプション" required: - title: "登録時の必須にしますか?" enabled: "必須" disabled: "オプション" editable: - title: "登録後に編集可能にしますか?" enabled: "編集可能" disabled: "編集不可" show_on_profile: - title: "公開プロフィールに表示しますか?" - enabled: "プロフィールに表示" - disabled: "プロフィール非表示" + enabled: "プロファイルに表示" + disabled: "プロファイル非表示" show_on_user_card: - title: "ユーザーカードに表示しますか?" enabled: "ユーザーカードに表示する" disabled: "ユーザーカードに表示しない" searchable: - title: "検索可能?" enabled: "検索できる" disabled: "検索できない" field_types: @@ -5290,7 +5279,7 @@ ja: uploading: "アップロード中..." upload_error: "ファイルのアップロード中にエラーが発生しました。もう一度お試しください。" staff_count: - other: "あなたのコミュニティには、あなたを含めて %{count} 名のスタッフがいます。" + other: "あなたのコミュニティーには、あなたを含めて %{count} 名のスタッフがいます。" invites: add_user: "追加" none_added: "あなたはスタッフを招待していません。 続行してもよろしいですか?" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index 22d92b102f..b45a50db62 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -1014,7 +1014,6 @@ ko: experimental_sidebar: options: "옵션" navigation_section: "내비게이션" - list_destination_default: "기본" change: "변경" featured_topic: "추천 주제" moderator: "%{user} 님은 운영자입니다" @@ -3769,7 +3768,6 @@ ko: go_back: "목록으로 돌아가기" payload_url: "Payload URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Webhook를 로컬 URL에 구성하려는 것 같습니다. 로컬 주소로 이벤트가 전달되면 부작용이나 예기치 않은 행동이 발생할 수 있습니다. 계속할까요?" secret_invalid: "Secret에는 공백이 있을 수 없습니다." secret_too_short: "Secret은 12자 이상이어야 합니다." secret_placeholder: "시그니처를 생성하기 위한 문자열(선택사항)" @@ -4050,7 +4048,6 @@ ko: is_private: "테마는 비공개 Git 저장소에 있습니다" remote_branch: "브랜치 이름(선택사항)" public_key: "다음 공개 키 액세스를 저장소에 부여하세요." - public_key_note: "위의 유효한 비공개 저장소 URL을 입력하면 SSH 키가 생성되어 여기에 표시됩니다." install: "설치" installed: "설치됨" install_popular: "인기" @@ -4718,7 +4715,6 @@ ko: trust_level_2_users: "신뢰 레벨 2 사용자" trust_level_3_requirements: "신뢰 레벨 3 요구사항" trust_level_locked_tip: "신뢰 레벨이 잠겼습니다. 시스템에서 사용자의 등급을 변경하지 않습니다" - trust_level_unlocked_tip: "신뢰 레벨이 잠금 해제되었습니다. 시스템에서 사용자의 등급을 변경할 수 있습니다" lock_trust_level: "신뢰 레벨 잠금" unlock_trust_level: "신뢰 레벨 잠금 해제" silenced_count: "차단됨" @@ -4777,23 +4773,23 @@ ko: delete_confirm: "이 사용자 필드를 삭제할까요?" options: "옵션" required: - title: "가입 시 필수 항목인가요?" + title: "회원가입시 필수항목" enabled: "필수" disabled: "필수 아님" editable: - title: "가입 후 편집 가능한가요?" + title: "가입 후 편집 가능" enabled: "편집 가능" disabled: "편집 불가" show_on_profile: - title: "공개 프로필에 표시할까요?" + title: "다른 사람이 프로필을 볼수 있게할까요" enabled: "프로필에 표시" disabled: "프로필에 표시하지 않기" show_on_user_card: - title: "사용자 카드에 표시할까요?" + title: "사용자 카드에 표시할까요" enabled: "사용자 카드에 표시" disabled: "사용자 카드에 표시하지 않기" searchable: - title: "검색 가능한가요?" + title: "검색 가능한가요" enabled: "검색 가능" disabled: "검색 불가" field_types: diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index 0d11e45743..b87083836b 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -1057,7 +1057,6 @@ lt: experimental_sidebar: options: "Nustatymai" navigation_section: "Navigacija" - list_destination_default: "Numatytasis" change: "pakeisti" featured_topic: "Įpatinga tema" moderator: "%{user} yra moderatorius" @@ -4052,7 +4051,6 @@ lt: import_file_tip: ".tar.gz, .zip arba .dcstyle.json failas, kuriame yra tema" is_private: "Tema yra privačioje „git“ saugykloje" remote_branch: "Filialo pavadinimas (neprivaloma)" - public_key_note: "Aukščiau įvedus galiojantį privačios saugyklos URL, bus sugeneruotas ir čia rodomas SSH raktas." install: "diegti" installed: "Įrašyta" install_popular: "Populiaros" @@ -4640,7 +4638,6 @@ lt: trust_level_2_users: "Vartotojai su 2 Pasitikėjimo Statusu " trust_level_3_requirements: "3 pasitikėjimo lygio reikalavimai" trust_level_locked_tip: "pasitikėjimo statusas užrakintas, sistema nereklamuos ir nežemins vartotojo" - trust_level_unlocked_tip: "pasitikėjimo statusas yra atrakintas, sistema gali reklamuoti ir žeminti vartotoją" lock_trust_level: "Užrakinti pasitikėjimo lygį" unlock_trust_level: "Atrakinti pasitikėjimo lygį" silenced_count: "Nutildytas" @@ -4696,23 +4693,22 @@ lt: delete_confirm: "Ar tikrai nori ištrinti vartotojo lauką?" options: "Nustatymai" required: - title: "Privaloma per prisijungimą?" + title: "Privaloma per prisijungimą" enabled: "privalomi" disabled: "neprivaloma" editable: - title: "Gali keisti po registracijos?" + title: "Gali keisti po registracijos" enabled: "redaguojama" disabled: "neredaguojama" show_on_profile: - title: "Rodyti paskyroje viešai" enabled: "Rodyti paskyroje" disabled: "Nerodyti paskyroje" show_on_user_card: - title: "Rodyti vartotojo kortelėje?" + title: "Rodyti vartotojo kortelėje" enabled: "rodoma vartotojo kortelėje" disabled: "nerodomas vartotojo kortelėje" searchable: - title: "Ieškoma?" + title: "Ieškoma" enabled: "ieškoma" disabled: "negalima ieškoti" field_types: diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 0dc115fc7e..e774bfbc36 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -979,7 +979,6 @@ lv: experimental_sidebar: options: "Iespējas" navigation_section: "Pārvietošanās" - list_destination_default: "Noklusējums" change: "mainīt" featured_topic: "Piedāvātā tēma" moderator: "%{user} ir moderators" @@ -3483,7 +3482,6 @@ lv: trust_level_2_users: "Uzticības 2. līmeņa lietotāji" trust_level_3_requirements: "Uzticības 3. līmeņa prasības:" trust_level_locked_tip: "Uzticības līmenis ir slēgts, sistēma nepaaugstinās vai nepazeminās lietotāju" - trust_level_unlocked_tip: "Uzticības līmenis ir atslēgts, sistēma paaugstinās vai pazeminās lietotāju" lock_trust_level: "Slēgt Uzticības līmeni" unlock_trust_level: "Atslēgt Uzticības līmeni" suspended_count: "Apturēts" @@ -3536,19 +3534,19 @@ lv: delete_confirm: "Vai tiešām vēlaties dzēst šo lietotāju laukā?" options: "Opcijas" required: - title: "Nepieciešams pierakstoties?" + title: "Nepieciešams pierakstoties" enabled: "obligāts" disabled: "nav obligāts" editable: - title: "Labot pēc pierakstīšanās?" + title: "Labot pēc pierakstīšanās" enabled: "labojams" disabled: "nav labojams" show_on_profile: - title: "Rādīt publiskajā profilā?" + title: "Rādīt publiskajā profilā" enabled: "redzams profilā" disabled: "nav redzams profilā" show_on_user_card: - title: "Parādīt leitotāja kartē?" + title: "Parādīt leitotāja kartē" enabled: "redzams lietotāja kartē" disabled: "nav redzams lietotāja kartē" field_types: diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index 48f41071ef..bbb0838136 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -1030,7 +1030,6 @@ nb_NO: experimental_sidebar: options: "Alternativer" navigation_section: "Navigasjon" - list_destination_default: "Forvalg" change: "Endre" featured_topic: "Utvalgte emne" moderator: "%{user} er en moderator" @@ -4439,7 +4438,6 @@ nb_NO: trust_level_2_users: "Brukere på tillitsnivå 2" trust_level_3_requirements: "Krav til tillitsnivå 3" trust_level_locked_tip: "tillitsnivå er låst, systemet vil ikke forfremme eller degradere bruker" - trust_level_unlocked_tip: "tillitsnivå er ulåst, systemet kan forfremme eller degradere bruker" lock_trust_level: "Lås tillitsnivå" unlock_trust_level: "Lås opp tillitsnivå" silenced_count: "Dempet" @@ -4492,19 +4490,19 @@ nb_NO: delete_confirm: "Er du sikker på at du vil fjerne brukerfeltet?" options: "Alternativer" required: - title: "Nødvendig ved registrering?" + title: "Nødvendig ved registrering" enabled: "nødvendig" disabled: "ikke obligatorisk" editable: - title: "Kan det endres etter registrering?" + title: "Kan det endres etter registrering" enabled: "kan endres" disabled: "kan ikke endres" show_on_profile: - title: "Vis på offentlig profil?" + title: "Vis på offentlig profil" enabled: "vises på profil" disabled: "vises ikke på profil" show_on_user_card: - title: "Vis på brukerkort?" + title: "Vis på brukerkort" enabled: "vist på brukerkort" disabled: "ikke vist på brukerkort" field_types: diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index ed8f3d7c3e..8a47cef927 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -25,61 +25,61 @@ nl: thousands: "%{number}k" millions: "%{number}M" dates: - time: "HH:mm" - time_with_zone: "HH:mm (z)" - time_short_day: "ddd, HH:mm" + time: "HH.mm" + time_with_zone: "HH.mm (z)" + time_short_day: "ddd, HH.mm" timeline_date: "MMM YYYY" - long_no_year: "D MMM, HH:mm" + long_no_year: "D MMM, HH.mm" long_no_year_no_time: "D MMM" full_no_year_no_time: "D MMMM" - long_with_year: "D MMM YYYY HH:mm" + long_with_year: "D MMM YYYY HH.mm" long_with_year_no_time: "D MMM YYYY" full_with_year_no_time: "D MMMM YYYY" - long_date_with_year: "D MMM 'YY HH:mm" - long_date_without_year: "D MMM HH:mm" + long_date_with_year: "D MMM 'YY HH.mm" + long_date_without_year: "D MMM HH.mm" long_date_with_year_without_time: "D MMM 'YY" - long_date_without_year_with_linebreak: "D MMM
    HH:mm" - long_date_with_year_with_linebreak: "D MMM 'YY
    HH:mm" + long_date_without_year_with_linebreak: "D MMM
    HH.mm" + long_date_with_year_with_linebreak: "D MMM 'YY
    HH.mm" wrap_ago: "%{date} geleden" wrap_on: "op %{date}" tiny: - half_a_minute: "< 1m" + half_a_minute: "< 1 m" less_than_x_seconds: - one: "< %{count}s" - other: "< %{count}s" + one: "< %{count} s" + other: "< %{count} s" x_seconds: - one: "%{count}s" - other: "%{count}s" + one: "%{count} s" + other: "%{count} s" less_than_x_minutes: - one: "< %{count}m" - other: "< %{count}m" + one: "< %{count} m" + other: "< %{count} m" x_minutes: - one: "%{count}m" - other: "%{count}m" + one: "%{count} m" + other: "%{count} m" about_x_hours: - one: "%{count}u" - other: "%{count}u" + one: "%{count} u" + other: "%{count} u" x_days: - one: "%{count}d" - other: "%{count}d" + one: "%{count} d" + other: "%{count} d" x_months: - one: "%{count}mnd" - other: "%{count}mnd" + one: "%{count} mnd" + other: "%{count} mnd" about_x_years: - one: "%{count}j" - other: "%{count}j" + one: "%{count} j" + other: "%{count} j" over_x_years: - one: "> %{count}j" - other: "> %{count}j" + one: "> %{count} j" + other: "> %{count} j" almost_x_years: - one: "%{count}j" - other: "%{count}j" + one: "%{count} j" + other: "%{count} j" date_month: "D MMM" date_year: "MMM 'YY" medium: x_minutes: - one: "%{count} min" - other: "%{count} min" + one: "%{count} min." + other: "%{count} min." x_hours: one: "%{count} uur" other: "%{count} uur" @@ -89,8 +89,8 @@ nl: date_year: "D MMM 'YY" medium_with_ago: x_minutes: - one: "%{count} min geleden" - other: "%{count} min geleden" + one: "%{count} min. geleden" + other: "%{count} min. geleden" x_hours: one: "%{count} uur geleden" other: "%{count} uur geleden" @@ -126,47 +126,47 @@ nl: email: "Verzenden via e-mail" url: "URL kopiëren en delen" action_codes: - public_topic: "heeft dit topic openbaar gemaakt op %{when}" - private_topic: "heeft dit topic een privébericht gemaakt op %{when}" - split_topic: "heeft dit topic gesplitst op %{when}" - invited_user: "heeft %{who} uitgenodigd op %{when}" - invited_group: "heeft %{who} uitgenodigd op %{when}" - user_left: "%{who} heeft zichzelf uit dit bericht verwijderd op %{when}" - removed_user: "heeft %{who} verwijderd op %{when}" - removed_group: "heeft %{who} verwijderd op %{when}" - autobumped: "automatisch gebumpt op %{when}" + public_topic: "heeft dit topic %{when} openbaar gemaakt" + private_topic: "heeft dit topic %{when} een privébericht gemaakt" + split_topic: "heeft dit topic %{when} gesplitst" + invited_user: "heeft %{who} %{when} uitgenodigd" + invited_group: "heeft %{who} %{when} uitgenodigd" + user_left: "%{who} heeft zichzelf %{when} uit dit bericht verwijderd" + removed_user: "heeft %{who} %{when} verwijderd" + removed_group: "heeft %{who} %{when} verwijderd" + autobumped: "heeft %{when} automatisch gebumpt" autoclosed: - enabled: "gesloten op %{when}" - disabled: "geopend op %{when}" + enabled: "%{when} gesloten" + disabled: "%{when} geopend" closed: - enabled: "gesloten op %{when}" - disabled: "geopend op %{when}" + enabled: "%{when} gesloten" + disabled: "%{when} geopend" archived: - enabled: "gearchiveerd op %{when}" - disabled: "gedearchiveerd op %{when}" + enabled: "%{when} gearchiveerd" + disabled: "%{when} gedearchiveerd" pinned: - enabled: "vastgemaakt op %{when}" - disabled: "losgemaakt op %{when}" + enabled: "%{when} vastgemaakt" + disabled: "%{when} losgemaakt" pinned_globally: - enabled: "globaal vastgemaakt op %{when}" - disabled: "losgemaakt op %{when}" + enabled: "%{when} globaal vastgemaakt" + disabled: "%{when} losgemaakt" visible: - enabled: "zichtbaar gemaakt op %{when}" - disabled: "onzichtbaar gemaakt op %{when}" + enabled: "%{when} zichtbaar gemaakt" + disabled: "%{when} onzichtbaar gemaakt" banner: - enabled: "heeft deze banner gemaakt op %{when}. De banner verschijnt bovenaan elke pagina, totdat de gebruiker deze verbergt." - disabled: "heeft deze banner verwijderd op %{when}. De banner zal niet meer bovenaan elke pagina verschijnen." + enabled: "heeft deze banner %{when} gemaakt. De banner wordt weergegeven bovenaan elke pagina, totdat de gebruiker deze sluit." + disabled: "heeft deze banner %{when} verwijderd. De banner wordt niet meer weergegeven bovenaan elke pagina." forwarded: "heeft de bovenstaande e-mail doorgestuurd" topic_admin_menu: "topicacties" - emails_are_disabled: "Alle uitgaande e-mail is uitgeschakeld door een beheerder. Er wordt geen enkele e-mailmelding verstuurd." + emails_are_disabled: "Alle uitgaande e-mail is uitgeschakeld door een beheerder. Er wordt geen enkele e-mailmelding gestuurd." software_update_prompt: - message: "We hebben deze site bijgewerkt, gelieve te vernieuwen, anders kan er onverwacht gedrag optreden." + message: "We hebben deze site bijgewerkt, dus vernieuw de pagina, anders kan er onverwacht gedrag optreden." dismiss: "Negeren" bootstrap_mode_enabled: - one: "Om het opzetten van uw nieuwe website makkelijker te maken, bevindt u zich in bootstrapmodus. Aan alle nieuwe gebruikers wordt vertrouwensniveau 1 toegekend, en dagelijkse e-mailsamenvattingen zijn voor hen ingeschakeld. Dit wordt automatisch uitgeschakeld zodra %{count} gebruiker lid is geworden." - other: "Om het opzetten van uw nieuwe website makkelijker te maken, bevindt u zich in bootstrapmodus. Aan alle nieuwe gebruikers wordt vertrouwensniveau 1 toegekend, en dagelijkse e-mailsamenvattingen zijn voor hen ingeschakeld. Dit wordt automatisch uitgeschakeld zodra %{count} gebruikers lid zijn geworden." + one: "Om het opzetten van je nieuwe website makkelijker te maken, bevind je je in de bootstrapmodus. Aan alle nieuwe gebruikers wordt vertrouwensniveau 1 toegekend en dagelijkse e-mailsamenvattingen zijn ingeschakeld voor hen. Dit wordt automatisch uitgeschakeld zodra %{count} gebruiker lid is geworden." + other: "Om het opzetten van je nieuwe website makkelijker te maken, bevind je je in de bootstrapmodus. Aan alle nieuwe gebruikers wordt vertrouwensniveau 1 toegekend en dagelijkse e-mailsamenvattingen zijn ingeschakeld voor hen. Dit wordt automatisch uitgeschakeld zodra %{count} gebruikers lid zijn geworden." bootstrap_mode_disabled: "De bootstrapmodus wordt binnen 24 uur uitgeschakeld." - bootstrap_invite_button_title: "Uitnodigingen versturen" + bootstrap_invite_button_title: "Uitnodigingen verzenden" themes: default_description: "Standaard" s3: @@ -174,11 +174,11 @@ nl: ap_northeast_1: "Azië Pacifisch (Tokio)" ap_northeast_2: "Azië Pacifisch (Seoel)" ap_east_1: "Azië Pacifisch (Hong Kong)" - ap_south_1: "Azië Pacifisch (Bombay)" + ap_south_1: "Azië Pacifisch (Mumbai)" ap_southeast_1: "Azië Pacifisch (Singapore)" ap_southeast_2: "Azië Pacifisch (Sydney)" ca_central_1: "Canada (Centraal)" - cn_north_1: "China (Peking)" + cn_north_1: "China (Beijing)" cn_northwest_1: "China (Ningxia)" eu_central_1: "EU (Frankfurt)" eu_north_1: "EU (Stockholm)" @@ -201,26 +201,26 @@ nl: yes_value: "Ja" ok_value: "OK" cancel_value: "Annuleren" - submit: "Versturen" + submit: "Verzenden" delete: "Verwijderen" - generic_error: "Sorry, er is iets fout gegaan." - generic_error_with_reason: "Er is iets fout gegaan: %{error}" + generic_error: "Sorry, er is een fout opgetreden." + generic_error_with_reason: "Er is een fout opgetreden: %{error}" sign_up: "Registreren" log_in: "Aanmelden" age: "Leeftijd" joined: "Lid sinds" admin_title: "Beheer" - show_more: "meer tonen" + show_more: "meer weergeven" show_help: "opties" - links: "Koppelingen" + links: "Links" links_lowercase: - one: "koppeling" - other: "koppelingen" + one: "link" + other: "links" faq: "FAQ" guidelines: "Richtlijnen" privacy_policy: "Privacybeleid" privacy: "Privacy" - tos: "Algemene Voorwaarden" + tos: "Gebruiksvoorwaarden" rules: "Regels" conduct: "Gedragscode" mobile_view: "Mobiele weergave" @@ -230,14 +230,14 @@ nl: read_more: "meer lezen" more: "Meer" x_more: - one: "%{count} Meer" - other: "%{count} Meer" + one: "Nog %{count}" + other: "Nog %{count}" never: "nooit" every_30_minutes: "elke 30 minuten" every_hour: "elk uur" daily: "dagelijks" weekly: "wekelijks" - every_month: "elke maand" + every_month: "maandelijks" every_six_months: "elke zes maanden" max_of_count: "max. %{count}" character_count: @@ -247,7 +247,7 @@ nl: aria_label: "Filteren op periode" related_messages: title: "Gerelateerde berichten" - see_all: 'Alle berichten van @%{username}bekijken...' + see_all: 'Alle berichten van @%{username} weergeven...' suggested_topics: title: "Aanbevolen topics" pm_title: "Voorgestelde berichten" @@ -271,36 +271,36 @@ nl: contact: "Contact" contact_info: "Neem in het geval van een kritieke kwestie of dringende vraagstukken in verband met deze website contact met ons op via %{contact_info}." bookmarked: - title: "Bladwijzer maken" + title: "Bladwijzer" edit_bookmark: "Bladwijzer bewerken" clear_bookmarks: "Bladwijzers wissen" help: bookmark: "Klik om een bladwijzer voor het eerste bericht van dit topic te maken" unbookmark: "Klik om alle bladwijzers in dit topic te verwijderen" bookmarks: - created: "U hebt een bladwijzer voor dit bericht gemaakt. %{name}" + created: "Je hebt een bladwijzer voor dit bericht gemaakt. %{name}" create: "Bladwijzer maken" edit: "Bladwijzer bewerken" - not_bookmarked: "bladwijzer voor dit bericht maken" - created_with_reminder: "U hebt een bladwijzer voor dit bericht gemaakt met een herinnering voor %{date}. %{name}" + not_bookmarked: "bladwijzer maken voor dit bericht" + created_with_reminder: "Je hebt een bladwijzer voor dit bericht gemaakt met een herinnering voor %{date}. %{name}" remove: "Bladwijzer verwijderen" delete: "Bladwijzer verwijderen" - confirm_delete: "Weet u zeker dat u deze bladwijzer wilt verwijderen? De herinnering wordt ook verwijderd." - confirm_clear: "Weet u zeker dat u alle bladwijzers van dit topic wilt verwijderen?" + confirm_delete: "Weet je zeker dat je deze bladwijzer wilt verwijderen? De herinnering wordt ook verwijderd." + confirm_clear: "Weet je zeker dat je alle bladwijzers van dit topic wilt verwijderen?" save: "Opslaan" - no_timezone: 'U hebt nog geen tijdzone ingesteld. Hierdoor kunt u geen herinneringen instellen. Stel er een in in uw profiel.' - invalid_custom_datetime: "De datum en tijd die u hebt opgegeven is ongeldig, probeer het opnieuw." - list_permission_denied: "U hebt geen toestemming om de bladwijzers van deze gebruiker te bekijken." - no_user_bookmarks: "U hebt geen bladwijzers voor berichten; via bladwijzers kunt u snel bepaalde berichten raadplegen." + no_timezone: 'Je hebt nog geen tijdzone ingesteld. Hierdoor kun je geen herinneringen instellen. Stel er een in in je profiel.' + invalid_custom_datetime: "De datum en tijd die je hebt opgegeven zijn ongeldig, probeer het opnieuw." + list_permission_denied: "Je hebt geen toestemming om de bladwijzers van deze gebruiker te bekijken." + no_user_bookmarks: "Je hebt geen bladwijzers voor berichten. Met bladwijzers kun je snel bepaalde berichten raadplegen." auto_delete_preference: when_reminder_sent: "Bladwijzer verwijderen" - search_placeholder: "Bladwijzers doorzoeken op naam, topictitel of berichtinhoud" + search_placeholder: "Zoek bladwijzers op naam, topictitel of berichtinhoud" search: "Zoeken" reminders: today_with_time: "vandaag om %{time}" tomorrow_with_time: "morgen om %{time}" - at_time: "op %{date_time}" - existing_reminder: "U hebt een herinnering voor deze bladwijzer ingesteld die %{at_date_time} wordt verzonden" + at_time: "om %{date_time}" + existing_reminder: "Je hebt een herinnering voor deze bladwijzer ingesteld die %{at_date_time} wordt verzonden" copy_codeblock: copied: "gekopieerd!" drafts: @@ -308,29 +308,29 @@ nl: label_with_count: "Concepten (%{count})" resume: "Hervatten" remove: "Verwijderen" - remove_confirmation: "Weet u zeker dat u dit concept wilt verwijderen?" - new_topic: "Nieuw-topicconcept" - new_private_message: "Nieuw privé-bericht concept" + remove_confirmation: "Weet je zeker dat je dit concept wilt verwijderen?" + new_topic: "Nieuw topicconcept" + new_private_message: "Nieuw privéberichtconcept" topic_reply: "Conceptantwoord" abandon: - confirm: "U hebt een concept in uitvoering voor dit topic. Wat wilt u er mee doen?" - yes_value: "Negeren" + confirm: "Je hebt een concept in uitvoering voor dit topic. Wat wil je ermee doen?" + yes_value: "Weggooien" no_value: "Bewerken hervatten" topic_count_categories: - one: "%{count} nieuw of bijgewerkt topic bekijken" - other: "%{count} nieuwe of bijgewerkte topics bekijken" + one: "%{count} nieuw of bijgewerkt topic weergeven" + other: "%{count} nieuwe of bijgewerkte topics weergeven" topic_count_latest: - one: "%{count} nieuw of bijgewerkt topic bekijken" - other: "%{count} nieuwe of bijgewerkte topics bekijken" + one: "%{count} nieuw of bijgewerkt topic weergeven" + other: "%{count} nieuwe of bijgewerkte topics weergeven" topic_count_unseen: - one: "%{count} nieuw of bijgewerkt topic bekijken" - other: "%{count} nieuwe of bijgewerkte topics bekijken" + one: "%{count} nieuw of bijgewerkt topic weergeven" + other: "%{count} nieuwe of bijgewerkte topics weergeven" topic_count_unread: - one: "%{count} ongelezen topic bekijken" - other: "%{count} ongelezen topics bekijken" + one: "%{count} ongelezen topic weergeven" + other: "%{count} ongelezen topics weergeven" topic_count_new: - one: "%{count} nieuw topic bekijken" - other: "%{count} nieuwe topics bekijken" + one: "%{count} nieuw topic weergeven" + other: "%{count} nieuwe topics weergeven" preview: "voorbeeld" cancel: "annuleren" deleting: "Verwijderen..." @@ -348,15 +348,15 @@ nl: disable: "Uitschakelen" continue: "Doorgaan" undo: "Ongedaan maken" - revert: "Terugzetten" + revert: "Herstellen" failed: "Mislukt" switch_to_anon: "Anonieme modus starten" switch_from_anon: "Anonieme modus verlaten" banner: - close: "Deze banner negeren" + close: "Deze banner sluiten" edit: "Bewerken" pwa: - install_banner: "Wilt u %{title} op dit apparaat installeren?" + install_banner: "Wil je %{title} installeren op dit apparaat?" choose_topic: none_found: "Geen topics gevonden." title: @@ -370,7 +370,7 @@ nl: review: order_by: "Sorteren op" date_filter: "Geplaatst tussen" - in_reply_to: "in reactie op" + in_reply_to: "in antwoord op" explain: why: "leg uit waarom dit item in de wachtrij is beland" title: "Beoordeelbare scores" @@ -381,7 +381,7 @@ nl: score_to_hide: "Score voor verbergen van bericht" take_action_bonus: name: "heeft actie ondernomen" - title: "Wanneer een staflid kiest voor het ondernemen van actie, wordt een bonus aan de markering gegeven." + title: "Wanneer een staflid ervoor kiest actie te ondernemen, ontvangt de markering een bonus." user_accuracy_bonus: name: "gebruikersnauwkeurigheid" title: "Gebruikers waarvan markeringen in het verleden zijn geaccordeerd ontvangen een bonus." @@ -389,15 +389,15 @@ nl: name: "vertrouwensniveau" title: "Beoordeelbare items die door gebruikers met een hoger vertrouwensniveau zijn gemaakt hebben een hogere score." type_bonus: - name: "type bonus" + name: "typebonus" title: "Aan bepaalde beoordeelbare typen kan door stafleden een bonus worden toegekend om ze hogere prioriteit te geven." claim_help: - optional: "U kunt dit item opeisen om te voorkomen dat anderen het beoordelen." - required: "U moet items opeisen voordat u ze kunt beoordelen." - claimed_by_you: "U hebt dit item opgeëist en kunt het beoordelen." + optional: "Je kunt dit item claimen om te voorkomen dat anderen het beoordelen." + required: "Je moet items claimen voordat je ze kunt beoordelen." + claimed_by_you: "Je hebt dit item geclaimd en kunt het beoordelen." claimed_by_other: "Dit item kan alleen worden beoordeeld door %{username}." claim: - title: "dit topic opeisen" + title: "dit topic claimen" unclaim: help: "deze claim verwijderen" awaiting_approval: "Wacht op goedkeuring" @@ -407,25 +407,25 @@ nl: save_changes: "Wijzigingen opslaan" title: "Instellingen" priorities: - title: "Beoordeelbare prioriteiten" + title: "Prioriteiten voor beoordeelbare items" moderation_history: "Moderatiegeschiedenis" - view_all: "Alle bekijken" + view_all: "Alles weergeven" grouped_by_topic: "Gegroepeerd op topic" none: "Er zijn geen items om te beoordelen." - view_pending: "wachtende bekijken" + view_pending: "wachtende weergeven" topic_has_pending: one: "Dit topic heeft %{count} bericht dat op goedkeuring wacht" other: "Dit topic heeft %{count} berichten die op goedkeuring wachten" title: "Beoordelen" topic: "Topic:" - filtered_topic: "U hebt op beoordeelbare inhoud in één topic gefilterd." + filtered_topic: "Je hebt op beoordeelbare inhoud in één topic gefilterd." filtered_user: "Gebruiker" filtered_reviewed_by: "Beoordeeld door" - show_all_topics: "alle topics tonen" + show_all_topics: "alle topics weergeven" deleted_post: "(bericht verwijderd)" deleted_user: "(gebruiker verwijderd)" user: - bio: "Biografie" + bio: "Bio" website: "Website" username: "Gebruikersnaam" email: "E-mailadres" @@ -461,7 +461,7 @@ nl: edit: "Bewerken" save: "Opslaan" cancel: "Annuleren" - new_topic: "Goedkeuren van dit item maakt een nieuw topic" + new_topic: "Door dit item goed te keuren, wordt eeen nieuw topic gemaakt" filters: all_categories: "(alle categorieën)" type: @@ -474,18 +474,18 @@ nl: orders: score: "Score" score_asc: "Score (omgekeerd)" - created_at: "Lid sinds" + created_at: "Gemaakt op" created_at_asc: "Gemaakt op (omgekeerd)" priority: title: "Minimale prioriteit" any: "(alle)" low: "Laag" - medium: "Gemiddeld" + medium: "Middel" high: "Hoog" conversation: - view_full: "volledige conversatie bekijken" + view_full: "volledige conversatie weergeven" scores: - about: "Deze score wordt berekend op basis van het vertrouwen van de melder, de nauwkeurigheid van zijn of haar eerdere markeringen, en de prioriteit van het item dat wordt gemeld." + about: "Deze score wordt berekend op basis van het vertrouwen van de melder, de nauwkeurigheid van diens eerdere markeringen en de prioriteit van het item dat wordt gemeld." score: "Score" date: "Datum" type: "Type" @@ -494,7 +494,7 @@ nl: reviewed_by: "Beoordeeld door" statuses: pending: - title: "In wachtrij" + title: "Wachtend" approved: title: "Goedgekeurd" rejected: @@ -520,16 +520,16 @@ nl: reviewable_post: title: "Bericht" approval: - title: "Bericht heeft goedkeuring nodig" - description: "We hebben uw nieuwe bericht ontvangen, maar dit moet eerst door een moderator worden goedgekeurd voordat het zichtbaar wordt. Heb geduld." + title: "Bericht vereist goedkeuring" + description: "We hebben je nieuwe bericht ontvangen, maar dit moet eerst door een moderator worden goedgekeurd voordat het zichtbaar wordt. Heb geduld." pending_posts: - one: "U hebt %{count} wachtend bericht." - other: "U hebt %{count} wachtende berichten." + one: "Je hebt %{count} wachtend bericht." + other: "Je hebt %{count} wachtende berichten." ok: "OK" example_username: "gebruikersnaam" reject_reason: - title: "Waarom keurt u deze gebruiker af?" - send_email: "Afwijzingsmail verzenden" + title: "Waarom wijs je deze gebruiker af?" + send_email: "Afwijzings-e-mail verzenden" relative_time_picker: minutes: one: "minuut" @@ -575,10 +575,10 @@ nl: user_action: user_posted_topic: "%{user} heeft het topic geplaatst" you_posted_topic: "U hebt het topic geplaatst" - user_replied_to_post: "%{user} heeft op %{post_number} geantwoord" - you_replied_to_post: "U hebt op %{post_number} geantwoord" - user_replied_to_topic: "%{user} heeft op het topic geantwoord" - you_replied_to_topic: "U hebt op het topic geantwoord" + user_replied_to_post: "%{user} heeft geantwoord op %{post_number}" + you_replied_to_post: "U hebt geantwoord op %{post_number}" + user_replied_to_topic: "%{user} heeft geantwoord op het topic" + you_replied_to_topic: "U hebt geantwoord op het topic" user_mentioned_user: "%{user} heeft %{another_user} genoemd" user_mentioned_you: "%{user} heeft u genoemd" you_mentioned_user: "U hebt %{another_user} genoemd" @@ -610,7 +610,7 @@ nl: other: "%{count} gebruikers" edit_columns: save: "Opslaan" - reset_to_default: "Standaardwaarden terugzetten" + reset_to_default: "Standaardwaarde herstellen" group: all: "alle groepen" group_histories: @@ -622,30 +622,30 @@ nl: remove_user_as_group_owner: "Eigenaar intrekken" groups: member_added: "Toegevoegd" - member_requested: "Aangevraagd:" + member_requested: "Verzocht op" add_members: - title: "Voeg gebruikers toe aan %{group_name}" - description: "Voer een lijst in met gebruikers die u voor de groep wilt uitnodigen of plak hier een door komma's gescheiden lijst:" + title: "Gebruikers toevoegen aan %{group_name}" + description: "Voer een lijst in met gebruikers die je wilt uitnodigen voor de groep of plak hier een door komma's gescheiden lijst:" usernames_placeholder: "gebruikersnamen" usernames_or_emails_placeholder: "gebruikersnamen of e-mailadressen" notify_users: "Gebruikers een melding sturen" set_owner: "Stel gebruikers in als eigenaar van deze groep" requests: - title: "Aanvragen" + title: "Verzoeken" reason: "Reden" accept: "Accepteren" accepted: "geaccepteerd" deny: "Weigeren" denied: "geweigerd" - undone: "aanvraag ongedaan gemaakt" - handle: "lidmaatschapsaanvraag behandelen" + undone: "verzoek ongedaan gemaakt" + handle: "lidmaatschapsverzoek behandelen" manage: title: "Beheren" name: "Naam" full_name: "Volledige naam" add_members: "Gebruikers toevoegen" invite_members: "Uitnodigen" - delete_member_confirm: "'%{username}' uit de groep '%{group}' verwijderen?" + delete_member_confirm: "'%{username}' verwijderen uit de groep '%{group}'?" profile: title: Profiel interaction: @@ -654,21 +654,21 @@ nl: notification: Melding email: title: "E-mailadres" - status: "%{old_emails} / %{total_emails} e-mails via IMAP gesynchroniseerd." + status: "%{old_emails} / %{total_emails} e-mails gesynchroniseerd via IMAP." enable_smtp: "SMTP inschakelen" enable_imap: "IMAP inschakelen" - test_settings: "Test Instellingen" + test_settings: "Instellingen testen" save_settings: "Instellingen opslaan" last_updated: "Laatst bijgewerkt:" last_updated_by: "door" - smtp_settings_valid: "SMTP instellingen geldig." + smtp_settings_valid: "SMTP-instellingen geldig." smtp_title: "SMTP" imap_title: "IMAP" - imap_additional_settings: "Extra instellingen" + imap_additional_settings: "Aanvullende instellingen" prefill: gmail: "GMail" credentials: - title: "Referenties" + title: "Aanmeldgegevens" smtp_server: "SMTP-server" smtp_port: "SMTP-poort" smtp_ssl: "SSL gebruiken voor SMTP" @@ -679,10 +679,10 @@ nl: password: "Wachtwoord" settings: title: "Instellingen" - allow_unknown_sender_topic_replies: "Sta topic antwoorden van onbekende afzenders toe." + allow_unknown_sender_topic_replies: "Sta topicantwoorden van onbekende afzenders toe." mailboxes: - synchronized: "Gesynchroniseerd postvak" - none_found: "Geen postvakken gevonden in deze e-mailaccount." + synchronized: "Gesynchroniseerde mailbox" + none_found: "Geen mailboxen gevonden op dit e-mailaccount." disabled: "Uitgeschakeld" membership: title: Lidmaatschap @@ -690,23 +690,23 @@ nl: categories: title: Categorieën long_title: "Standaardmeldingen voor categorieën" - description: "Wanneer gebruikers aan deze groep worden toegevoegd, worden hun instellingen voor categoriemeldingen op deze standaardwaarden ingesteld. Daarna kunnen ze deze wijzigen." - watched_categories_instructions: "Automatisch alle topics in deze categorieën in de gaten houden. Groepsleden ontvangen meldingen bij alle nieuwe berichten en topics, en het aantal nieuwe berichten verschijnt ook naast het topic." - tracked_categories_instructions: "Automatisch alle topics in deze categorieën volgen. Het aantal nieuwe berichten verschijnt naast het topic." + description: "Wanneer gebruikers aan deze groep worden toegevoegd, worden hun instellingen voor categoriemeldingen ingesteld op deze standaardwaarden. Daarna kunnen ze deze wijzigen." + watched_categories_instructions: "Observeer automatisch alle topics in deze categorieën. Groepsleden ontvangen meldingen bij alle nieuwe berichten en topics en het aantal nieuwe berichten wordt weergegeven naast het topic." + tracked_categories_instructions: "Volg automatisch alle topics in deze categorieën. Het aantal nieuwe berichten wordt weergegeven naast het topic." watching_first_post_categories_instructions: "Gebruikers ontvangen een melding bij het eerste bericht in elk nieuw topic in deze categorieën." regular_categories_instructions: "Als deze categorieën zijn gedempt, wordt het dempen opgeheven voor groepsleden. Gebruikers ontvangen een melding als ze worden genoemd of als iemand erop antwoordt." - muted_categories_instructions: "Gebruikers ontvangen geen enkele melding over nieuwe topics in deze categorieën, en ze verschijnen niet op de pagina's Categorieën of Nieuwste." + muted_categories_instructions: "Gebruikers ontvangen geen enkele melding over nieuwe topics en berichten in deze categorieën en deze worden niet weergegeven op de pagina's Categorieën of Nieuwste." tags: title: Tags long_title: "Standaardmeldingen voor tags" - description: "Wanneer gebruikers aan deze groep worden toegevoegd, worden hun instellingen voor tagmeldingen op deze standaardwaarden ingesteld. Daarna kunnen ze deze wijzigen." - watched_tags_instructions: "Automatisch alle topics met deze tags in de gaten houden. Groepsleden ontvangen meldingen bij alle nieuwe berichten en topics, en het aantal nieuwe berichten verschijnt ook naast het topic." - tracked_tags_instructions: "Automatisch alle topics met deze tags volgen. Het aantal nieuwe berichten verschijnt naast het topic." + description: "Wanneer gebruikers aan deze groep worden toegevoegd, worden hun instellingen voor tagmeldingen ingesteld op deze standaardwaarden. Daarna kunnen ze deze wijzigen." + watched_tags_instructions: "Observeer automatisch alle topics met deze tags. Groepsleden ontvangen meldingen bij alle nieuwe berichten en topics en het aantal nieuwe berichten wordt weergegeven naast het topic." + tracked_tags_instructions: "Volg automatisch alle topics met deze tags. Het aantal nieuwe berichten wordt weergegeven naast het topic." watching_first_post_tags_instructions: "Gebruikers ontvangen een melding bij het eerste bericht in elk nieuw topic met deze tags." - regular_tags_instructions: "Als deze tags zijn gedempt, wordt het dempen opgeheven voor groepsleden. Gebruikers ontvangen een melding als ze worden genoemd of als iemand erop antwoordt." - muted_tags_instructions: "Gebruikers ontvangen geen enkele melding over nieuwe topics met deze tags, en ze verschijnen niet in Nieuwste." + regular_tags_instructions: "Als deze tags zijn gedempt, wordt het dempen opgeheven voor groepsleden. Gebruikers ontvangen een melding als ze worden genoemd of als erop wordt geantwoord." + muted_tags_instructions: "Gebruikers ontvangen geen meldingen over nieuwe topics met deze tags en ze worden niet weergegeven in Nieuwste." logs: - title: "Logboeken" + title: "Logs" when: "Wanneer" action: "Actie" acting_user: "Uitvoerende gebruiker" @@ -719,28 +719,28 @@ nl: title: "Toestemmingen" none: "Er zijn geen categorieën aan deze groep gekoppeld." description: "Leden van deze groep hebben toegang tot deze categorieën" - public_admission: "Gebruikers mogen vrij aan de groep deelnemen (Vereist openbaar zichtbare groep)" - public_exit: "Gebruikers mogen vrij de groep verlaten" + public_admission: "Gebruikers toestaan de groep te verlaten (vereist openbaar zichtbare groep)" + public_exit: "Gebruikers toestaan de groep te verlaten" empty: posts: "Er zijn geen berichten van leden van deze groep." members: "Er zijn geen leden in deze groep." - requests: "Er zijn geen lidmaatschapsaanvragen voor deze groep." + requests: "Er zijn geen lidmaatschapsverzoeken voor deze groep." mentions: "Er zijn geen vermeldingen van deze groep." messages: "Er zijn geen berichten voor deze groep." topics: "Er zijn geen topics van leden van deze groep." logs: "Er zijn geen logs voor deze groep." add: "Toevoegen" - join: "Toetreden" + join: "Deelnemen" leave: "Verlaten" - request: "Aanvraag" + request: "Verzoek" message: "Bericht" - confirm_leave: "Weet u zeker dat u deze groep wilt verlaten?" - allow_membership_requests: "Gebruikers mogen lidmaatschapsaanvragen naar groepseigenaren sturen (Vereist openbaar zichtbare groep)" - membership_request_template: "Aangepaste sjabloon om weer te geven voor gebruikers bij het sturen van een lidmaatschapsaanvraag" + confirm_leave: "Weet je zeker dat je deze groep wilt verlaten?" + allow_membership_requests: "Sta gebruikers toe om lidmaatschapsverzoeken naar groepseigenaren sturen (vereist een openbaar zichtbare groep)" + membership_request_template: "Aangepaste sjabloon om aan gebruikers te tonen bij het sturen van een lidmaatschapsverzoek" membership_request: - submit: "Aanvraag versturen" + submit: "Verzoek verzenden" title: "Verzoek voor deelname aan @%{group_name}" - reason: "Laat de groepseigenaren weten waarom u in deze groep hoort" + reason: "Laat de groepseigenaren weten waarom je in deze groep hoort" membership: "Lidmaatschap" name: "Naam" group_name: "Groepsnaam" @@ -753,7 +753,7 @@ nl: all: "Alle groepen" empty: "Er zijn geen zichtbare groepen." filter: "Filteren op groepstype" - owner_groups: "Mijn groepen" + owner_groups: "Groepen in mijn bezit" close_groups: "Besloten groepen" automatic_groups: "Automatische groepen" automatic: "Automatisch" @@ -774,35 +774,35 @@ nl: filter_placeholder_admin: "gebruikersnaam of e-mailadres" filter_placeholder: "gebruikersnaam" remove_member: "Lid verwijderen" - remove_member_description: "%{username} uit deze groep verwijderen" + remove_member_description: "Verwijder %{username} uit deze groep" make_owner: "Eigenaar maken" - make_owner_description: "%{username} een eigenaar van deze groep maken" + make_owner_description: "Maak %{username} een eigenaar van deze groep" remove_owner: "Verwijderen als eigenaar" - remove_owner_description: "%{username} als een eigenaar van deze groep verwijderen" + remove_owner_description: "Verwijder %{username} als eigenaar van deze groep" make_primary: "Primair maken" - make_primary_description: "Dit de primaire groep maken voor %{username}" - remove_primary: "Als primaire verwijderen" - remove_primary_description: "Deze als de primaire groep voor %{username} verwijderen" + make_primary_description: "Maak dit de primaire groep voor %{username}" + remove_primary: "Verwijderen als primair" + remove_primary_description: "Verwijder deze als primaire groep voor %{username}" remove_members: "Leden verwijderen" - remove_members_description: "Geselecteerde gebruikers verwijderen uit deze groep" - make_owners: "Eigenaren maken" - make_owners_description: "Geselecteerde gebruikers eigenaren van deze groep maken" + remove_members_description: "Verwijder de geselecteerde gebruikers uit deze groep" + make_owners: "Eigenaar maken" + make_owners_description: "Maak de geselecteerde gebruikers eigenaar van deze groep" remove_owners: "Eigenaren verwijderen" - remove_owners_description: "Geselecteerde gebruikers verwijderen als eigenaren van deze groep" + remove_owners_description: "Verwijder de geselecteerde gebruikers als eigenaar van deze groep" make_all_primary: "Alles primair maken" - make_all_primary_description: "Dit de primaire groep maken voor alle geselecteerde gebruikers." - remove_all_primary: "Alles als primair verwijderen" - remove_all_primary_description: "Deze groep verwijderen als primair" + make_all_primary_description: "Maak dit de primaire groep voor alle geselecteerde gebruikers." + remove_all_primary: "Verwijderen als primair" + remove_all_primary_description: "Verwijder deze groep als primair" owner: "Eigenaar" primary: "Primair" - forbidden: "U mag de leden niet bekijken." + forbidden: "Je mag de leden niet bekijken." topics: "Topics" posts: "Berichten" mentions: "Vermeldingen" messages: "Berichten" notification_level: "Standaard meldingsniveau voor groepsberichten" alias_levels: - mentionable: "Wie kan deze groep taggen?" + mentionable: "Wie kan deze groep @noemen?" messageable: "Wie kan deze groep een bericht sturen?" nobody: "Niemand" only_admins: "Alleen beheerders" @@ -812,39 +812,39 @@ nl: everyone: "Iedereen" notifications: watching: - title: "In de gaten houden" - description: "U ontvangt een melding bij elk nieuw bericht, en het aantal nieuwe antwoorden wordt weergeven." + title: "Geobserveerd" + description: "Je ontvangt een melding bij elk nieuw bericht en het aantal nieuwe antwoorden wordt weergeven." watching_first_post: - title: "Eerste bericht in de gaten houden" - description: "U ontvangt meldingen van nieuwe berichten in deze groep, maar niet van antwoorden op de berichten." + title: "Eerste bericht geobserveerd" + description: "Je ontvangt meldingen van nieuwe berichten in deze groep, maar niet van antwoorden op de berichten." tracking: title: "Volgen" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt, en het aantal nieuwe antwoorden wordt weergeven." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt en het aantal nieuwe antwoorden wordt weergeven." regular: title: "Normaal" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." muted: - title: "Genegeerd" - description: "U ontvangt geen enkele melding over berichten in deze groep." - flair_url: "Afbeelding voor avatar-flair" + title: "Gedempt" + description: "Je ontvangt geen meldingen over berichten in deze groep." + flair_url: "Afbeelding voor avatarflair" flair_upload_description: "Gebruik vierkante afbeeldingen, niet kleiner dan 20px bij 20px." - flair_bg_color: "Achtergrondkleur van avatar-flair" - flair_bg_color_placeholder: "(Optioneel) Hex-kleurwaarde" - flair_color: "Kleur van avatar-flair" - flair_color_placeholder: "(Optioneel) Hex-kleurwaarde" + flair_bg_color: "Achtergrondkleur van avatarflair" + flair_bg_color_placeholder: "(Optioneel) Hexadecimale kleurwaarde" + flair_color: "Kleur van avatarflair" + flair_color_placeholder: "(Optioneel) Hexadecimale kleurwaarde" flair_preview_icon: "Pictogramvoorbeeld" - flair_preview_image: "Afbeeldingsvoorbeeld" + flair_preview_image: "Voorbeeld van afbeelding" flair_type: - icon: "Een pictogram selecteren" - image: "Een afbeelding uploaden" + icon: "Selecteer een pictogram" + image: "Upload een afbeelding" default_notifications: - modal_description: "Wilt u deze wijziging op het verleden toepassen? Hierdoor worden voor %{count} bestaande gebruikers voorkeuren gewijzigd." + modal_description: "Wil je deze wijziging met terugwerkende kracht toepassen? Hierdoor worden voorkeuren gewijzigd voor %{count} bestaande gebruikers." modal_yes: "Ja" modal_no: "Nee, wijziging alleen vanaf nu toepassen" user_action_groups: - "1": "Gegeven likes" - "2": "Ontvangen likes" - "3": "Favorieten" + "1": "Likes" + "2": "Likes" + "3": "Bladwijzers" "4": "Topics" "5": "Antwoorden" "6": "Reacties" @@ -853,17 +853,17 @@ nl: "11": "Bewerkingen" "12": "Verzonden items" "13": "Inbox" - "14": "In wachtrij" + "14": "Wachtend" "15": "Concepten" categories: all: "alle categorieën" all_subcategories: "alles" no_subcategory: "geen" category: "Categorie" - category_list: "Categorielijst weergeven" + category_list: "Categorieënlijst weergeven" reorder: - title: "Categorieën herschikken" - title_long: "De categorielijst opnieuw ordenen" + title: "Categorieën herordenen" + title_long: "Categorieënlijst herordenen" save: "Volgorde opslaan" apply_all: "Toepassen" position: "Positie" @@ -871,7 +871,7 @@ nl: topics: "Topics" latest: "Nieuwste" subcategories: "Subcategorieën" - muted: "Genegeerde categorieën" + muted: "Gedempte categorieën" topic_sentence: one: "%{count} topic" other: "%{count} topics" @@ -885,11 +885,11 @@ nl: one: "%{number} totaal" other: "%{number} totaal" topic_stat_sentence_week: - one: "%{count} nieuw topic in de afgelopen week." - other: "%{count} nieuwe topics in de afgelopen week." + one: "%{count} nieuw topic de afgelopen week." + other: "%{count} nieuwe topics de afgelopen week." topic_stat_sentence_month: - one: "%{count} nieuw topic in de afgelopen maand." - other: "%{count} nieuwe topics in de afgelopen maand." + one: "%{count} nieuw topic de afgelopen maand." + other: "%{count} nieuwe topics de afgelopen maand." n_more: "Categorieën (nog %{count})..." ip_lookup: title: IP-adres zoeken @@ -897,16 +897,16 @@ nl: location: Locatie location_not_found: (onbekend) organisation: Organisatie - phone: Telefoon + phone: Telefoonnummer other_accounts: "Andere accounts met dit IP-adres:" delete_other_accounts: "%{count} verwijderen" username: "gebruikersnaam" trust_level: "TL" read_time: "leestijd" topics_entered: "topics ingevoerd" - post_count: "# berichten" - confirm_delete_other_accounts: "Weet u zeker dat u deze accounts wilt verwijderen?" - powered_by: "gebruikt MaxMindDB" + post_count: "Aantal berichten" + confirm_delete_other_accounts: "Weet je zeker dat je deze accounts wilt verwijderen?" + powered_by: "met MaxMindDB" copied: "gekopieerd" user_fields: none: "(selecteer een optie)" @@ -914,70 +914,70 @@ nl: user: said: "%{username}:" profile: "Profiel" - mute: "Negeren" + mute: "Dempen" edit: "Voorkeuren bewerken" download_archive: button_text: "Alles downloaden" - confirm: "Weet u zeker dat u uw berichten wilt downloaden?" - success: "Downloaden is gestart; u ontvangt een melding zodra het proces is voltooid." - rate_limit_error: "Berichten kunnen maar één keer per dag worden gedownload; probeer het morgen opnieuw." + confirm: "Weet je zeker dat je je berichten wilt downloaden?" + success: "Download is gestart, je ontvangt een melding zodra het proces is voltooid." + rate_limit_error: "Berichten kunnen één keer per dag worden gedownload. Probeer het morgen opnieuw." new_private_message: "Nieuw bericht" private_message: "Bericht" private_messages: "Berichten" user_notifications: filters: filter_by: "Filteren op" - all: "Alle" + all: "Alles" read: "Gelezen" unread: "Ongelezen" - unseen: "Ongelezen" + unseen: "Ongezien" ignore_duration_title: "Gebruiker negeren" ignore_duration_username: "Gebruikersnaam" - ignore_duration_when: "Tijdsduur:" + ignore_duration_when: "Duur:" ignore_duration_save: "Negeren" - ignore_duration_note: "Houd er rekening mee dat alle negeeracties na het verlopen van de tijdsduur automatisch worden verwijderd." + ignore_duration_note: "Houd er rekening mee dat alle negeeracties na het verlopen van de negeerduur automatisch worden verwijderd." ignore_duration_time_frame_required: "Selecteer een tijdsbestek" - ignore_no_users: "U hebt geen genegeerde gebruikers." + ignore_no_users: "Je hebt geen genegeerde gebruikers." ignore_option: "Genegeerd" - ignore_option_title: "U ontvangt geen meldingen met betrekking tot deze gebruiker, en alle topics en antwoorden ervan worden verborgen." + ignore_option_title: "Je ontvangt geen meldingen met betrekking tot deze gebruiker en alle topics en antwoorden ervan worden verborgen." add_ignored_user: "Toevoegen..." mute_option: "Gedempt" - mute_option_title: "U ontvangt geen meldingen met betrekking tot deze gebruiker." + mute_option_title: "Je ontvangt geen meldingen met betrekking tot deze gebruiker." normal_option: "Normaal" - normal_option_title: "U ontvangt een melding als deze gebruiker een bericht van u beantwoordt, u citeert, of uw naam noemt." + normal_option_title: "Je ontvangt een melding als deze gebruiker een bericht van je beantwoordt, je citeert, of je naam noemt." notification_schedule: title: "Meldingsschema" label: "Aangepast meldingsschema inschakelen" - tip: "Buiten deze uren wordt u automatisch op 'niet storen' gezet." + tip: "Buiten deze tijden word je automatisch op 'niet storen' gezet." midnight: "Middernacht" none: "Geen" monday: "Maandag" tuesday: "Dinsdag" - wednesday: "woensdag" - thursday: "donderdag" - friday: "vrijdag" - saturday: "zaterdag" - sunday: "zondag" - to: "aan" + wednesday: "Woensdag" + thursday: "Donderdag" + friday: "Vrijdag" + saturday: "Zaterdag" + sunday: "Zondag" + to: "tot" activity_stream: "Activiteit" read: "Gelezen" - read_help: "Onlangs gelezen topics" + read_help: "Recent gelezen topics" preferences: "Voorkeuren" feature_topic_on_profile: open_search: "Selecteer een nieuw topic" - title: "Een topic selecteren" - search_label: "Zoeken naar topic op titel" + title: "Selecteer een topic" + search_label: "Topic zoeken op titel" save: "Opslaan" clear: title: "Wissen" - warning: "Weet u zeker dat u uw aanbevolen topic wilt wissen?" + warning: "Weet je zeker dat je je uitgelichte topic wilt wissen?" use_current_timezone: "Huidige tijdzone gebruiken" profile_hidden: "Het openbare profiel van deze gebruiker is verborgen." expand_profile: "Uitvouwen" sr_expand_profile: "Profieldetails uitvouwen" collapse_profile: "Samenvouwen" - sr_collapse_profile: "Profieldetails inklappen" - bookmarks: "Favorieten" + sr_collapse_profile: "Profieldetails samenvouwen" + bookmarks: "Bladwijzers" bio: "Over mij" timezone: "Tijdzone" invited_by: "Uitgenodigd door" @@ -986,41 +986,41 @@ nl: statistics: "Statistieken" desktop_notifications: label: "Livemeldingen" - not_supported: "Meldingen worden in deze browser niet ondersteund. Sorry." + not_supported: "Meldingen worden niet ondersteund in deze browser. Sorry." perm_default: "Meldingen inschakelen" perm_denied_btn: "Toestemming geweigerd" - perm_denied_expl: "U hebt toestemming voor meldingen geweigerd. Sta meldingen toe via uw browserinstellingen." + perm_denied_expl: "Je hebt toestemming voor meldingen geweigerd. Sta meldingen toe via je browserinstellingen." disable: "Meldingen uitschakelen" enable: "Meldingen inschakelen" - each_browser_note: 'Opmerking: U moet deze instelling wijzigen in elke browser die u gebruikt. Alle meldingen worden uitgeschakeld wanneer u zich in "niet storen" bevindt, ongeacht deze instelling.' - consent_prompt: "Wilt u livemeldingen ontvangen als mensen op uw berichten antwoorden?" + each_browser_note: 'Opmerking: je moet deze instelling wijzigen in elke browser die je gebruikt. Alle meldingen worden uitgeschakeld wanneer je op ''niet storen'' staat, ongeacht deze instelling.' + consent_prompt: "Wil je live meldingen ontvangen als mensen antwoorden op je berichten?" dismiss: "Negeren" - dismiss_notifications: "Alle verwijderen" + dismiss_notifications: "Alles negeren" dismiss_notifications_tooltip: "Alle ongelezen meldingen markeren als gelezen" - no_messages_title: "U hebt geen berichten" + no_messages_title: "Je hebt geen berichten" no_messages_body: > - Wilt u een direct persoonlijk gesprek met iemand hebben, buiten de normale gespreksstroom? Stuur ze een bericht door hun avatar te selecteren en de %{icon} berichtknop te gebruiken.

    Als u hulp nodig hebt, kunt u een medewerker een bericht sturen. - no_bookmarks_title: "U hebt nog geen bladwijzers gemaakt" + Wil je een direct persoonlijk gesprek met iemand hebben, buiten de normale gespreksstroom? Stuur de persoon een bericht door diens avatar te selecteren en de %{icon} berichtknop te gebruiken.

    Als je hulp nodig hebt, kun je een medewerker een bericht sturen. + no_bookmarks_title: "Je hebt nog geen bladwijzers gemaakt" no_bookmarks_body: > - Begin een bladwijzer te maken voor berichten met de %{icon} knop en ze worden hier weergegeven voor gemakkelijke referentie. U kunt ook een herinnering plannen! - no_bookmarks_search: "Geen bladwijzers gevonden met de opgegeven zoekopdracht." - no_notifications_title: "U hebt nog geen meldingen" - no_notifications_page_title: "U hebt nog geen meldingen" + Begin met bladwijzers voor berichten te maken met de %{icon} knop. Ze worden hier weergegeven voor eenvoudige referentie. Je kunt ook een herinnering plannen! + no_bookmarks_search: "Geen bladwijzers gevonden met de opgegeven zoekcriteria." + no_notifications_title: "Je hebt nog geen meldingen" + no_notifications_page_title: "Je hebt nog geen meldingen" first_notification: "Uw eerste melding! Selecteer deze om te beginnen." - dynamic_favicon: "Aantal op browserpictogram tonen" + dynamic_favicon: "Aantallen weergeven op browserpictogram" skip_new_user_tips: - description: "Onboarding-tips en badges voor nieuwe gebruikers overslaan" - not_first_time: "Niet uw eerste keer?" + description: "Introductietips en badges voor nieuwe gebruikers overslaan" + not_first_time: "Niet je eerste keer?" skip_link: "Deze tips overslaan" - read_later: "Ik zal het later lezen." + read_later: "Ik lees het later." theme_default_on_all_devices: "Dit het standaardthema maken op al mijn apparaten" - color_scheme_default_on_all_devices: "Standaard kleurenschema(’s) op al mijn apparaten instellen" + color_scheme_default_on_all_devices: "Standaard kleurenschema(’s) instellen op al mijn apparaten" color_scheme: "Kleurenschema" color_schemes: default_description: "Standaard voor thema" disable_dark_scheme: "Hetzelfde als normaal" - dark_instructions: "U kunt een voorbeeld van het kleurenschema van de donkere modus bekijken door de donkere modus van uw apparaat om te schakelen." - undo: "Terugzetten" + dark_instructions: "Je kunt een voorbeeld van het kleurenschema van de donkere modus bekijken door de donkere modus van je apparaat in te schakelen." + undo: "Herstellen" regular: "Normaal" dark: "Donkere modus" default_dark_scheme: "(standaard voor website)" @@ -1028,15 +1028,14 @@ nl: dark_mode_enable: "Automatisch kleurenschema voor donkere modus inschakelen" text_size_default_on_all_devices: "Dit de standaard tekstgrootte maken op al mijn apparaten" allow_private_messages: "Andere gebruikers mogen mij persoonlijke berichten sturen" - external_links_in_new_tab: "Alle externe koppelingen openen in een nieuw tabblad" - enable_quoting: "Antwoord-met-citaat voor gemarkeerde tekst inschakelen" - enable_defer: "Negeren voor markeren van topics als ongelezen inschakelen" + external_links_in_new_tab: "Alle externe links openen op een nieuw tabblad" + enable_quoting: "Antwoorden met citaat inschakelen voor gemarkeerde tekst" + enable_defer: "Uitstellen door als ongelezen te markeren inschakelen" experimental_sidebar: options: "Opties" navigation_section: "Navigatie" - list_destination_default: "Standaard" change: "wijzigen" - featured_topic: "Aanbevolen topic" + featured_topic: "Uitgelicht topic" moderator: "%{user} is een moderator" admin: "%{user} is een beheerder" moderator_tooltip: "Deze gebruiker is een moderator" @@ -1052,60 +1051,60 @@ nl: enabled: "Mailinglijstmodus inschakelen" instructions: | Deze instelling overschrijft de activiteitsamenvatting.
    - Genegeerde topics en categorieën zijn niet in deze e-mails inbegrepen. - individual: "Een e-mail voor elk nieuw bericht verzenden" - individual_no_echo: "Een e-mail voor elk nieuw bericht verzenden, behalve die van mezelf" - many_per_day: "Mij een e-mail voor elk nieuw bericht sturen (ongeveer %{dailyEmailEstimate} per dag)" - few_per_day: "Mij een e-mail voor elk nieuw bericht sturen (ongeveer 2 per dag)" + Gedempte topics en categorieën zijn niet opgenomen in deze e-mails. + individual: "E-mail sturen voor elk nieuw bericht" + individual_no_echo: "E-mail sturen voor elk nieuw bericht, behalve die van mezelf" + many_per_day: "Stuur mij een e-mail voor elk nieuw bericht (ongeveer %{dailyEmailEstimate} per dag)" + few_per_day: "Stuur mij een e-mail voor elk nieuw bericht (ongeveer 2 per dag)" warning: "Mailinglijstmodus ingeschakeld. E-mailmeldingsinstellingen worden genegeerd." tag_settings: "Tags" - watched_tags: "In de gaten gehouden" - watched_tags_instructions: "U houdt automatisch alle nieuwe topics met deze tags in de gaten. U ontvangt meldingen bij alle nieuwe berichten en topics, en het aantal nieuwe berichten verschijnt ook naast het topic." + watched_tags: "Geobserveerd" + watched_tags_instructions: "Je observeert automatisch alle nieuwe topics met deze tags. Je ontvangt meldingen bij alle nieuwe berichten en topics en het aantal nieuwe berichten wordt weergegeven naast het topic." tracked_tags: "Gevolgd" - tracked_tags_instructions: "U volgt automatisch alle topics met deze tags. Het aantal nieuwe berichten verschijnt naast het topic." - muted_tags: "Genegeerd" - muted_tags_instructions: "U ontvangt geen enkele melding over nieuwe topics met deze tags, en ze verschijnen niet in Nieuwste." - watched_categories: "In de gaten gehouden" - watched_categories_instructions: "U houdt automatisch alle nieuwe topics in deze categorieën in de gaten. U ontvangt meldingen bij alle nieuwe berichten en topics, en het aantal nieuwe berichten verschijnt ook naast het topic." + tracked_tags_instructions: "Je volgt automatisch alle topics met deze tags. Het aantal nieuwe berichten wordt weergegeven naast het topic." + muted_tags: "Gedempt" + muted_tags_instructions: "Je ontvangt geen meldingen over nieuwe topics met deze tags en deze worden niet weergegeven in Nieuwste." + watched_categories: "Geobserveerd" + watched_categories_instructions: "Je observeert automatisch alle nieuwe topics in deze categorieën. Je ontvangt meldingen bij alle nieuwe berichten en topics en het aantal nieuwe berichten wordt weergegeven naast het topic." tracked_categories: "Gevolgd" - tracked_categories_instructions: "U volgt automatisch alle topics in deze categorieën. Het aantal nieuwe berichten verschijnt naast het topic." - watched_first_post_categories: "Eerste bericht in de gaten houden." - watched_first_post_categories_instructions: "U ontvangt een melding bij het eerste bericht in elk nieuw topic in deze categorieën." - watched_first_post_tags: "Eerste bericht in de gaten houden" - watched_first_post_tags_instructions: "U ontvangt een melding bij het eerste bericht in elk nieuw topic met deze tags." - muted_categories: "Genegeerd" - muted_categories_instructions: "U ontvangt geen enkele melding over nieuwe topics en berichten in deze categorieën, en ze verschijnen niet op de pagina's Categorieën of Nieuwste." - muted_categories_instructions_dont_hide: "U ontvangt geen enkele melding over nieuwe topics in deze categorieën." + tracked_categories_instructions: "Je volgt automatisch alle topics in deze categorieën. Het aantal nieuwe berichten wordt weergegeven naast het topic." + watched_first_post_categories: "Eerste bericht geobserveerd" + watched_first_post_categories_instructions: "Je ontvangt een melding bij het eerste bericht in elk nieuw topic in deze categorieën." + watched_first_post_tags: "Eerste bericht geobserveerd" + watched_first_post_tags_instructions: "Je ontvangt een melding bij het eerste bericht in elk nieuw topic met deze tags." + muted_categories: "Gedempt" + muted_categories_instructions: "Je ontvangt geen meldingen over nieuwe topics en berichten in deze categorieën en deze worden niet weergegeven op de pagina's Categorieën of Nieuwste." + muted_categories_instructions_dont_hide: "Je ontvangt geen meldingen over nieuwe topics in deze categorieën." regular_categories: "Normaal" - regular_categories_instructions: "Deze categorieën ziet u in de topiclijsten ‘Nieuwste’ en ‘Top’." - no_category_access: "Als moderator hebt u beperkte toegang tot categorieën, opslaan is uitgeschakeld." + regular_categories_instructions: "Deze categorieën zie je in de topiclijsten 'Nieuwste’ en ‘Top’." + no_category_access: "Als moderator heb je beperkte toegang tot categorieën, opslaan is uitgeschakeld." delete_account: "Mijn account verwijderen" - delete_account_confirm: "Weet u zeker dat u uw account definitief wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt!" - deleted_yourself: "Uw account is succesvol verwijderd." - delete_yourself_not_allowed: "Neem contact op met een staflid als u wilt dat uw account wordt verwijderd." + delete_account_confirm: "Weet je zeker dat je je account definitief wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt!" + deleted_yourself: "Uw account is verwijderd." + delete_yourself_not_allowed: "Neem contact op met een staflid als je wilt dat je account wordt verwijderd." unread_message_count: "Berichten" admin_delete: "Verwijderen" users: "Gebruikers" - muted_users: "Genegeerd" - muted_users_instructions: "Alle meldingen en PB's van deze gebruikers onderdrukken." + muted_users: "Gedempt" + muted_users_instructions: "Onderdruk alle meldingen en PB's van deze gebruikers." allowed_pm_users: "Toegestaan" - allowed_pm_users_instructions: "Alleen PB's van deze gebruikers toestaan." + allowed_pm_users_instructions: "Sta alleen PB's van deze gebruikers toe." allow_private_messages_from_specific_users: "Alleen bepaalde gebruikers mogen mij persoonlijke berichten sturen" ignored_users: "Genegeerd" - ignored_users_instructions: "Alle berichten, meldingen en PB's van deze gebruikers onderdrukken." - tracked_topics_link: "Tonen" - automatically_unpin_topics: "Topics automatisch losmaken als ik de onderkant bereik" + ignored_users_instructions: "Onderdruk alle berichten, meldingen en PB's van deze gebruikers." + tracked_topics_link: "Weergeven" + automatically_unpin_topics: "Topics automatisch losmaken als ik de onderkant bereik." apps: "Apps" revoke_access: "Toegang intrekken" undo_revoke_access: "Toegang intrekken ongedaan maken" api_approved: "Goedgekeurd:" api_last_used_at: "Laatst gebruikt op:" theme: "Thema" - save_to_change_theme: 'Thema wordt bijgewerkt nadat u op ''%{save_text}'' klikt' + save_to_change_theme: 'Thema wordt bijgewerkt nadat je op ''%{save_text}'' klikt' home: "Standaard startpagina" - staged: "Staged" + staged: "Gefaseerd" staff_counters: - flags_given: "behulpzame markeringen" + flags_given: "nuttige markeringen" flagged_posts: "gemarkeerde berichten" deleted_posts: "verwijderde berichten" suspensions: "schorsingen" @@ -1113,7 +1112,7 @@ nl: rejected_posts: "geweigerde berichten" messages: all: "alle inboxen" - inbox: "Postvak IN" + inbox: "Inbox" personal: "Persoonlijk" latest: "Nieuwste" sent: "Verzonden" @@ -1127,9 +1126,9 @@ nl: other: "Nieuw (%{count})" archive: "Archief" groups: "Mijn groepen" - move_to_inbox: "Verplaatsen naar Postvak IN" + move_to_inbox: "Verplaatsen naar inbox" move_to_archive: "Archiveren" - failed_to_move: "Het verplaatsen van geselecteerde berichten is niet gelukt (misschien is uw netwerkverbinding verbroken)" + failed_to_move: "Verplaatsen van geselecteerde berichten mislukt (mogelijk is je netwerkverbinding verbroken)" tags: "Tags" all_tags: "Alle tags" warnings: "Officiële waarschuwingen" @@ -1146,31 +1145,31 @@ nl: apps: "Apps" change_password: success: "(e-mail verzonden)" - in_progress: "(e-mail wordt verzonden)" + in_progress: "(e-mail verzenden)" error: "(fout)" emoji: "slot-emoji" - action: "E-mail voor wachtwoordherinitialisatie verzenden" + action: "E-mail voor wachtwoordherstel verzenden" set_password: "Wachtwoord instellen" choose_new: "Kies een nieuw wachtwoord" choose: "Kies een wachtwoord" second_factor_backup: - title: "Tweefactor-back-upcodes" + title: "Back-upcodes voor tweeledige verificatie" regenerate: "Opnieuw genereren" disable: "Uitschakelen" enable: "Inschakelen" enable_long: "Back-upcodes inschakelen" manage: - one: "Back-upcodes beheren. U hebt %{count} back-upcode over." - other: "Back-upcodes beheren. U hebt %{count} back-upcodes over." + one: "Back-upcodes beheren. Je hebt nog %{count} back-upcode over." + other: "Back-upcodes beheren. Je hebt nog %{count} back-upcodes over." copy_to_clipboard: "Kopiëren naar klembord" copy_to_clipboard_error: "Fout bij kopiëren van gegevens naar klembord" copied_to_clipboard: "Gekopieerd naar klembord" download_backup_codes: "Back-upcodes downloaden" remaining_codes: - one: "U hebt %{count} back-upcode over." - other: "U hebt %{count} back-upcodes over." + one: "Je hebt nog %{count} back-upcode over." + other: "Je hebt nog %{count} back-upcodes over." use: "Een back-upcode gebruiken" - enable_prerequisites: "U moet een primaire tweefactormethode inschakelen voordat u back-upcodes genereert." + enable_prerequisites: "Je moet een primaire methode voor tweeledige verificatie inschakelen voordat je back-upcodes genereert." codes: title: "Back-upcodes gegenereerd" description: "Elke back-upcode kan maar één keer worden gebruikt. Bewaar ze op een veilige maar toegankelijke plek." @@ -1182,7 +1181,7 @@ nl: confirm_password_description: "Bevestig uw wachtwoord om door te gaan" name: "Naam" label: "Code" - rate_limit: "Wacht even voordat u een andere authenticatiecode probeert." + rate_limit: "Wacht even voordat je een andere verificatiecode probeert." enable_description: | Scan deze QR-code in een ondersteunde app (AndroidiOS) en voer uw authenticatiecode in. disable_description: "Voer de authenticatiecode van uw app in" @@ -1193,9 +1192,9 @@ nl: Tweefactorauthenticatie voegt extra beveiliging toe aan uw account door naast uw wachtwoord een eenmalige code te vereisen. Tokens kunnen op Android- en iOS -apparaten worden gegenereerd. oauth_enabled_warning: "Houd er rekening mee dat sociale aanmeldingen worden uitgeschakeld zodra tweefactorauthenticatie op uw account is ingeschakeld." use: "Authenticator-app gebruiken" - enforced_notice: "U dient tweefactorauthenticatie in te schakelen voordat u deze website bezoekt." + enforced_notice: "Je moet tweeledige verificatie inschakelen voordat je deze website bezoekt." disable: "Uitschakelen" - disable_confirm: "Weet u zeker dat u alle tweefactormethoden wilt uitschakelen?" + disable_confirm: "Weet je zeker dat je alle methoden voor tweeledige verificatie wilt uitschakelen?" save: "Opslaan" edit: "Bewerken" edit_title: "Authenticator bewerken" @@ -1204,81 +1203,81 @@ nl: title: "Op tokens gebaseerde authenticators" add: "Authenticator toevoegen" default_name: "Mijn authenticator" - name_and_code_required_error: "U moet een naam en de code van uw authenticator-app opgeven." + name_and_code_required_error: "Je moet een naam en de code van je authenticator-app opgeven." security_key: register: "Registreren" default_name: "Hoofdbeveiligingssleutel" iphone_default_name: "iPhone" android_default_name: "Android" not_allowed_error: "Het registratieproces van de beveiligingssleutel had een time-out of is geannuleerd." - already_added_error: "U hebt deze beveiligingssleutel al geregistreerd. U hoeft deze niet opnieuw te registreren." + already_added_error: "Je hebt deze beveiligingssleutel al geregistreerd. Je hoeft deze niet opnieuw te registreren." save: "Opslaan" - name_required_error: "U moet een naam voor uw beveiligingssleutel opgeven." + name_required_error: "Je moet een naam voor je beveiligingssleutel opgeven." change_about: title: "Over mij wijzigen" error: "Er is een fout opgetreden bij het wijzigen van deze waarde." change_username: title: "Gebruikersnaam wijzigen" - confirm: "Weet u absoluut zeker dat u uw gebruikersnaam wilt wijzigen?" - taken: "Sorry, maar die gebruikersnaam is al in gebruik." - invalid: "Die gebruikersnaam is ongeldig. Hij mag alleen cijfers en letters bevatten." + confirm: "Weet je zeker dat je je gebruikersnaam wilt wijzigen?" + taken: "Sorry, die gebruikersnaam is al in gebruik." + invalid: "Die gebruikersnaam is ongeldig. De naam mag alleen cijfers en letters bevatten." add_email: title: "E-mailadres toevoegen" add: "toevoegen" change_email: title: "E-mailadres wijzigen" taken: "Sorry, dat e-mailadres is niet beschikbaar." - error: "Er is een fout opgetreden bij het wijzigen van uw e-mailadres. Misschien is dat adres al in gebruik?" - success: "We hebben een e-mail naar dat adres gestuurd. Volg de instructies voor bevestiging." - success_via_admin: "We hebben een e-mail naar dat adres gestuurd. Volg de instructies voor bevestiging in de e-mail." - success_staff: "Er is een e-mail naar uw huidige adres verzonden. Volg de bevestigingsinstructies." + error: "Er is een fout opgetreden bij het wijzigen van je e-mailadres. Misschien is het adres al in gebruik?" + success: "We hebben een e-mail naar dat adres gestuurd. Volg de bevestigingsinstructies." + success_via_admin: "We hebben een e-mail naar dat adres gestuurd. Volg de bevestigingsinstructies in de e-mail." + success_staff: "We hebben een e-mail naar je huidige adres gestuurd. Volg de bevestigingsinstructies." change_avatar: title: "Uw profielafbeelding wijzigen" gravatar: "%{gravatarName}, gebaseerd op" - gravatar_title: "Wijzig uw avatar op de website van %{gravatarName}" - gravatar_failed: "We konden geen %{gravatarName} voor dat e-mailadres vinden." + gravatar_title: "Wijzig je avatar op de website van %{gravatarName}" + gravatar_failed: "We konden geen %{gravatarName} vinden voor dat e-mailadres." refresh_gravatar_title: "Uw %{gravatarName} vernieuwen" letter_based: "Door systeem toegekende profielafbeelding" uploaded_avatar: "Eigen afbeelding" - uploaded_avatar_empty: "Een eigen afbeelding toevoegen" - upload_title: "Uw afbeelding uploaden" - image_is_not_a_square: "Waarschuwing: we hebben uw afbeelding bijgesneden; breedte en hoogte waren niet gelijk." + uploaded_avatar_empty: "Voeg een eigen afbeelding toe" + upload_title: "Upooad je afbeelding" + image_is_not_a_square: "Waarschuwing: we hebben je afbeelding bijgesneden; de breedte en hoogte waren niet gelijk." logo_small: "Het kleine logo van de website. Standaard gebruikt." change_profile_background: title: "Profielkoptekst" - instructions: "Profielkopteksten worden gecentreerd en hebben een standaardbreedte van 1110px." + instructions: "Profielkopteksten worden gecentreerd en hebben een standaardbreedte van 1110 pixels." change_card_background: title: "Achtergrond van gebruikerskaart" - instructions: "Achtergrondafbeeldingen worden gecentreerd en hebben een standaardbreedte van 590px." + instructions: "Achtergrondafbeeldingen worden gecentreerd en hebben een standaardbreedte van 590 pixels." change_featured_topic: - title: "Aanbevolen topic" - instructions: "Er wordt een koppeling naar dit topic op uw gebruikerskaart en profiel geplaatst." + title: "Uitgelicht topic" + instructions: "Er wordt een link naar dit topic op je gebruikerskaart en profiel geplaatst." email: - title: "E-mail" + title: "E-mailadres" primary: "Primair e-mailadres" - secondary: "Extra e-mailadressen" - primary_label: "primaire" + secondary: "Secundaire e-mailadressen" + primary_label: "primair" unconfirmed_label: "onbevestigd" - resend_label: "bevestigingsmail opnieuw verzenden" + resend_label: "bevestigings-e-mail opnieuw verzenden" resending_label: "verzenden..." resent_label: "e-mail verzonden" update_email: "E-mailadres wijzigen" set_primary: "Primair e-mailadres instellen" destroy: "E-mailadres verwijderen" add_email: "Alternatief e-mailadres toevoegen" - auth_override_instructions: "E-mailadres kan worden bijgewerkt vanaf de authenticatieprovider." - no_secondary: "Geen extra e-mailadressen" + auth_override_instructions: "E-mailadres kan worden bijgewerkt via de authenticatieprovider." + no_secondary: "Geen secundaire e-mailadressen" instructions: "Nooit openbaar zichtbaar." - admin_note: "Opmerking: een beheerder die het e-mailadres van een andere niet-beheerder wijzigt, geeft aan dat de gebruiker geen toegang meer heeft tot zijn of haar e-mailaccount, dus er wordt een e-mail voor opnieuw instellen van het wachtwoord naar zijn of haar nieuwe adres gestuurd. Het e-mailadres van de gebruiker wordt pas gewijzigd nadat hij of zij het proces voor opnieuw instellen van het wachtwoord heeft voltooid." + admin_note: "Opmerking: een beheerder die het e-mailadres van een andere niet-beheerder wijzigt, geeft aan dat de gebruiker geen toegang meer heeft tot diens e-mailaccount, dus wordt er een e-mail voor wachtwoordherstel naar diens nieuwe adres gestuurd. Het e-mailadres van de gebruiker wordt pas gewijzigd nadat deze het proces voor wachtwoordherstel heeft voltooid." ok: "We sturen een e-mail ter bevestiging" required: "Voer een e-mailadres in" invalid: "Voer een geldig e-mailadres in" - authenticated: "Uw e-mailadres is geauthenticeerd door %{provider}" - invite_auth_email_invalid: "Uw uitnodigingsmail komt niet overeen met het e-mailadres dat door %{provider} is geverifieerd" - frequency_immediately: "Als u de inhoud in kwestie nog niet hebt gelezen, sturen we u direct een e-mail." + authenticated: "Uw e-mailadres is geverifieerd door %{provider}" + invite_auth_email_invalid: "Uw uitnodigings-e-mail komt niet overeen met het e-mailadres dat door %{provider} is geverifieerd" + frequency_immediately: "Als je de inhoud in kwestie nog niet hebt gelezen, sturen we je direct een e-mail." frequency: - one: "We sturen alleen een e-mail als we u de laatste minuut niet hebben gezien." - other: "We sturen alleen een e-mail als we u de laatste %{count} minuten niet hebben gezien." + one: "We sturen alleen een e-mail als we je de afgelopen minuut niet hebben gezien." + other: "We sturen alleen een e-mail als we je de afgelopen %{count} minuten niet hebben gezien." associated_accounts: title: "Gekoppelde accounts" connect: "Verbinden" @@ -1287,8 +1286,8 @@ nl: not_connected: "(niet gekoppeld)" confirm_modal_title: "%{provider}-account koppelen" confirm_description: - account_specific: "Uw %{provider}-account '%{account_description}' wordt voor authenticatie gebruikt." - generic: "Uw %{provider}-account wordt voor authenticatie gebruikt." + account_specific: "Uw %{provider}-account '%{account_description}' wordt gebruikt voor verificatie." + generic: "Uw %{provider}-account wordt gebruikt voor verificatie." name: title: "Naam" instructions: "uw volledige naam (optioneel)" @@ -1299,7 +1298,7 @@ nl: username: title: "Gebruikersnaam" instructions: "uniek, geen spaties, kort" - short_instructions: "Mensen kunnen u vermelden als @%{username}" + short_instructions: "Mensen kunnen je vermelden als @%{username}" available: "Uw gebruikersnaam is beschikbaar" not_available: "Niet beschikbaar. %{suggestion} proberen?" not_available_no_suggestion: "Niet beschikbaar" @@ -1310,8 +1309,8 @@ nl: required: "Voer een gebruikersnaam in" edit: "Gebruikersnaam bewerken" locale: - title: "Taal van interface" - instructions: "Taal van de gebruikersinterface. Deze verandert zodra u de pagina opnieuw laadt." + title: "Interfacetaal" + instructions: "Taal van de gebruikersinterface. Deze verandert wanneer de pagina wordt vernieuwd." default: "(standaard)" any: "alle" password_confirmation: @@ -1326,11 +1325,11 @@ nl: not_you: "Niet u?" show_all: "Alles tonen (%{count})" show_few: "Minder tonen" - was_this_you: "Was u dit?" - was_this_you_description: "Als u dit niet was, raden we aan om uw wachtwoord te wijzigen en u overal af te melden." + was_this_you: "Was jij dit?" + was_this_you_description: "Als jij het niet was, raden we je aan om je wachtwoord te wijzigen en je overal af te melden." browser_and_device: "%{browser} op %{device}" secure_account: "Mijn account beveiligen" - latest_post: "U hebt voor het laatst een bericht geplaatst…" + latest_post: "Je hebt als laatste geplaatst…" device_location: '%{device}%{location}' browser_active: '%{browser} | nu actief' browser_last_seen: "%{browser} | %{date}" @@ -1358,37 +1357,37 @@ nl: title: "Een melding sturen wanneer geliket" always: "Altijd" first_time_and_daily: "De eerste keer dat een bericht is geliket en dagelijks" - first_time: "De eerste keer dat een bericht is geliket" + first_time: "Eerste keer dat een bericht is geliket" never: "Nooit" email_previous_replies: - title: "Vorige antwoorden onder e-mails bijvoegen" + title: "Eerdere antwoorden bijvoegen onderaan e-mails" unless_emailed: "tenzij eerder verzonden" always: "altijd" never: "nooit" email_digests: - title: "Als ik hier niet kom, mij een e-mailsamenvatting van populaire topics en antwoorden sturen" + title: "Als ik hier niet kom, stuur me dan een e-mailsamenvatting van populaire topics en antwoorden" every_30_minutes: "elke 30 minuten" every_hour: "elk uur" daily: "dagelijks" weekly: "wekelijks" - every_month: "elke maand" + every_month: "maandelijks" every_six_months: "elke zes maanden" email_level: always: "altijd" only_when_away: "alleen wanneer afwezig" never: "nooit" - include_tl0_in_digests: "Bijdragen van nieuwe gebruikers in e-mailsamenvattingen bijvoegen" - email_in_reply_to: "Fragment van antwoord op bericht in e-mails bijvoegen" - other_settings: "Overige" + include_tl0_in_digests: "Content van nieuwe gebruikers opnemen in samenvattings-e-mails" + email_in_reply_to: "Fragment van antwoord op bericht opnemen in e-mails" + other_settings: "Overig" categories_settings: "Categorieën" new_topic_duration: - label: "Topics als nieuw beschouwen wanneer" + label: "Topics als nieuw beschouwen als" not_viewed: "ik ze nog niet heb bekeken" - last_here: "ze sinds mijn laatste bezoek zijn aangemaakt" - after_1_day: "ze de afgelopen dag zijn aangemaakt" - after_2_days: "ze de afgelopen 2 dagen zijn aangemaakt" - after_1_week: "ze de afgelopen week zijn aangemaakt" - after_2_weeks: "ze de afgelopen 2 weken zijn aangemaakt" + last_here: "ze sinds mijn laatste bezoek zijn gemaakt" + after_1_day: "ze de afgelopen dag zijn gemaakt" + after_2_days: "ze de afgelopen 2 dagen zijn gemaakt" + after_1_week: "ze de afgelopen week zijn gemaakt" + after_2_weeks: "ze de afgelopen 2 weken zijn gemaakt" auto_track_topics: "Automatisch topics volgen die ik heb bezocht" auto_track_options: never: "nooit" @@ -1410,16 +1409,16 @@ nl: redeemed_tab: "Verzilverd" redeemed_tab_with_count: "Verzilverd (%{count})" invited_via: "Uitnodiging" - invited_via_link: "koppeling %{key} (%{count} / %{max} verzilverd)" + invited_via_link: "link %{key} (%{count} / %{max} verzilverd)" groups: "Groepen" topic: "Topic" - sent: "Aangemaakt/Laatst Verstuurd" + sent: "Gemaakt/laatst verzonden" expires_at: "Verloopt" edit: "Bewerken" remove: "Verwijderen" - copy_link: "Koppeling ophalen" + copy_link: "Link ophalen" reinvite: "E-mail opnieuw versturen" - reinvited: "Uitnodiging opnieuw verstuurd" + reinvited: "Uitnodiging opnieuw verzonden" removed: "Verwijderd" search: "typ om uitnodigingen te zoeken..." user: "Uitgenodigde gebruiker" @@ -1429,61 +1428,61 @@ nl: other: "De eerste %{count} uitnodigingen worden getoond." redeemed: "Verzilverde uitnodigingen" redeemed_at: "Verzilverd" - pending: "Uitstaande uitnodigingen" + pending: "Openstaande uitnodigingen" topics_entered: "Topics bekeken" posts_read_count: "Berichten gelezen" expired: "Deze uitnodiging is verlopen." - remove_all: "Verwijder Verlopen uitnodigingen" - removed_all: "Alle Verlopen uitnodigingen verwijderd!" - remove_all_confirm: "Weet u zeker dat u alle verlopen uitnodigingen wilt verwijderen?" - reinvite_all: "Alle uitnodigingen opnieuw versturen" - reinvite_all_confirm: "Weet u zeker dat u alle uitnodigingen opnieuw wilt versturen?" - reinvited_all: "Alle uitnodigingen zijn verstuurd!" + remove_all: "Verlopen uitnodigingen verwijderen" + removed_all: "Alle verlopen uitnodigingen verwijderd!" + remove_all_confirm: "Weet je zeker dat je alle verlopen uitnodigingen wilt verwijderen?" + reinvite_all: "Alle uitnodigingen opnieuw sturen" + reinvite_all_confirm: "Weet je zeker dat je alle uitnodigingen opnieuw wilt sturen?" + reinvited_all: "Alle uitnodigingen zijn verzonden!" time_read: "Leestijd" days_visited: "Dagen bezocht" - account_age_days: "Leeftijd van account in dagen" + account_age_days: "Accountleeftijd in dagen" create: "Uitnodigen" - generate_link: "Uitnodigingskoppeling maken" - link_generated: "Hier is uw uitnodigingskoppeling!" - valid_for: "De uitnodigingskoppeling is alleen geldig voor dit e-mailadres: %{email}" + generate_link: "Uitnodigingslink maken" + link_generated: "Hier is je uitnodigingslink!" + valid_for: "De uitnodigingslink is alleen geldig voor dit e-mailadres: %{email}" single_user: "Uitnodigen via e-mail" - multiple_user: "Uitnodigen via koppeling" + multiple_user: "Uitnodigen via link" invite_link: - title: "Uitnodigingskoppeling" - success: "Uitnodigingskoppeling succesvol gegenereerd!" - error: "Er is een fout opgetreden bij het genereren van de uitnodigingskoppeling" + title: "Uitnodigingslink" + success: "Uitnodigingslink gegenereerd!" + error: "Er is een fout opgetreden bij het genereren van de uitnodigingslink" invite: new_title: "Uitnodiging maken" edit_title: "Uitnodiging bewerken" - instructions: "Deze koppeling delen om meteen toegang tot deze website te verlenen:" - copy_link: "koppeling kopiëren" - show_advanced: "Geavanceerde opties tonen" + instructions: "Deel deze link om direct toegang te geven tot deze site:" + copy_link: "link kopiëren" + show_advanced: "Geavanceerde opties weergeven" hide_advanced: "Geavanceerde opties verbergen" - email_or_domain_placeholder: "naam@example.com of example.com" + email_or_domain_placeholder: "naam@voorbeeld.com of voorbeeld.com" add_to_groups: "Toevoegen aan groepen" expires_at: "Verloopt na" custom_message: "Optioneel persoonlijk bericht" - send_invite_email: "E-mail opslaan en versturen" + send_invite_email: "E-mail opslaan en verzenden" save_invite: "Uitnodiging opslaan" invite_saved: "Uitnodiging opgeslagen." bulk_invite: none: "Geen uitnodigingen om weer te geven op deze pagina." text: "Bulkuitnodiging" instructions: | -

    Nodig een lijst met gebruikers uit om uw community snel op gang te helpen. Bereid een CSV-bestand voor met ten minste één rij per e-mailadres van gebruikers die u wilt uitnodigen. De volgende door komma's gescheiden informatie kan worden verstrekt als u mensen aan groepen wilt toevoegen of ze naar een specifiek topic wilt sturen de eerste keer dat ze zich aanmelden.

    -
    john@smith.com,eerste_groepsnaam;tweede_groepsnaam,topic_id
    -

    Elk e-mailadres in uw geüploade CSV-bestand zal een uitnodiging opgestuurd krijgen en u zult deze later kunnen beheren.

    +

    Nodig een lijst van gebruikers uit om je community snel op gang te helpen. Stel een CSV-bestand op met minimaal één rij per e-mailadres van gebruikers die je wilt uitnodigen. De volgende door komma's gescheiden gegevens kunnen worden verstrekt als je mensen aan groepen wilt toevoegen of ze naar een specifiek topic wilt sturen de eerste keer dat ze zich aanmelden.

    +
    jan@pietersen.com,eerste_groepsnaam;tweede_groepsnaam,topic_id
    +

    Elk e-mailadres in je geüploade CSV-bestand ontvangt een uitnodiging die je later kunt beheren.

    progress: "%{progress}% geüpload..." - success: "Bestand succesvol geüpload. U ontvangt een melding zodra het proces is voltooid." - error: "Sorry, bestand dient de CSV-indeling te hebben." + success: "Bestand geüpload. Je ontvangt een melding zodra het proces is voltooid." + error: "Sorry, het bestand moet de CSV-indeling hebben." password: title: "Wachtwoord" too_short: "Uw wachtwoord is te kort." - common: "Dat wachtwoord wordt al te vaak gebruikt." - same_as_username: "Uw wachtwoord is hetzelfde als uw gebruikersnaam." - same_as_email: "Uw wachtwoord is hetzelfde als uw e-mailadres." + common: "Dat wachtwoord wordt te vaak gebruikt." + same_as_username: "Je wachtwoord is hetzelfde als je gebruikersnaam." + same_as_email: "Je wachtwoord is hetzelfde als je e-mailadres." ok: "Uw wachtwoord ziet er goed uit." - instructions: "minstens %{count} tekens" + instructions: "minimaal %{count} tekens" required: "Voer een wachtwoord in" summary: title: "Samenvatting" @@ -1512,7 +1511,7 @@ nl: one: "bericht gelezen" other: "berichten gelezen" bookmark_count: - one: "favoriet" + one: "bladwijzer" other: "bladwijzers" top_replies: "Topantwoorden" no_replies: "Nog geen antwoorden." @@ -1523,11 +1522,11 @@ nl: top_badges: "Topbadges" no_badges: "Nog geen badges." more_badges: "Meer badges" - top_links: "Topkoppelingen" - no_links: "Nog geen koppelingen." + top_links: "Toplinks" + no_links: "Nog geen links." most_liked_by: "Meest geliket door" most_liked_users: "Meest geliket" - most_replied_to_users: "Meest geantwoord op" + most_replied_to_users: "Meest op geantwoord" no_likes: "Nog geen likes." top_categories: "Topcategorieën" topics: "Topics" @@ -1544,7 +1543,7 @@ nl: title: title: "Titel" none: "(geen)" - instructions: "komt achter je gebruikersnaam" + instructions: "wordt weergegeven na je gebruikersnaam" flair: none: "(geen)" primary_group: @@ -1569,10 +1568,10 @@ nl: unknown: "Fout" not_found: "Pagina niet gevonden" desc: - network: "Controleer uw verbinding." + network: "Controleer je verbinding." network_fixed: "De verbinding lijkt te zijn hersteld." server: "Foutcode: %{status}" - forbidden: "U mag dat niet bekijken." + forbidden: "Je mag dat niet bekijken." not_found: "Oeps, de toepassing heeft geprobeerd een URL te laden die niet bestaat." unknown: "Er is iets misgegaan." buttons: @@ -1583,16 +1582,16 @@ nl: close: "sluiten" dismiss_error: "Fout negeren" close: "Sluiten" - assets_changed_confirm: "Deze website heeft zojuist een software-upgrade ontvangen. Nu de laatste versie downloaden?" - logout: "U bent afgemeld." + assets_changed_confirm: "Deze website heeft zojuist een software-upgrade gekregen. Nieuwste versie downloaden?" + logout: "Je bent afgemeld." refresh: "Vernieuwen" home: "Hoofdpagina" read_only_mode: - enabled: "Deze website bevindt zich in alleen-lezenmodus. U kunt doorgaan met browsen, maar berichten beantwoorden, likes geven en andere acties zijn momenteel uitgeschakeld." - login_disabled: "Aanmelden is uitgeschakeld zolang de website zich in alleen-lezenmodus bevindt." - logout_disabled: "Afmelden is uitgeschakeld zolang de website zich in alleen-lezenmodus bevindt." + enabled: "Deze website bevindt zich in de alleen-lezenmodus. Je kunt doorgaan met browsen, maar berichten beantwoorden, likes geven en andere acties zijn momenteel uitgeschakeld." + login_disabled: "Aanmelden is uitgeschakeld zolang de website zich in de alleen-lezenmodus bevindt." + logout_disabled: "Afmelden is uitgeschakeld zolang de website zich in de alleen-lezenmodus bevindt." too_few_topics_and_posts_notice_MF: >- - Laten we de discussie starten! Er {currentTopics, plural, one {is # topic} other {zijn # topics}} en {currentPosts, plural, one {# bericht} other {# berichten}}. Bezoekers hebben er meer nodig om te lezen en op te antwoorden – minstens {requiredTopics, plural, one {# topic} other {# topics}} en {requiredPosts, plural, one {# bericht} other {# berichten}} wordt aanbevolen. Alleen stafleden kunnen dit bericht zien. + Laten we de discussie starten! Er {currentTopics, plural, one {is # topic} other {zijn # topics}} en {currentPosts, plural, one {# bericht} other {# berichten}}. Bezoekers hebben er meer nodig om te lezen en op te antwoorden – minimaal {requiredTopics, plural, one {# topic} other {# topics}} en {requiredPosts, plural, one {# bericht} other {# berichten}} wordt aanbevolen. Alleen stafleden kunnen dit bericht zien. too_few_topics_notice_MF: >- Laten we de discussie starten! Er {currentTopics, plural, one {is # topic} other {zijn # topics}}. Bezoekers hebben er meer nodig om te lezen en op te antwoorden – minstens {requiredTopics, plural, one {# topic} other {# topics}} wordt aanbevolen. Alleen stafleden kunnen dit bericht zien. too_few_posts_notice_MF: >- @@ -1619,10 +1618,10 @@ nl: signup_cta: sign_up: "Registreren" hide_forever: "nee, bedankt" - hidden_for_session: "OK, we vragen het u morgen. U kunt ook altijd 'Inloggen' gebruiken om een account aan te maken." - intro: "Hallo! Zo te zien beleeft u plezier aan de discussie, maar hebt u zich nog niet voor een account geregistreerd." + hidden_for_session: "OK, we vragen het je morgen. Je kunt ook altijd 'Aanmelden' gebruiken om een account te maken." + intro: "Hallo! Zo te zien heb je plezier aan de discussie, maar heb je je nog niet geregistreerd voor een account." summary: - enabled_description: "U bekijkt een samenvatting van dit topic: de interessantste berichten volgens de gemeenschap." + enabled_description: "Je bekijkt een samenvatting van dit topic: de interessantste berichten volgens de community." description: one: "Er is %{count} antwoord." other: "Er zijn %{count} antwoorden." @@ -1630,21 +1629,21 @@ nl: enable: "Dit topic samenvatten" disable: "Alle berichten tonen" short_label: "Samenvatten" - short_title: "Bekijk een samenvatting van dit topic: de interessantste berichten volgens de gemeenschap" + short_title: "Bekijk een samenvatting van dit topic: de interessantste berichten volgens de community" deleted_filter: enabled_description: "Dit topic bevat verwijderde berichten, die zijn verborgen." - disabled_description: "Verwijderde berichten in het topic worden getoond." + disabled_description: "Verwijderde berichten in het topic worden weergegeven." enable: "Verwijderde berichten verbergen" - disable: "Verwijderde berichten tonen" + disable: "Verwijderde berichten weergeven" private_message_info: title: "Bericht" invite: "Anderen uitnodigen..." edit: "Toevoegen of verwijderen..." remove: "Verwijderen..." add: "Toevoegen..." - leave_message: "Weet u zeker dat u dit bericht wilt verlaten?" - remove_allowed_user: "Wilt u %{name} echt uit dit bericht verwijderen?" - remove_allowed_group: "Wilt u %{name} echt uit dit bericht verwijderen?" + leave_message: "Weet je zeker dat je dit bericht wilt verlaten?" + remove_allowed_user: "Weet je zeker dat je %{name} wilt verwijderen uit dit bericht?" + remove_allowed_group: "Weet je zeker dat je %{name} wilt verwijderen uit dit bericht?" leave: "Verlaten" remove_user: "Gebruiker verwijderen" email: "E-mailadres" @@ -1657,32 +1656,32 @@ nl: create_account: header_title: "Welkom!" subheader_title: "Laten we uw account maken" - disclaimer: "Door te registreren gaat u akkoord met het privacybeleid en de servicevoorwaarden." + disclaimer: "Door je te registreren, ga je akkoord met het privacybeleid en de gebruiksvoorwaarden." title: "Nieuw account maken" - failed: "Er is iets misgegaan; mogelijk is het e-mailadres al geregistreerd. Probeer de koppeling 'Wachtwoord vergeten'." + failed: "Er is iets misgegaan, mogelijk is het e-mailadres al geregistreerd. Probeer de link 'Wachtwoord vergeten'." forgot_password: - title: "Wachtwoord herinitialiseren" + title: "Wachtwoord herstellen" action: "Ik ben mijn wachtwoord vergeten" - invite: "Voer uw gebruikersnaam of e-mailadres in, en we sturen u een e-mail om uw wachtwoord opnieuw in te stellen." - reset: "Wachtwoord herinitialiseren" - complete_username: "Als een account overeenkomt met de gebruikersnaam %{username}, zou u spoedig een e-mail moeten ontvangen met instructies om uw wachtwoord opnieuw in te stellen." - complete_email: "Als een account overeenkomt met %{email}, zou u spoedig een e-mail moeten ontvangen met instructies om uw wachtwoord opnieuw in te stellen." - complete_username_found: "We hebben een account gevonden die met de gebruikersnaam %{username} overeenkomt. U zou spoedig een e-mail moeten ontvangen met instructies om uw wachtwoord opnieuw in te stellen." - complete_email_found: "We hebben een account gevonden die met het e-mailadres %{email} overeenkomt. U zou spoedig een e-mail moeten ontvangen met instructies om uw wachtwoord opnieuw in te stellen." + invite: "Voer je gebruikersnaam of e-mailadres in, dan sturen we je een e-mail om je wachtwoord te herstellen." + reset: "Wachtwoord herstellen" + complete_username: "Als een account overeenkomt met de gebruikersnaam %{username}, zou je binnen enkele ogenblikken een e-mail moeten ontvangen met instructies om je wachtwoord te herstellen." + complete_email: "Als een account overeenkomt met %{email}, zou je binnen enkele ogenblikken een e-mail moeten ontvangen met instructies om je wachtwoord te herstellen." + complete_username_found: "We hebben een account gevonden dat overeenkomt met de gebruikersnaam %{username}. Je zou binnen enkele ogenblikken een e-mail moeten ontvangen met instructies om je wachtwoord te herstellen." + complete_email_found: "We hebben een account gevonden dat overeenkomt met %{email}. Je zou binnen enkele ogenblikken een e-mail moeten ontvangen met instructies om je wachtwoord te herstellen." complete_username_not_found: "Geen account met de gebruikersnaam %{username} gevonden" complete_email_not_found: "Geen account met het e-mailadres %{email} gevonden" - help: "Komt de e-mail niet aan? Controleer eerst uw spammap.

    Weet u niet zeker welk e-mailadres u hebt gebruikt? Voer een e-mailadres in en we laten u weten of het hier bestaat.

    Als u geen toegang meer hebt tot het e-mailadres van uw account, neem dan contact op met onze behulpzame staf.

    " + help: "Geen e-mail? Controleer eerst je spammap.

    Weet je niet zeker welk e-mailadres je hebt gebruikt? Voer een e-mailadres in, dan laten we je weten of het bestaat hier.

    Als je geen toegang meer hebt tot het e-mailadres van je account, neem dan contact op met onze behulpzame staf.

    " button_ok: "OK" button_help: "Help" email_login: - link_label: "Een koppeling voor aanmelding e-mailen" - button_label: "met e-mail" - login_link: "Sla het wachtwoord over; mail me een inloglink" - emoji: "slot-emoji" - complete_username: "Als een account overeenkomt met de gebruikersnaam %{username}, zou u spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." - complete_email: "Als een account overeenkomt met %{email}, zou u spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." - complete_username_found: "We hebben een account gevonden die overeenkomt met de gebruikersnaam %{username}. U zou spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." - complete_email_found: "We hebben een account gevonden die overeenkomt met %{email}. U zou spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." + link_label: "E-mail mij een aanmeldlink" + button_label: "met e-mailadres" + login_link: "Wachtwoord overslaan, e-mail me een aanmeldlink" + emoji: "slotemoji" + complete_username: "Als een account overeenkomt met %{username}, zou je binnen enkele ogenblikken een e-mail met een aanmeldlink moeten ontvangen." + complete_email: "Als een account overeenkomt met %{email}, zou je binnen enkele ogenblikken een e-mail met een aanmeldlink moeten ontvangen." + complete_username_found: "We hebben een account gevonden dat overeenkomt met de gebruikersnaam %{username}. Je zou binnen enkele ogenblikken een e-mail met een aanmeldlink moeten ontvangen." + complete_email_found: "We hebben een account gevonden dat overeenkomt met %{email}. Je zou binnen enkele ogenblikken een e-mail met een aanmeldlink moeten ontvangen." complete_username_not_found: "Er komt geen account overeen met de gebruikersnaam %{username}" complete_email_not_found: "Er komt geen account overeen met %{email}" confirm_title: Doorgaan naar %{site_name} @@ -1697,42 +1696,42 @@ nl: second_factor_title: "Tweefactorauthenticatie" second_factor_description: "Voer de authenticatiecode van uw app in:" second_factor_backup: "Aanmelden met een back-upcode" - second_factor_backup_title: "Tweefactor-back-up" - second_factor_backup_description: "Voer één van uw back-upcodes in:" + second_factor_backup_title: "Back-up voor tweeledige verificatie" + second_factor_backup_description: "Voer één van je back-upcodes in:" second_factor: "Aanmelden met authenticator-app" security_key_alternative: "Andere manier proberen" - security_key_authenticate: "Authenticeren met beveiligingssleutel" - security_key_not_allowed_error: "Het authenticatieproces van de beveiligingssleutel had een time-out of is geannuleerd." - security_key_no_matching_credential_error: "Geen referenties gevonden in de opgegeven beveiligingssleutel." - security_key_support_missing_error: "Uw huidige apparaat of browser ondersteunt geen gebruik van beveiligingssleutels. Gebruik een andere methode." - email_placeholder: "E-mailadres / Gebruikersnaam" - caps_lock_warning: "Caps Lock staat aan" + security_key_authenticate: "Verifiëren met beveiligingssleutel" + security_key_not_allowed_error: "Het verificatieproces met beveiligingssleutel had een time-out of is geannuleerd." + security_key_no_matching_credential_error: "Geen aanmeldingsgegevens gevonden in de opgegeven beveiligingssleutel." + security_key_support_missing_error: "Uw huidige apparaat of browser ondersteunt het gebruik van beveiligingssleutels niet. Gebruik een andere methode." + email_placeholder: "E-mailadres / gebruikersnaam" + caps_lock_warning: "Caps Lock is ingeschakeld" error: "Onbekende fout" - cookies_error: "Het lijkt erop dat uw browser geen cookies toestaat. Als u ze niet eerst toestaat, kunt u zich misschien niet aanmelden." - rate_limit: "Wacht even voordat u zich opnieuw probeert aan te melden." - blank_username: "Voer uw e-mailadres of gebruikersnaam in." - blank_username_or_password: "Voer uw e-mailadres of gebruikersnaam en wachtwoord in." - reset_password: "Wachtwoord herinitialiseren" + cookies_error: "Het lijkt erop dat je browser geen cookies toestaat. Als je ze niet toestaat, kun je je mogelijk niet aanmelden." + rate_limit: "Wacht voordat je je opnieuw probeert aan te melden." + blank_username: "Voer je e-mailadres of gebruikersnaam in." + blank_username_or_password: "Voer je e-mailadres of gebruikersnaam en je wachtwoord in." + reset_password: "Wachtwoord herstellen" logging_in: "Aanmelden..." or: "Of" - authenticating: "Authenticeren..." - awaiting_activation: "Uw account wacht op activering; gebruik de koppeling 'Wachtwoord vergeten' om een nieuwe activeringsmail te ontvangen." - awaiting_approval: "Uw account is nog niet door een staflid goedgekeurd. U ontvangt een e-mail zodra dat is gebeurd." - requires_invite: "Sorry, toegang tot dit forum werkt alleen via uitnodiging." - not_activated: "U kunt zich nog niet aanmelden. We hebben een activeringsmail naar %{sentTo} gestuurd. Volg de instructies in dat e-mailbericht om uw account te activeren." - not_allowed_from_ip_address: "U kunt zich niet aanmelden vanaf dat IP-adres." - admin_not_allowed_from_ip_address: "U kunt zich niet aanmelden als beheerder vanaf dat IP-adres." - resend_activation_email: "Klik hier om de activeringsmail opnieuw te versturen." - omniauth_disallow_totp: "Uw account heeft tweefactorauthenticatie ingeschakeld. Meld u aan met uw wachtwoord." - resend_title: "Activeringsmail opnieuw versturen" + authenticating: "Verifiëren..." + awaiting_activation: "Uw account wacht op activering. Gebruik de link 'Wachtwoord vergeten' om een nieuwe activerings-e-mail te ontvangen." + awaiting_approval: "Je account is nog niet goedgekeurd door een staflid. Je ontvangt een e-mail zodra dat is gebeurd." + requires_invite: "Sorry, dit forum is alleen toegankelijk op uitnodiging." + not_activated: "Je kunt je nog niet aanmelden. We hebben een activerings-e-mail naar %{sentTo} gestuurd. Volg de instructies in de e-mail om je account te activeren." + not_allowed_from_ip_address: "Je kunt je niet aanmelden vanaf dat IP-adres." + admin_not_allowed_from_ip_address: "Je kunt je niet aanmelden als beheerder vanaf dat IP-adres." + resend_activation_email: "Klik hier om de activerings-e-mail opnieuw te sturen." + omniauth_disallow_totp: "Tweeledige verificatie is ingeschakeld voor je account. Meld je aan met je wachtwoord." + resend_title: "Activerings-e-mail opnieuw sturen" change_email: "E-mailadres wijzigen" - provide_new_email: "Geef een nieuw adres op en we sturen uw bevestigingsmail opnieuw." + provide_new_email: "Geef een nieuw adres op, dan sturen we je bevestigings-e-mail opnieuw." submit_new_email: "E-mailadres bijwerken" - sent_activation_email_again: "We hebben een nieuwe activeringsmail naar %{currentEmail} gestuurd. Het kan een aantal minuten duren voor deze aankomt; controleer ook uw spammap." - sent_activation_email_again_generic: "We hebben nog een activeringsmail gestuurd. Het kan een aantal minuten duren voordat deze aankomt; controleer ook uw spammap." - to_continue: "Meld u aan" - preferences: "U dient aangemeld te zijn om uw gebruikersvoorkeuren te wijzigen." - not_approved: "Uw account is nog niet goedgekeurd. U ontvangt een melding via e-mail zodra u zich kunt aanmelden." + sent_activation_email_again: "We hebben een nieuwe activerings-e-mail naar %{currentEmail} gestuurd. Het kan enkele minuten duren voordat deze aankomt. Controleer ook de spammap." + sent_activation_email_again_generic: "We hebben een nieuwe activerings-e-mail gestuurd. Het kan enkele minuten duren voordat deze aankomt. Controleer ook de spammap." + to_continue: "Meld je aan" + preferences: "Je moet aangemeld zijn om je gebruikersvoorkeuren te wijzigen." + not_approved: "Je account is nog niet goedgekeurd. Je ontvangt een melding via e-mail zodra je je kunt aanmelden." google_oauth2: name: "Google" twitter: @@ -1746,35 +1745,35 @@ nl: discord: name: "Discord" second_factor_toggle: - totp: "Een authenticator-app gebruiken" - backup_code: "Een back-upcode gebruiken" + totp: "Authenticator-app gebruiken" + backup_code: "Back-upcode gebruiken" invites: accept_title: "Uitnodiging" - emoji: "envelop-emoji" + emoji: "envelopemoji" welcome_to: "Welkom bij %{site_name}!" - invited_by: "U bent uitgenodigd door:" - social_login_available: "U kunt zich ook aanmelden met een willekeurige sociale aanmelding die dat e-mailadres gebruikt." - your_email: "Het e-mailadres van uw account is %{email}." + invited_by: "Je bent uitgenodigd door:" + social_login_available: "Je kunt je ook aanmelden met een sociale-media-account dat dat e-mailadres gebruikt." + your_email: "Het e-mailadres van je account is %{email}." accept_invite: "Uitnodiging accepteren" - success: "Uw account is gemaakt en u bent nu aangemeld." + success: "Je account is gemaakt en je bent aangemeld." name_label: "Naam" password_label: "Wachtwoord" password_reset: continue: "Doorgaan naar %{site_name}" emoji_set: - apple_international: "Apple/International" + apple_international: "Apple/internationaal" google: "Google" twitter: "Twitter" - win10: "Win10" + win10: "WIndows 10" google_classic: "Google Klassiek" facebook_messenger: "Facebook Messenger" category_page_style: categories_only: "Alleen categorieën" - categories_with_featured_topics: "Categorieën met aanbevolen topics" + categories_with_featured_topics: "Categorieën met uitgelichte topics" categories_and_latest_topics: "Categorieën en nieuwste topics" categories_and_top_topics: "Categorieën en toptopics" categories_boxes: "Vakken met subcategorieën" - categories_boxes_with_topics: "Vakken met aanbevolen topics" + categories_boxes_with_topics: "Vakken met uitgelichte topics" shortcut_modifier_key: shift: "Shift" ctrl: "Ctrl" @@ -1795,21 +1794,21 @@ nl: select_kit: delete_item: "%{name} verwijderen" filter_by: "Filteren op: %{name}" - select_to_filter: "Een waarde selecteren om te filteren" + select_to_filter: "Selecteer een waarde om te filteren" default_header_text: Selecteren... no_content: Geen overeenkomsten gevonden results_count: one: "%{count} resultaat" other: "%{count} resultaten" filter_placeholder: Zoeken... - filter_placeholder_with_any: Zoeken of aanmaken... - create: "Aanmaken: '%{content}'" + filter_placeholder_with_any: Zoeken of maken... + create: "Maken: '%{content}'" max_content_reached: - one: "U kunt maar %{count} item selecteren." - other: "U kunt maar %{count} items selecteren." + one: "Je kunt slechts %{count} item selecteren." + other: "Je kunt slechts %{count} items selecteren." min_content_not_reached: - one: "Selecteer minstens %{count} item." - other: "Selecteer minstens %{count} items." + one: "Selecteer minimaal %{count} item." + other: "Selecteer minimaal %{count} items." components: tag_drop: filter_for_more: Filteren voor meer... @@ -1819,30 +1818,30 @@ nl: from: Van to: Aan emoji_picker: - filter_placeholder: Zoeken naar emoji - smileys_&_emotion: Smileys en Emotie - people_&_body: Mensen en Lichaam - animals_&_nature: Dieren en Natuur - food_&_drink: Eten en Drinken - travel_&_places: Reizen en Plaatsen + filter_placeholder: Emoji zoeken + smileys_&_emotion: Smileys en emoji's + people_&_body: Mensen en lichaam + animals_&_nature: Dieren en natuur + food_&_drink: Eten en drinken + travel_&_places: Reizen en plaatsen activities: Activiteiten - objects: Objecten + objects: Voorwerpen symbols: Symbolen flags: Vlaggen - recent: Onlangs gebruikt + recent: Recent gebruikt default_tone: Geen huidskleur light_tone: Lichte huidskleur - medium_light_tone: Licht gemiddelde huidskleur + medium_light_tone: Gemiddeld lichte huidskleur medium_tone: Gemiddelde huidskleur - medium_dark_tone: Donker gemiddelde huidskleur + medium_dark_tone: Gemiddelde donkere huidskleur dark_tone: Donkere huidskleur - default: Eigen emoji + default: Eigen emoji's shared_drafts: title: "Gedeelde concepten" notice: "Dit topic is alleen zichtbaar voor degenen die gedeelde concepten kunnen publiceren." destination_category: "Bestemmingscategorie" publish: "Gedeeld concept publiceren" - confirm_publish: "Weet u zeker dat u dit concept wilt publiceren?" + confirm_publish: "Weet je zeker dat je dit concept wilt publiceren?" publishing: "Topic publiceren..." composer: emoji: "Emoji :)" @@ -1851,39 +1850,39 @@ nl: whisper: "fluisteren" unlist: "onzichtbaar" add_warning: "Dit is een officiële waarschuwing." - toggle_whisper: "Fluistermodus in-/uitschakelen" + toggle_whisper: "Fluisteren in-/uitschakelen" toggle_unlisted: "Onzichtbaar in-/uitschakelen" - posting_not_on_topic: "Op welk topic wilt u antwoorden?" + posting_not_on_topic: "Op welk topic wil je antwoorden?" saved_local_draft_tip: "lokaal opgeslagen" similar_topics: "Uw topic lijkt op..." drafts_offline: "concepten offline" edit_conflict: "bewerkingsconflict" group_mentioned_limit: - one: "Waarschuwing! U hebt %{group} genoemd, maar deze groep heeft meer leden dan de door een beheerder geconfigureerde limiet van %{count} gebruiker voor noemen. Niemand krijgt een melding." - other: "Waarschuwing! U hebt %{group} genoemd, maar deze groep heeft meer leden dan de door een beheerder geconfigureerde limiet van %{count} gebruikers voor noemen. Niemand krijgt een melding." + one: "Waarschuwing! Je hebt %{group} genoemd, maar deze groep heeft meer leden dan de door een beheerder geconfigureerde vermeldingslimiet van %{count} gebruiker. Niemand krijgt een melding." + other: "Waarschuwing! Je hebt %{group} genoemd, maar deze groep heeft meer leden dan de door een beheerder geconfigureerde vermeldingslimiet van %{count} gebruikers. Niemand krijgt een melding." group_mentioned: - one: "Door %{group} te vermelden, gaat u %{count} persoon op de hoogte brengen – weet u dit zeker?" - other: "Door %{group} te vermelden, gaat u %{count} personen op de hoogte brengen – weet u dit zeker?" - duplicate_link: "Het lijkt erop dat uw koppeling naar %{domain} al in het topic is geplaatst door @%{username} in een antwoord op %{ago} – weet u zeker dat u deze opnieuw wilt plaatsen?" + one: "Door %{group} te vermelden, krijgt %{count} persoon een melding. Weet je het zeker?" + other: "Door %{group} te vermelden, krijgen %{count} personen een melding. Weet je het zeker?" + duplicate_link: "Het lijkt erop dat je link naar %{domain} al in het topic is geplaatst door @%{username} in een antwoord op %{ago} – weet je zeker dat je deze opnieuw wilt plaatsen?" reference_topic_title: "RE: %{title}" error: title_missing: "Titel is vereist" title_too_short: - one: "Titel moet minstens %{count} teken bevatten." - other: "Titel moet minstens %{count} tekens bevatten." + one: "Titel moet minimaal %{count} teken lang zijn." + other: "Titel moet minimaal %{count} tekens lang zijn." title_too_long: - one: "Titel kan niet langer zijn dan %{count} teken" - other: "Titel kan niet langer zijn dan %{count} tekens" - post_missing: "Bericht kan niet leeg zijn" + one: "Titel mag niet langer zijn dan %{count} teken" + other: "Titel mag niet langer zijn dan %{count} tekens" + post_missing: "Bericht mag niet leeg zijn" post_length: - one: "Bericht moet minstens %{count} teken bevatten." - other: "Bericht moet minstens %{count} tekens bevatten." - try_like: "Hebt u de knop %{heart} geprobeerd?" - category_missing: "U moet een categorie kiezen" + one: "Bericht moet minimaal %{count} teken lang zijn." + other: "Bericht moet minimaal %{count} tekens lang zijn." + try_like: "Heb je de knop %{heart} geprobeerd?" + category_missing: "Je moet een categorie kiezen" tags_missing: - one: "U moet minstens %{count} tag kiezen" - other: "U moet minstens %{count} tags kiezen" - topic_template_not_modified: "Voeg details en specifieke kenmerken toe aan uw topic door de topicsjabloon te bewerken." + one: "Je moet minimaal %{count} tag kiezen" + other: "Je moet minimaal %{count} tags kiezen" + topic_template_not_modified: "Voeg details en specifieke informatie toe aan uw topic door de topicsjabloon te bewerken." save_edit: "Bewerking opslaan" overwrite_edit: "Bewerking overschrijven" reply_original: "Antwoorden op oorspronkelijke topic" @@ -1897,29 +1896,29 @@ nl: edit_shared_draft: "Gedeeld concept bewerken" title: "Of druk op %{modifier}Enter" title_placeholder: "Waar gaat deze discussie over in één korte zin?" - title_or_link_placeholder: "Typ de titel, of plak hier een koppeling" - edit_reason_placeholder: "vanwaar deze bewerking?" - topic_featured_link_placeholder: "Voer koppeling in die met titel wordt getoond." - remove_featured_link: "Koppeling uit topic verwijderen." + title_or_link_placeholder: "Typ de titel of plak hier een link" + edit_reason_placeholder: "waarom bewerk je?" + topic_featured_link_placeholder: "Voer de weergegeven link in met titel." + remove_featured_link: "Link verwijderen uit topic." reply_placeholder: "Typ hier. Gebruik Markdown, BBCode of HTML voor opmaak. Sleep of plak afbeeldingen." reply_placeholder_no_images: "Typ hier. Gebruik Markdown, BBCode of HTML voor opmaak." - reply_placeholder_choose_category: "Selecteer een categorie voordat u hier typt." + reply_placeholder_choose_category: "Selecteer een categorie voordat je hier typt." view_new_post: "Uw nieuwe bericht bekijken" saving: "Opslaan" saved: "Opgeslagen!" saved_draft: "Berichtconcept wordt uitgevoerd. Tik om te hervatten." uploading: "Uploaden..." - show_preview: "toon voorbeeld" - hide_preview: "verberg voorbeeld" + show_preview: "voorbeeld weergeven" + hide_preview: "voorbeeld verbergen" quote_post_title: "Hele bericht citeren" bold_label: "B" bold_title: "Vet" - bold_text: "Vetgedrukte tekst" + bold_text: "vetgedrukte tekst" italic_label: "I" italic_title: "Cursief" italic_text: "Cursieve tekst" link_title: "Hyperlink" - link_description: "voer hier een omschrijving in" + link_description: "voer hier een linkbeschrijving in" link_dialog_title: "Hyperlink invoegen" link_optional_text: "optionele titel" link_url_placeholder: "Plak een URL of typ om topics te zoeken" @@ -1944,12 +1943,12 @@ nl: hide_toolbar: "editorwerkbalk verbergen" modal_ok: "OK" modal_cancel: "Annuleren" - cant_send_pm: "Sorry, u kunt geen bericht naar %{username} sturen." + cant_send_pm: "Sorry, je kunt geen bericht naar %{username} sturen." yourself_confirm: - title: "Bent u ontvangers vergeten toe te voegen?" - body: "Het bericht wordt nu alleen naar uzelf verstuurd!" + title: "Ben je vergeten ontvangers toe te voegen?" + body: "Het bericht wordt nu alleen naar jezelf gestuurd!" slow_mode: - error: "Dit topic bevindt zich in de langzame modus. U hebt onlangs al gepost; U kunt opnieuw posten in %{timeLeft}." + error: "Dit topic bevindt zich in de langzame modus. Je hebt onlangs al geplaatst; je kunt opnieuw plaatsen over %{timeLeft}." admin_options_title: "Optionele stafinstellingen voor dit topic" composer_actions: reply: Antwoorden @@ -1961,27 +1960,27 @@ nl: reply_as_new_topic: label: Antwoorden als gekoppeld topic desc: Een nieuw topic maken dat aan dit topic is gekoppeld - confirm: U hebt een nieuw-topicconcept opgeslagen, dat wordt overschreven als u een gekoppeld topic maakt. + confirm: Je hebt een nieuw topicconcept opgeslagen, dat wordt overschreven als je een gekoppeld topic maakt. reply_as_new_group_message: label: Antwoorden als nieuw groepsbericht reply_to_topic: label: Antwoorden op topic - desc: Antwoorden op het topic, niet een bepaald bericht + desc: Antwoord op het topic, niet een bepaald bericht toggle_whisper: - label: Fluistermodus in-/uitschakelen + label: Fluisteren in-/uitschakelen desc: Fluisterberichten zijn alleen zichtbaar voor stafleden create_topic: label: "Nieuw topic" shared_draft: label: "Gedeeld concept" - desc: "Een concepttopic maken dat alleen zichtbaar is voor toegestane gebruikers" + desc: "Maak een topicconcept dat alleen zichtbaar is voor toegestane gebruikers" toggle_topic_bump: - label: "Topicbump in-/uitschakelen" + label: "Omhoog plaatsen van topic in-/uitschakelen" desc: "Antwoorden zonder datum van laatste antwoord te wijzigen" reload: "Opnieuw laden" ignore: "Negeren" image_alt_text: - aria_label: Alt-tekst voor afbeelding + aria_label: Alternatieve tekst voor afbeelding notifications: tooltip: regular: @@ -1993,11 +1992,11 @@ nl: high_priority: one: "%{count} ongelezen melding met hoge prioriteit" other: "%{count} ongelezen meldingen met hoge prioriteit" - title: "meldingen van @naam-vermeldingen, antwoorden op uw berichten en topics, berichten, etc." + title: "meldingen van @naamvermeldingen, antwoorden op je berichten en topics, berichten, enz." none: "Meldingen kunnen momenteel niet worden geladen." empty: "Geen meldingen gevonden." post_approved: "Uw bericht is goedgekeurd" - reviewable_items: "items die beoordeling nodig hebben" + reviewable_items: "items die moeten worden beoordeeld" watching_first_post_label: "Nieuw topic" mentioned: "%{username} %{description}" group_mentioned: "%{username} %{description}" @@ -2013,46 +2012,46 @@ nl: other: "%{username}, %{username2} en %{count} anderen %{description}" liked_by_2_users: "%{username}, %{username2}" liked_by_multiple_users: - one: "%{username}, %{username2} en %{count} anderen" + one: "%{username}, %{username2} en %{count} ander" other: "%{username}, %{username2} en %{count} anderen" liked_consolidated_description: - one: "heeft %{count} van uw berichten geliket" - other: "heeft %{count} van uw berichten geliket" + one: "heeft %{count} van je berichten geliket" + other: "heeft %{count} van je berichten geliket" liked_consolidated: "%{username} %{description}" private_message: "%{username} %{description}" invited_to_private_message: "

    %{username} %{description}" invited_to_topic: "%{username} %{description}" - invitee_accepted: "%{username} heeft uw uitnodiging geaccepteerd" + invitee_accepted: "%{username} heeft je uitnodiging geaccepteerd" moved_post: "%{username} heeft %{description} verplaatst" linked: "%{username} %{description}" - granted_badge: "'%{description}' ontvangen" + granted_badge: "'%{description}' verdiend" topic_reminder: "%{username} %{description}" - watching_first_post: "Nieuw Topic %{description}" - membership_request_accepted: "Lidmaatschap geaccepteerd in '%{group_name}'" + watching_first_post: "Nieuw topic %{description}" + membership_request_accepted: "Lidmaatschap geaccepteerd voor '%{group_name}'" membership_request_consolidated: - one: "%{count} open lidmaatschapsaanvraag voor '%{group_name}'" - other: "%{count} open lidmaatschapsaanvragen voor '%{group_name}'" + one: "%{count} openstaand lidmaatschapsverzoek voor '%{group_name}'" + other: "%{count} openstaande lidmaatschapsverzoeken voor '%{group_name}'" reaction: "%{username} %{description}" reaction_2: "%{username}, %{username2} %{description}" votes_released: "%{description} - voltooid" dismiss_confirmation: body: default: - one: "Weet u het zeker? U hebt %{count} belangrijke melding." - other: "Weet u het zeker? U hebt %{count} belangrijke meldingen." + one: "Weet je het zeker? Je hebt %{count} belangrijke melding." + other: "Weet je het zeker? Je hebt %{count} belangrijke meldingen." dismiss: "Negeren" cancel: "Annuleren" group_message_summary: - one: "%{count} bericht in uw Postvak IN voor %{group_name}" - other: "%{count} berichten in uw Postvak IN voor %{group_name}" + one: "%{count} bericht in je inbox voor %{group_name}" + other: "%{count} berichten in je inbox voor %{group_name}" popup: - mentioned: '%{username} heeft u genoemd in ''%{topic}'' - %{site_title}' - group_mentioned: '%{username} heeft u genoemd in ''%{topic}'' - %{site_title}' - quoted: '%{username} heeft u geciteerd in ''%{topic}'' - %{site_title}' - replied: '%{username} heeft op u geantwoord in ''%{topic}'' - %{site_title}' + mentioned: '%{username} heeft je genoemd in ''%{topic}'' - %{site_title}' + group_mentioned: '%{username} heeft je genoemd in ''%{topic}'' - %{site_title}' + quoted: '%{username} heeft je geciteerd in ''%{topic}'' - %{site_title}' + replied: '%{username} heeft op je geantwoord in ''%{topic}'' - %{site_title}' posted: '%{username} heeft een bericht geplaatst in ''%{topic}'' - %{site_title}' - private_message: '%{username} heeft u een persoonlijk bericht gestuurd in ''%{topic}'' - %{site_title}' - linked: '%{username} heeft een koppeling naar uw bericht geplaatst vanaf ''%{topic}'' - %{site_title}' + private_message: '%{username} heeft je een persoonlijk bericht gestuurd in ''%{topic}'' - %{site_title}' + linked: '%{username} heeft een link geplaatst naar je bericht in ''%{topic}'' - %{site_title}' watching_first_post: '%{username} heeft een nieuw topic gemaakt: ''%{topic}'' - %{site_title}' confirm_title: "Meldingen ingeschakeld - %{site_title}" confirm_body: "Gelukt! Meldingen zijn ingeschakeld." @@ -2068,7 +2067,7 @@ nl: invitee_accepted: "uitnodiging geaccepteerd" posted: "nieuw bericht" moved_post: "bericht verplaatst" - linked: "gekoppeld" + linked: "gelinkt" bookmark_reminder: "bladwijzerherinnering" bookmark_reminder_with_name: "bladwijzerherinnering - %{name}" granted_badge: "badge toegekend" @@ -2076,15 +2075,15 @@ nl: group_mentioned: "groep genoemd" group_message_summary: "nieuwe groepsberichten" watching_first_post: "nieuw topic" - topic_reminder: "topic-herinnering" + topic_reminder: "topicherinnering" liked_consolidated: "nieuwe likes" post_approved: "bericht goedgekeurd" - membership_request_consolidated: "nieuwe lidmaatschapsaanvragen" + membership_request_consolidated: "nieuwe lidmaatschapsverzoeken" reaction: "nieuwe reactie" votes_released: "Stem is vrijgegeven" upload_selector: uploading: "Uploaden" - select_file: "Bestand selecteren" + select_file: "Selecteer bestand" default_image_alt_text: afbeelding search: sort_by: "Sorteren op" @@ -2098,39 +2097,39 @@ nl: too_short: "Uw zoekterm is te kort." clear_search: "Zoekopdracht wissen" result_count: - one: "%{count} resultaat voor%{term}" - other: "%{count}%{plus} resultaten voor%{term}" + one: "%{count} resultaat voor %{term}" + other: "%{count}%{plus} resultaten voor %{term}" title: "Zoeken" full_page_title: "Zoeken" no_results: "Geen resultaten gevonden." - no_more_results: "Geen resultaten meer gevonden." - post_format: "#%{post_number} door %{username}" + no_more_results: "Geen verdere resultaten gevonden." + post_format: "#%{post_number} van %{username}" results_page: "Zoekresultaten voor '%{term}'" - more_results: "Er zijn meer resultaten. Verfijn uw zoekcriteria." - cant_find: "Kunt u niet vinden wat u zoekt?" + more_results: "Er zijn meer resultaten. Verfijn je zoekcriteria." + cant_find: "Kun je niet vinden wat je zoekt?" start_new_topic: "Misschien een nieuw topic starten?" - or_search_google: "Of probeer in plaats hiervan te zoeken met Google:" - search_google: "Probeer in plaats hiervan te zoeken met Google:" + or_search_google: "Of probeer in plaats daarvan te zoeken met Google:" + search_google: "Probeer in plaats daarvan te zoeken met Google:" search_google_button: "Google" search_button: "Zoeken" categories: "Categorieën" tags: "Tags" in: "in" - in_this_topic: "in dit onderwerp" + in_this_topic: "in dit topic" in_topics_posts: "in alle topics en berichten" enter_hint: "of druk op Enter" in_posts_by: "In berichten van @%{username}" type: - default: "Onderwerpen/berichten" + default: "Topics/berichten" users: "Gebruikers" categories: "Categorieën" categories_and_tags: "Categorieën/tags" context: - user: "Berichten van @%{username} doorzoeken" - category: "De categorie #%{category} doorzoeken" - tag: "De tag #%{tag} doorzoeken" - topic: "Dit topic doorzoeken" - private_messages: "Berichten doorzoeken" + user: "Zoeken in berichten van @%{username}" + category: "Zoeken in de categorie #%{category}" + tag: "Zoeken in de tag #%{tag}" + topic: "Zoeken in dit topic" + private_messages: "Zoeken in berichten" tips: category_tag: "filtert op categorie of tag" full_search: "start zoeken op volledige pagina" @@ -2146,14 +2145,14 @@ nl: label: Met badge with_tags: label: Getagd - aria_label: Filter met tags + aria_label: Filteren met tags filters: label: Alleen topics/berichten weergeven... - title: Die alleen met de titel overeenkomen + title: Alleen matchen in titel likes: die ik heb geliket posted: waarin ik iets heb geplaatst - created: die ik heb aangemaakt - watching: die ik in de gaten houd + created: die ik heb gemaakt + watching: die ik observeer tracking: die ik volg private: In mijn berichten bookmarks: Ik heb een bladwijzer gemaakt @@ -2192,13 +2191,13 @@ nl: placeholder: maximaal aria_label: filteren op maximale weergaven additional_options: - label: "Filteren op aantal berichten en onderwerpweergaven" + label: "Filteren op aantal berichten en topicweergaven" hamburger_menu: "menu" new_item: "nieuw" go_back: "terug" not_logged_in_user: "gebruikerspagina met samenvatting van huidige activiteit en voorkeuren" current_user: "naar uw gebruikerspagina" - view_all: "Alle %{tab} bekijken" + view_all: "alle %{tab} weergeven" user_menu: tabs: replies: "Antwoorden" @@ -2218,85 +2217,85 @@ nl: dismiss: "Negeren" dismiss_read: "Alle ongelezen negeren" dismiss_read_with_selected: - one: "%{count} ongelezen onderwerp negeren" - other: "%{count} ongelezen onderwerpen negeren" + one: "%{count} ongelezen topic negeren" + other: "%{count} ongelezen topics negeren" dismiss_button: "Negeren..." dismiss_button_with_selected: one: "Negeren (%{count})…" other: "Negeren (%{count})…" - dismiss_tooltip: "Alleen nieuwe berichten negeren of het volgen van topics stoppen" - also_dismiss_topics: "Het volgen van deze topics stoppen, zodat ze nooit meer als ongelezen verschijnen." - dismiss_new: "Nieuwe berichten negeren" + dismiss_tooltip: "Negeer alleen nieuwe berichten of stop het volgen van topics" + also_dismiss_topics: "Volgen van deze topics stoppen, zodat ze nooit meer als ongelezen verschijnen voor mij" + dismiss_new: "Nieuwe negeren" dismiss_new_with_selected: - one: "Negeer nieuw topic (%{count})" - other: "Negeer nieuwe topics (%{count})" + one: "Nieuwe negeren (%{count})" + other: "Nieuwe negeren (%{count})" toggle: "bulkselectie van topics in-/uitschakelen" actions: "Bulkacties" close_topics: "Topics sluiten" archive_topics: "Topics archiveren" - move_messages_to_inbox: "Naar Postvak IN verplaatsen" + move_messages_to_inbox: "Verplaatsen naar inbox" notification_level: "Meldingen..." - change_notification_level: "Wijzig meldingsniveau" + change_notification_level: "Meldingsniveau wijzigen" choose_new_category: "Kies de nieuwe categorie voor de topics:" selected: - one: "U hebt %{count} topic geselecteerd." - other: "U hebt %{count} topics geselecteerd." + one: "Je hebt %{count} topic geselecteerd." + other: "Je hebt %{count} topics geselecteerd." change_tags: "Tags vervangen" append_tags: "Tags toevoegen" choose_new_tags: "Kies nieuwe tags voor deze topics:" - choose_append_tags: "Kies nieuwe tags om voor deze topics toe te voegen:" + choose_append_tags: "Kies nieuwe tags om toe te voegen voor deze topics:" changed_tags: "De tags van deze topics zijn gewijzigd." - remove_tags: "Verwijder alle tags" + remove_tags: "Alle tags verwijderen" confirm_remove_tags: - one: "Alle tags zullen verwijderd worden uit dit topic. Weet u het zeker?" - other: "Alle tags zullen verwijderd worden uit %{count} topics. Weet u het zeker?" + one: "Alle tags worden verwijderd van dit topic. Weet je het zeker?" + other: "Alle tags worden verwijderd van %{count} topics. Weet je het zeker?" progress: one: "Voortgang: %{count} topic" other: "Voortgang: %{count} topics" none: - unread: "U hebt geen ongelezen topics." - unseen: "U hebt geen ongelezen topics." - new: "U hebt geen nieuwe topics." - read: "U hebt nog geen topics gelezen." - posted: "U hebt nog niet in een topic gereageerd." - latest: "U bent helemaal bij!" - bookmarks: "U hebt nog geen bladwijzers voor topics gemaakt." + unread: "Je hebt geen ongelezen topics." + unseen: "Je hebt geen ongeziene topics." + new: "Je hebt geen nieuwe topics." + read: "Je hebt nog geen topics gelezen." + posted: "Je hebt nog geen berichten geplaatst in een topic." + latest: "Je bent helemaal bij!" + bookmarks: "Je hebt nog geen bladwijzers voor topics gemaakt." category: "Er zijn geen topics in %{category}." top: "Er zijn geen toptopics." educate: - new: '

    Hier verschijnen uw nieuwe topics. Standaard worden topics als nieuw beschouwd en verschijnt de indicator als deze in de afgelopen 2 dagen zijn aangemaakt.

    Bezoek uw voorkeuren om dit te wijzigen.

    ' - unread: "

    Uw ongelezen topics verschijnen hier.

    Standaard worden topics als ongelezen beschouwd en zullen ze het aantal ongelezen berichten laten zien 1 als u:

    • Het topic heeft aangemaakt
    • Op het topic heeft geantwoord
    • Het topic langer dan 4 minuten heeft gelezen

    Of als u het topic expliciet heeft ingesteld op \"Gevolgd\" of \"In de gaten gehouden\" via de \U0001F514 in elk topic.

    Bezoek uw voorkeuren om dit te wijzigen.

    " + new: '

    Hier worden je nieuwe topics weergegeven. Standaard worden topics als nieuw beschouwd en wordt de indicator weergegeven als ze de afgelopen 2 dagen zijn gemaakt.

    Ga naar je voorkeuren om dit te wijzigen.

    ' + unread: "

    Je ongelezen topics worden hier weergegeven.

    Standaard worden topics als ongelezen beschouwd en wordt het aantal ongelezen berichten aangegeven 1 als je:

    • Het topic hebt gemaakt
    • Op het topic hebt geantwoord
    • Het topic langer dan 4 minuten hebt gelezen

    Of als je het topic expliciet hebt ingesteld op \"Gevolgd\" of \"Geobserveerd\" via de \U0001F514 in elk topic.

    Ga naar je voorkeuren om dit te wijzigen.

    " bottom: - latest: "Er zijn geen nieuwste topics meer." - posted: "Er zijn geen topics meer geplaatst." - read: "Er zijn geen gelezen topics meer." - new: "Er zijn geen nieuwe topics meer." - unread: "Er zijn geen ongelezen topics meer." - unseen: "Er zijn geen ongelezen topics meer." - category: "Er zijn geen topics meer in %{category}." - tag: "Er zijn geen topics meer in %{tag}." - top: "Er zijn geen toptopics meer." - bookmarks: "Er zijn geen topics met bladwijzers meer." + latest: "Er zijn verder geen nieuwste topics." + posted: "Er zijn verder geen geplaatste topics." + read: "Er zijn verder geen gelezen topics." + new: "Er zijn verder geen nieuwe topics." + unread: "Er zijn verder geen ongelezen topics." + unseen: "Er zijn verder geen ongeziene topics." + category: "Er zijn verder geen topics in %{category}." + tag: "Er zijn verder geen topics over %{tag}." + top: "Er zijn verder geen toptopics." + bookmarks: "Er zijn verder geen topics met bladwijzers." topic: filter_to: one: "%{count} bericht in topic" other: "%{count} berichten in topic" create: "Nieuw topic" - create_long: "Een nieuw topic maken" + create_long: "Nieuw topic maken" open_draft: "Concept openen" - private_message: "Een bericht sturen" + private_message: "Bericht sturen" archive_message: - help: "Bericht naar uw archief verplaatsen" + help: "Bericht verplaatsen naar je archief" title: "Archiveren" move_to_inbox: - title: "Verplaatsen naar Postvak IN" - help: "Bericht weer naar Postvak IN verplaatsen" + title: "Verplaatsen naar inbox" + help: "Bericht terug naar inbox verplaatsen" edit_message: - help: "Het eerste bericht van het bericht bewerken" + help: "Eerste bericht bewerken" title: "Bewerken" defer: help: "Markeren als ongelezen" - title: "Negeren" + title: "Uitstellen" list: "Topics" new: "nieuw topic" unread: "ongelezen" @@ -2309,8 +2308,8 @@ nl: title: "Topic" invalid_access: title: "Topic is privé" - description: "Sorry, u hebt geen toegang tot dat topic!" - login_required: "U dient zich aan te melden om dat topic te zien." + description: "Sorry, je hebt geen toegang tot dat topic!" + login_required: "Je moet je aanmelden om dat topic te zien." server_error: title: "Laden van topic is mislukt" description: "Sorry, we konden dit topic niet laden, mogelijk door een verbindingsprobleem. Probeer het opnieuw. Als het probleem zich blijft voordoen, laat het ons dan weten." @@ -2318,22 +2317,22 @@ nl: title: "Topic niet gevonden" description: "Sorry, we konden het opgevraagde topic niet vinden. Misschien is het verwijderd door een moderator?" unread_posts: - one: "u hebt %{count} ongelezen bericht in dit topic" - other: "u hebt %{count} ongelezen berichten in dit topic" + one: "Je hebt %{count} ongelezen bericht in dit topic" + other: "Je hebt %{count} ongelezen berichten in dit topic" likes: one: "er is %{count} like in dit topic" other: "er zijn %{count} likes in dit topic" back_to_list: "Terug naar topiclijst" options: "Topic-opties" - show_links: "koppelingen binnen dit topic tonen" - read_more_in_category: "Wilt u meer lezen? U kunt door andere topics in %{catLink} bladeren, of de %{latestLink}." - read_more: "Wilt u meer lezen? %{catLink} of %{latestLink}." + show_links: "links binnen dit topic weergeven" + read_more_in_category: "Wil je meer lezen? Je kunt bladeren door andere topics in %{catLink} of %{latestLink}." + read_more: "WIl je meer lezen? %{catLink} of %{latestLink}." unread_indicator: "Nog geen enkel lid heeft het laatste bericht van dit topic gelezen." read_more_MF: "Er { UNREAD, plural, =0 {} one { is # ongelezen } other { zijn # ongelezen } } { NEW, plural, =0 {} one { {BOTH, select, true{en } false {is } other{}} # nieuw topic} other { {BOTH, select, true{en } false {zijn } other{}} # nieuwe topics} } over, of {CATEGORY, select, true {bekijk andere topics in {catLink}} false {{latestLink}} other {}}" bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}" browse_all_categories: Alle categorieën bekijken browse_all_tags: Alle tags bekijken - view_latest_topics: nieuwste topics bekijken + view_latest_topics: nieuwste topics weergeven jump_reply_up: naar eerder antwoord springen jump_reply_down: naar later antwoord springen deleted: "Het topic is verwijderd" @@ -2371,9 +2370,9 @@ nl: publish_to: "Publiceren naar:" when: "Wanneer:" time_frame_required: "Selecteer een tijdsbestek" - min_duration: "Tijdsduur moet langer zijn dan 0" - max_duration: "Tijdsduur moet minder dan 20 jaar zijn" - duration: "Tijdsduur" + min_duration: "Duur moet langer zijn dan 0" + max_duration: "Duur moet minder dan 20 jaar zijn" + duration: "Duur" publish_to_category: title: "Publicatie plannen" temp_open: @@ -2384,26 +2383,26 @@ nl: title: "Topic automatisch sluiten" label: "Sluit topic automatisch na:" error: "Voer een geldige waarde in." - based_on_last_post: "Pas sluiten als het laatste bericht in het topic minstens zo oud is" + based_on_last_post: "Pas sluiten als het laatste bericht in het topic minimaal zo oud is" auto_close_after_last_post: title: "Topic automatisch sluiten na laatste bericht" auto_delete: title: "Topic automatisch verwijderen" auto_bump: - title: "Topic automatisch bumpen" + title: "Topic automatisch omhoog plaatsen" reminder: title: "Mij herinneren" auto_delete_replies: title: "Antwoorden automatisch verwijderen" status_update_notice: - auto_open: "Dit topic wordt %{timeLeft} automatisch geopend." - auto_close: "Dit topic wordt %{timeLeft} automatisch gesloten." - auto_publish_to_category: "Dit topic wordt %{timeLeft} naar #%{categoryName} gepubliceerd." + auto_open: "Dit topic wordt automatisch geopend over %{timeLeft}." + auto_close: "Dit topic wordt automatisch gesloten over %{timeLeft}." + auto_publish_to_category: "Dit topic wordt over %{timeLeft} gepubliceerd in #%{categoryName}." auto_close_after_last_post: "Dit topic wordt %{duration} na het laatste antwoord gesloten." - auto_delete: "Dit topic wordt %{timeLeft} automatisch verwijderd." - auto_bump: "Dit topic wordt %{timeLeft} automatisch gebumpt." - auto_reminder: "U wordt %{timeLeft} aan dit topic herinnerd." - auto_delete_replies: "Antwoorden op dit topic worden na %{duration} automatisch verwijderd." + auto_delete: "Dit topic wordt automatisch verwijderd over %{timeLeft} ." + auto_bump: "Dit topic wordt automatisch omhoog geplaatst over %{timeLeft}." + auto_reminder: "Je wordt herinnerd aan dit topic herinnerd over %{timeLeft}." + auto_delete_replies: "Antwoorden op dit topic worden automatisch verwijderd na %{duration}." auto_close_title: "Instellingen voor automatisch sluiten" auto_close_immediate: one: "Het laatste bericht in dit topic is al %{count} uur oud, dus het topic wordt meteen gesloten." @@ -2413,7 +2412,7 @@ nl: other: "Het laatste bericht in dit topic is al %{count} uur oud, dus het topic wordt gesloten." timeline: back: "Terug" - back_description: "Terug naar uw laatste ongelezen bericht" + back_description: "Ga terug naar je laatste ongelezen bericht" replies_short: "%{current} / %{total}" progress: title: topicvoortgang @@ -2422,51 +2421,51 @@ nl: one: "van %{count} bericht" other: "van %{count} berichten" jump_prompt_long: "Springen naar..." - jump_prompt_to_date: "tot datum" + jump_prompt_to_date: "naar datum" jump_prompt_or: "of" notifications: - title: wijzigen hoe vaak u meldingen over dit topic ontvangt + title: wijzig hoe vaak je meldingen over dit topic ontvangt reasons: - mailing_list_mode: "U hebt de mailinglijstmodus ingeschakeld, dus u ontvangt meldingen over antwoorden op dit topic via e-mail." - "3_10": "U ontvangt meldingen, omdat u een tag in dit topic in de gaten houdt." - "3_6": "U ontvangt meldingen, omdat u deze categorie in de gaten houdt." - "3_5": "U ontvangt meldingen, omdat u dit topic automatisch in de gaten houdt." - "3_2": "U ontvangt meldingen, omdat u dit topic in de gaten houdt." - "3_1": "U ontvangt meldingen, omdat u dit topic hebt aangemaakt." - "3": "U ontvangt meldingen, omdat u dit topic in de gaten houdt." - "2_8": "U ziet het aantal nieuwe antwoorden, omdat u deze categorie volgt." - "2_4": "U ziet het aantal nieuwe antwoorden, omdat u een antwoord in dit topic hebt geplaatst." - "2_2": "U ziet het aantal nieuwe antwoorden, omdat u dit topic volgt." - "2": 'U ziet een aantal nieuwe antwoorden, omdat u dit topic hebt gelezen.' - "1_2": "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." - "1": "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." - "0_7": "U negeert alle meldingen in deze categorie." - "0_2": "U negeert alle meldingen in dit topic." - "0": "U negeert alle meldingen in dit topic." + mailing_list_mode: "Je hebt de mailinglijstmodus ingeschakeld, dus je ontvangt meldingen over antwoorden op dit topic via e-mail." + "3_10": "Je ontvangt meldingen omdat je een tag in dit topic observeert." + "3_6": "Je ontvangt meldingen omdat je deze categorie observeert." + "3_5": "Je ontvangt meldingen omdat je dit topic automatisch observeert." + "3_2": "Je ontvangt meldingen omdat je dit topic observeert." + "3_1": "Je ontvangt meldingen omdat je dit topic hebt gemaakt." + "3": "Je ontvangt meldingen omdat je dit topic observeert." + "2_8": "Je ziet het aantal nieuwe antwoorden omdat je deze categorie volgt." + "2_4": "Je ziet het aantal nieuwe antwoorden omdat je een antwoord in dit topic hebt geplaatst." + "2_2": "Je ziet het aantal nieuwe antwoorden omdat je dit topic volgt." + "2": 'Je ziet een aantal nieuwe antwoorden omdat je dit topic hebt gelezen.' + "1_2": "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." + "1": "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." + "0_7": "Je negeert alle meldingen in deze categorie." + "0_2": "Je negeert alle meldingen in dit topic." + "0": "Je negeert alle meldingen in dit topic." watching_pm: - title: "In de gaten houden" - description: "U ontvangt een melding voor elk nieuw antwoord op dit bericht, en het aantal nieuwe antwoorden wordt weergegeven." + title: "Geobserveerd" + description: "Je ontvangt een melding voor elk nieuw antwoord op dit bericht en het aantal nieuwe antwoorden wordt weergegeven." watching: - title: "In de gaten houden" - description: "U ontvangt een melding voor elk nieuw antwoord in dit topic, en het aantal nieuwe antwoorden wordt weergegeven." + title: "Geobserveerd" + description: "Je ontvangt een melding voor elk nieuw antwoord in dit topic en het aantal nieuwe antwoorden wordt weergegeven." tracking_pm: title: "Volgen" - description: "Het aantal nieuwe antwoorden op dit bericht wordt weergegeven. U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Het aantal nieuwe antwoorden op dit bericht wordt weergegeven. Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." tracking: title: "Volgen" - description: "Het aantal nieuwe antwoorden op dit topic wordt weergegeven. U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Het aantal nieuwe antwoorden op dit topic wordt weergegeven. Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." regular: title: "Normaal" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." regular_pm: title: "Normaal" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." muted_pm: title: "Genegeerd" - description: "U ontvangt geen enkele melding over dit bericht." + description: "Je ontvangt geen meldingen over dit bericht." muted: title: "Genegeerd" - description: "U ontvangt geen enkele melding over dit topic, en het verschijnt niet in Nieuwste." + description: "Je ontvangt geen meldingen over dit topic en het wordt niet weergegeven in Nieuwste." actions: title: "Acties" recover: "Topic verwijderen ongedaan maken" @@ -2491,9 +2490,9 @@ nl: title: "Antwoorden" help: "beginnen met opstellen van een antwoord op dit topic" share: - extended_title: "Een koppeling delen" - help: "een koppeling naar dit topic delen" - instructions: "Een koppeling naar dit topic delen:" + extended_title: "Deel een link" + help: "deel een link naar dit topic" + instructions: "Deel een link naar dit topic:" copied: "Topiclink gekopieerd." invite_users: "Uitnodigen" print: @@ -2502,13 +2501,13 @@ nl: flag_topic: title: "Markeren" help: "een privémarkering aan dit topic geven of er een privébericht over sturen" - success_message: "U hebt dit topic succesvol gemarkeerd." + success_message: "Je hebt dit topic gemarkeerd." make_public: title: "Converteren naar openbaar topic" choose_category: "Kies een categorie voor het openbare topic:" feature_topic: title: "Dit topic aanbevelen" - pin: "Dit topic boven in de categorie %{categoryLink} laten verschijnen tot" + pin: "Laat dit topic bovenaan de categorie %{categoryLink} weergeven tot" unpin: "Verwijder dit topic uit de bovenkant van de categorie %{categoryLink}." unpin_until: "Verwijder dit topic uit de bovenkant van de categorie %{categoryLink} of wacht tot %{until}." pin_note: "Gebruikers kunnen het topic individueel voor zichzelf losmaken." @@ -2517,10 +2516,10 @@ nl: already_pinned: one: "Momenteel vastgemaakte topics in %{categoryLink}: %{count}" other: "Momenteel vastgemaakte topics in %{categoryLink}: %{count}." - pin_globally: "Dit topic bovenaan in alle topiclijsten laten verschijnen tot" + pin_globally: "Laat dit topic bovenaan alle topiclijsten weergeven tot" confirm_pin_globally: - one: "U hebt al %{count} globaal vastgemaakt topic. Te veel vastgemaakte topics kunnen storend zijn voor nieuwe en anonieme gebruikers. Weet u zeker dat u nog een topic globaal wilt vastmaken?" - other: "U hebt al %{count} globaal vastgemaakte topics. Te veel vastgemaakte topics kunnen storend zijn voor nieuwe en anonieme gebruikers. Weet u zeker dat u nog een topic globaal wilt vastmaken?" + one: "Je hebt al %{count} globaal vastgemaakt topic. Te veel vastgemaakte topics kunnen storend zijn voor nieuwe en anonieme gebruikers. Weet je zeker dat je nog een topic globaal wilt vastmaken?" + other: "Je hebt al %{count} globaal vastgemaakte topics. Te veel vastgemaakte topics kunnen storend zijn voor nieuwe en anonieme gebruikers. Weet je zeker dat je nog een topic globaal wilt vastmaken?" unpin_globally: "Verwijder dit topic uit de bovenkant van alle topiclijsten." unpin_globally_until: "Verwijder dit topic uit de bovenkant van alle topiclijsten of wacht tot %{until}." global_pin_note: "Gebruikers kunnen het topic individueel voor zichzelf losmaken." @@ -2528,8 +2527,8 @@ nl: already_pinned_globally: one: "Momenteel globaal vastgemaakte topics: %{count}" other: "Momenteel globaal vastgemaakte topics: %{count}." - make_banner: "Dit topic omzetten naar een banner die bovenaan alle pagina's verschijnt" - remove_banner: "De banner die bovenaan alle pagina's verschijnt verwijderen" + make_banner: "Zet dit topic om in een banner die bovenaan alle pagina's wordt weergegeven." + remove_banner: "Verwijder de banner die bovenaan alle pagina's wordt weergegeven." banner_note: "Gebruikers kunnen de banner negeren door deze te sluiten. Er kan maar één bannertopic tegelijk bestaan." no_banner_exists: "Er is geen bannertopic." banner_exists: "Er is momenteel een bannertopic." @@ -2551,14 +2550,14 @@ nl: username_placeholder: "gebruikersnaam" action: "Uitnodiging sturen" help: "anderen uitnodigen voor dit topic via e-mail of meldingen" - to_forum: "We sturen een kort mailtje waarmee uw vriend(in) direct kan deelnemen door op een koppeling te klikken." + to_forum: "We sturen een korte e-mail waarmee uw vriend(in) direct kan deelnemen door op een link te klikken." discourse_connect_enabled: "Voer de gebruikersnaam in van de persoon die u voor dit topic wilt uitnodigen." to_topic_blank: "Voer de gebruikersnaam of het e-mailadres in van de persoon die u voor dit topic wilt uitnodigen." - to_topic_email: "U hebt een e-mailadres ingevoerd. We sturen een uitnodiging per e-mail waarmee uw vriend(in) direct op dit topic kan antwoorden." - to_topic_username: "U hebt een gebruikersnaam ingevoerd. We sturen een melding met een uitnodigingskoppeling om aan dit topic deel te nemen." - to_username: "Voer de gebruikersnaam in van de persoon die u wilt uitnodigen. We sturen een melding met een uitnodigingskoppeling om aan dit topic deel te nemen." + to_topic_email: "Je hebt een e-mailadres ingevoerd. We sturen een uitnodiging per e-mail waarmee uw vriend(in) direct op dit topic kan antwoorden." + to_topic_username: "Je hebt een gebruikersnaam ingevoerd. We sturen een melding met een uitnodigingslink om aan dit topic deel te nemen." + to_username: "Voer de gebruikersnaam in van de persoon die u wilt uitnodigen. We sturen een melding met een uitnodigingslink om aan dit topic deel te nemen." email_placeholder: "name@example.com" - success_email: "We hebben een uitnodiging verstuurd naar %{invitee}. We brengen u op de hoogte wanneer de uitnodiging is afgegeven. Controleer het tabblad Uitnodigingen op uw gebruikerspagina om uw uitnodigingen bij te houden." + success_email: "We hebben een uitnodiging gestuurd naar %{invitee}. We laten het je weten wanneer de uitnodiging wordt ingewisseld. Controleer het tabblad Uitnodigingen op je gebruikerspagina om je uitnodigingen bij te houden." success_username: "We hebben die gebruiker uitgenodigd om aan dit topic deel te nemen." error: "Sorry, we konden deze persoon niet uitnodigen. Misschien is deze al uitgenodigd? (Uitnodigingen zijn in aantal beperkt)" success_existing_email: "Er bestaat al een gebruiker met het e-mailadres %{emailOrUsername}. We hebben deze gebruiker uitgenodigd om aan dit topic deel te nemen." @@ -2579,8 +2578,8 @@ nl: radio_label: "Nieuw topic" error: "Er is een fout opgetreden bij het verplaatsen van berichten naar het nieuwe topic." instructions: - one: "U staat op het punt een nieuw topic te maken en het in te vullen met het bericht dat u hebt geselecteerd." - other: "U staat op het punt een nieuw topic te maken en het in te vullen met de %{count} berichten die u hebt geselecteerd." + one: "Je staat op het punt een nieuw topic te maken en het in te vullen met het bericht dat je hebt geselecteerd." + other: "Je staat op het punt een nieuw topic te maken en het in te vullen met de %{count} berichten die je hebt geselecteerd." merge_topic: title: "Verplaatsen naar bestaand topic" action: "verplaatsen naar bestaand topic" @@ -2596,8 +2595,8 @@ nl: radio_label: "Nieuw bericht" participants: "Deelnemers" instructions: - one: "U staat op het punt een nieuw bericht te maken en het in te vullen met het bericht dat u hebt geselecteerd." - other: "U staat op het punt een nieuw bericht te maken en het in te vullen met de %{count} berichten die u hebt geselecteerd." + one: "Je staat op het punt een nieuw bericht te maken en het in te vullen met het bericht dat je hebt geselecteerd." + other: "Je staat op het punt een nieuw bericht te maken en het in te vullen met de %{count} berichten die je hebt geselecteerd." move_to_existing_message: title: "Verplaatsen naar bestaand bericht" action: "verplaatsen naar bestaand bericht" @@ -2620,7 +2619,7 @@ nl: publish_url: "Uw pagina is gepubliceerd op:" topic_published: "Uw topic is gepubliceerd op:" preview_url: "Uw pagina wordt gepubliceerd op:" - invalid_slug: "Sorry, u kunt deze pagina niet publiceren." + invalid_slug: "Sorry, je kunt deze pagina niet publiceren." unpublish: "Publicatie ongedaan maken" unpublished: "Uw pagina is niet meer gepubliceerd en niet meer toegankelijk." publishing_settings: "Publicatie-instellingen" @@ -2661,8 +2660,8 @@ nl: select_all: alles selecteren deselect_all: alles deselecteren description: - one: U hebt %{count} bericht geselecteerd. - other: "U hebt %{count} berichten geselecteerd." + one: Je hebt %{count} bericht geselecteerd. + other: "Je hebt %{count} berichten geselecteerd." post: quote_reply: "Citeren" quote_reply_shortcut: "Of druk op q" @@ -2687,7 +2686,7 @@ nl: one: "%{count} verborgen antwoord weergeven" other: "%{count} verborgen antwoorden weergeven" notice: - new_user: "Dit is de eerste keer dat %{user} iets heeft geplaatst – we heten hem/haar welkom in onze gemeenschap!" + new_user: "Dit is de eerste keer dat %{user} iets heeft geplaatst – we heten hem/haar welkom in onze community!" returning_user: "Het is al even geleden dat we %{user} hebben gezien – hun laatste bericht dateert van %{time}." unread: "Bericht is ongelezen" has_replies: @@ -2698,33 +2697,33 @@ nl: has_likes_title: one: "%{count} persoon heeft dit bericht geliket" other: "%{count} personen hebben dit bericht geliket" - has_likes_title_only_you: "u hebt dit bericht geliket" + has_likes_title_only_you: "je hebt dit bericht geliket" has_likes_title_you: - one: "u en %{count} andere persoon hebben dit bericht geliket" - other: "u en %{count} andere personen hebben dit bericht geliket" + one: "jij en %{count} ander hebben dit bericht geliket" + other: "jij en %{count} anderen hebben dit bericht geliket" filtered_replies_hint: - one: "Dit bericht en zijn antwoord bekijken" - other: "Dit bericht en zijn %{count} antwoorden bekijken" + one: "Dit bericht en 1 antwoord weergeven" + other: "Dit bericht en %{count} antwoorden weergeven" filtered_replies_viewing: - one: "%{count} antwoord aan het bekijken op" - other: "%{count} antwoorden aan het bekijken op" + one: "%{count} antwoord weergegeven op" + other: "%{count} antwoorden weergegeven op" in_reply_to: "Bovenliggend bericht laden" - view_all_posts: "Alle berichten bekijken" + view_all_posts: "Alle berichten weergeven" errors: create: "Sorry, er is een fout opgetreden bij het plaatsen van uw bericht. Probeer het opnieuw." edit: "Sorry, er is een fout opgetreden bij het bewerken van uw bericht. Probeer het opnieuw." upload: "Sorry, er is een fout opgetreden bij het uploaden van dat bestand. Probeer het opnieuw." - file_too_large: "Sorry, dat bestand is te groot (maximale grootte is %{max_size_kb}kb). Misschien kunt u het uploaden naar een cloudopslagdienst, en dan de koppeling plakken?" - too_many_uploads: "Sorry, u kunt maar één bestand tegelijk uploaden." + file_too_large: "Sorry, dat bestand is te groot (maximale grootte is %{max_size_kb}kb). Misschien kunt u het uploaden naar een cloudopslagdienst, en dan de link plakken?" + too_many_uploads: "Sorry, je kunt slechts één bestand tegelijk uploaden." too_many_dragged_and_dropped_files: - one: "Sorry, u kunt maar %{count} bestand tegelijk uploaden." - other: "Sorry, u kunt maar %{count} bestanden tegelijk uploaden." - upload_not_authorized: "Sorry, het bestand dat u probeert te uploaden is niet geautoriseerd (geautoriseerde extensies: %{authorized_extensions})." + one: "Sorry, je kunt slechts %{count} bestand tegelijk uploaden." + other: "Sorry, u kunt slechts %{count} bestanden tegelijk uploaden." + upload_not_authorized: "Sorry, het bestand dat je probeert te uploaden is niet toegestaan (toegestane extensies: %{authorized_extensions})." image_upload_not_allowed_for_new_user: "Sorry, nieuwe gebruikers kunnen geen afbeeldingen uploaden." attachment_upload_not_allowed_for_new_user: "Sorry, nieuwe gebruikers kunnen geen bijlagen uploaden." - attachment_download_requires_login: "Sorry, u dient aangemeld te zijn om bijlagen te downloaden." + attachment_download_requires_login: "Sorry, je moet aangemeld zijn om bijlagen te downloaden." cancel_composer: - confirm: "Wat wilt u met uw bericht doen?" + confirm: "Wat wil je doen met je bericht?" discard: "Negeren" save_draft: "Concept opslaan voor later" keep_editing: "Blijf bewerken" @@ -2733,7 +2732,7 @@ nl: whisper: "dit bericht is alleen toegankelijk voor moderators" wiki: about: "dit bericht is een wiki" - few_likes_left: "Bedankt voor uw steun! U kunt vandaag nog een paar likes uitdelen." + few_likes_left: "Bedankt voor je steun! Je kunt vandaag nog maar een paar likes uitdelen." controls: reply: "beginnen met opstellen van een antwoord op dit bericht" like: "dit bericht liken" @@ -2742,14 +2741,14 @@ nl: undo_like: "like ongedaan maken" edit: "dit bericht bewerken" edit_action: "Bewerken" - edit_anonymous: "Sorry, maar u dient aangemeld te zijn om dit bericht te bewerken." + edit_anonymous: "Sorry, je moet aangemeld zijn om dit bericht te bewerken." flag: "een privémarkering aan dit topic geven of er een privébericht over sturen" delete: "dit bericht verwijderen" undelete: "dit bericht herstellen" - share: "een koppeling naar dit bericht delen" + share: "deel een link naar dit bericht" more: "Meer" delete_replies: - confirm: "Wilt u ook de antwoorden op dit bericht verwijderen?" + confirm: "Wil je ook de antwoorden op dit bericht verwijderen?" direct_replies: one: "Ja, en %{count} direct antwoord" other: "Ja, en %{count} directe antwoorden" @@ -2770,11 +2769,11 @@ nl: lock_post_description: "voorkomen dat de schrijver dit bericht bewerkt" unlock_post: "Bericht ontgrendelen" unlock_post_description: "toestaan dat de schrijver dit bericht bewerkt" - delete_topic_disallowed_modal: "U hebt geen toestemming om dit topic te verwijderen. Als u echt wilt dat het wordt verwijderd, plaats dan een markering voor aandacht van een moderator, en voeg de reden bij." - delete_topic_disallowed: "u hebt geen toestemming om dit topic te verwijderen" + delete_topic_disallowed_modal: "Je hebt geen toestemming om dit topic te verwijderen. Als je echt wilt dat het wordt verwijderd, plaats dan een markering om een moderator erop te wijzigen en voeg de reden bij." + delete_topic_disallowed: "je hebt geen toestemming om dit topic te verwijderen" delete_topic_confirm_modal: - one: "Dit topic heeft momenteel meer dan %{count} weergave en kan een populaire zoekbestemming zijn. Weet u zeker dat u dit topic volledig wilt verwijderen, in plaats van het te bewerken voor verbetering?" - other: "Dit topic heeft momenteel meer dan %{count} weergaven en kan een populaire zoekbestemming zijn. Weet u zeker dat u dit topic volledig wilt verwijderen, in plaats van het te bewerken voor verbetering?" + one: "Dit topic heeft momenteel meer dan %{count} weergave en kan een populaire zoekbestemming zijn. Weet je zeker dat je dit topic volledig wilt verwijderen, in plaats van het te bewerken om het te verbeteren?" + other: "Dit topic heeft momenteel meer dan %{count} weergaven en kan een populaire zoekbestemming zijn. Weet je zeker dat je dit topic volledig wilt verwijderen, in plaats van het te bewerken om het te verbeteren?" delete_topic_confirm_modal_yes: "Ja, dit topic verwijderen" delete_topic_confirm_modal_no: "Nee, dit topic behouden" delete_topic_error: "Er is een fout opgetreden bij het verwijderen van dit topic" @@ -2797,15 +2796,15 @@ nl: one: "en %{count} ander heeft dit gelezen" other: "en %{count} anderen hebben dit gelezen" by_you: - off_topic: "U hebt dit als off-topic gemarkeerd" - spam: "U hebt dit als spam gemarkeerd" - inappropriate: "U hebt dit als ongepast gemarkeerd" - notify_moderators: "U hebt dit voor moderatie gemarkeerd" - notify_user: "U hebt een bericht naar deze gebruiker gestuurd" + off_topic: "Je hebt dit als off-topic gemarkeerd" + spam: "Je hebt dit als spam gemarkeerd" + inappropriate: "Je hebt dit als ongepast gemarkeerd" + notify_moderators: "Je hebt dit voor moderatie gemarkeerd" + notify_user: "Je hebt een bericht naar deze gebruiker gestuurd" delete: confirm: - one: "Weet u zeker dat u dat bericht wilt verwijderen?" - other: "Weet u zeker dat u die %{count} berichten wilt verwijderen?" + one: "Weet je zeker dat je dat bericht wilt verwijderen?" + other: "Weet je zeker dat je die %{count} berichten wilt verwijderen?" revisions: controls: first: "Eerste revisie" @@ -2842,7 +2841,7 @@ nl: bookmarks: create: "Bladwijzer maken" edit: "Bladwijzer bewerken" - edit_for_topic: "Bewerk bladwijzer voor onderwerp" + edit_for_topic: "Bladwijzer voor topic bewerken" created: "Gemaakt" updated: "Bijgewerkt" name: "Naam" @@ -2863,9 +2862,9 @@ nl: name: "Bladwijzer losmaken" description: "Maak de bladwijzer los. Het wordt niet langer boven aan uw bladwijzerlijst weergegeven." filtered_replies: - viewing_posts_by: "%{post_count} berichten aan het bekijken door" + viewing_posts_by: "%{post_count} berichten weergegeven van" viewing_subset: "Sommige antwoorden zijn samengevouwen" - viewing_summary: "Samenvatting van dit topic aan het bekijken" + viewing_summary: "Samenvatting van dit topic weergegeven" post_number: "%{username}, bericht #%{post_number}" show_all: "Alles tonen" category: @@ -2889,7 +2888,7 @@ nl: allow_global_tags_label: "Ook andere tags toestaan" required_tag_group: delete: "Verwijderen" - topic_featured_link_allowed: "Aanbevolen koppelingen in deze categorie toestaan" + topic_featured_link_allowed: "Uitgelichte links toestaan in deze categorie" delete: "Categorie verwijderen" create: "Nieuwe categorie" create_long: "Een nieuwe categorie maken" @@ -2907,7 +2906,7 @@ nl: foreground_color: "Voorgrondkleur" name_placeholder: "Maximaal een of twee woorden" color_placeholder: "Willekeurige webkleur" - delete_confirm: "Weet u zeker dat u deze categorie wilt verwijderen?" + delete_confirm: "Weet je zeker dat je deze categorie wilt verwijderen?" delete_error: "Er is een fout opgetreden bij het verwijderen van de categorie." list: "Lijst van categorieën" no_description: "Voeg een omschrijving toe voor deze categorie." @@ -2917,7 +2916,7 @@ nl: security_add_group: "Een groep toevoegen" permissions: group: "Groep" - see: "Bekijken" + see: "Weergeven" reply: "Antwoorden" create: "Aanmaken" no_groups_selected: "Er is geen toegang tot groepen verleend; deze categorie is alleen zichtbaar voor stafleden." @@ -2925,10 +2924,10 @@ nl: toggle_reply: "Toestemming Antwoorden in-/uitschakelen" toggle_full: "Toestemming Aanmaken in-/uitschakelen" inherited: 'Deze toestemming is overgenomen van ''iedereen''' - special_warning: "Waarschuwing: deze categorie is een vooraf geseede categorie, en de beveiligingsinstellingen kunnen niet worden bewerkt. Als u deze categorie niet wenst te gebruiken, verwijder deze dan in plaats van het doel ervan te wijzigen." + special_warning: "Waarschuwing: deze categorie is een vooraf geseede categorie, de beveiligingsinstellingen kunnen niet worden bewerkt. Als je deze categorie niet wilt gebruiken, verwijder deze dan in plaats van het doel ervan te wijzigen." uncategorized_security_warning: "Deze categorie is bijzonder. Hij is bedoeld als wachtruimte voor topics die geen categorie hebben, en kan geen beveiligingsinstellingen bevatten." - uncategorized_general_warning: 'Deze categorie is bijzonder. Hij is bedoeld als de standaardcategorie voor nieuwe topics die geen selecteerde categorie hebben. Als u dit gedrag wilt voorkomen en categorieselectie wilt afdwingen, schakel de instelling dan hier uit. Als u de naam of omschrijving wilt wijzigen, ga dan naar Aanpassen / Tekstinhoud.' - pending_permission_change_alert: "U hebt %{group} niet aan deze categorie toegevoegd; klik op deze knop om de groep toe te voegen." + uncategorized_general_warning: 'Deze categorie is bijzonder. Hij wordt gebruikt als de standaardcategorie voor nieuwe topics waarvoor geen categorie is selecteerd. Als je dit gedrag wilt voorkomen en categorieselectie wilt afdwingen, schakel de instelling dan hier uit. Als je de naam of beschrijving wilt wijzigen, ga dan naar Aanpassen / Tekstinhoud.' + pending_permission_change_alert: "Je hebt %{group} niet toegevoegd aan deze categorie. Klik op deze knop om de groep toe te voegen." images: "Afbeeldingen" email_in: "Aangepast adres voor inkomende e-mail:" email_in_allow_strangers: "E-mails van anonieme gebruikers zonder account accepteren" @@ -2949,8 +2948,8 @@ nl: allow_badges_label: "Badges laten toekennen in deze categorie" edit_permissions: "Toestemmingen bewerken" review_group_name: "groepsnaam" - require_topic_approval: "Goedkeuring van moderator voor alle nieuwe topics vereisen" - require_reply_approval: "Goedkeuring van moderator voor alle nieuwe antwoorden vereisen" + require_topic_approval: "Goedkeuring van moderator vereisen voor alle nieuwe topics" + require_reply_approval: "Goedkeuring van moderator vereisen voor alle nieuwe antwoorden" this_year: "dit jaar" position: "Positie op de categoriepagina:" default_position: "Standaardpositie" @@ -2962,20 +2961,20 @@ nl: navigate_to_first_post_after_read: "Naar eerste bericht nadat topics zijn gelezen" notifications: watching: - title: "In de gaten houden" - description: "U houdt automatisch alle nieuwe topics in deze categorieën in de gaten. U ontvangt meldingen bij elk nieuw bericht in elk topic, en het aantal nieuwe antwoorden verschijnt naast het topic." + title: "Geobserveerd" + description: "Je observeert automatisch alle nieuwe topics in deze categorieën. Je ontvangt meldingen bij elk nieuw bericht in elk topic en het aantal nieuwe antwoorden wordt weergegeven." watching_first_post: - title: "Eerste bericht in de gaten houden" - description: "U ontvangt meldingen over nieuwe topics in deze categorie, maar niet over antwoorden op de topics." + title: "Eerste bericht geobserveerd" + description: "Je ontvangt meldingen over nieuwe topics in deze categorie, maar niet over antwoorden op de topics." tracking: title: "Volgen" - description: "U volgt automatisch alle nieuwe topics in deze categorie. U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt, en het aantal nieuwe antwoorden wordt weergeven." + description: "Je volgt automatisch alle nieuwe topics in deze categorie. Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt en het aantal nieuwe antwoorden wordt weergeven." regular: title: "Normaal" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." muted: title: "Genegeerd" - description: "U ontvangt geen enkele melding over nieuwe topics in deze categorie, en ze verschijnen niet in Nieuwste." + description: "Je ontvangt geen meldingen over nieuwe topics in deze categorie en ze worden niet weergegeven in Nieuwste." search_priority: label: "Zoekprioriteit" options: @@ -3010,15 +3009,15 @@ nl: list_filters: all: "alle topics" none: "geen subcategorieën" - colors_disabled: "U kunt geen kleuren selecteren omdat u een categoriestijl van geen hebt." + colors_disabled: "Je kunt geen kleuren selecteren omdat je categoriestijl 'none' is." flagging: - title: "Bedankt voor het beleefd houden van onze gemeenschap!" + title: "Bedankt voor het beleefd houden van onze community!" action: "Bericht markeren" take_action: "Actie ondernemen..." take_action_options: default: title: "Actie ondernemen" - details: "De markeerdrempel direct bereiken, in plaats van op meer markeringen door de gemeenschap te wachten" + details: "De markeerdrempel direct bereiken, in plaats van op meer markeringen door de community te wachten" suspend: title: "Gebruiker schorsen" details: "De markeerdrempel bereiken, en de gebruiker schorsen" @@ -3029,20 +3028,20 @@ nl: official_warning: "Officiële waarschuwing" delete_spammer: "Spammer verwijderen" flag_for_review: "Voor beoordeling in wachtrij zetten" - delete_confirm_MF: "U staat op het punt {POSTS, plural, one {# bericht} other {# berichten}} en {TOPICS, plural, one {# topic} other {# topics}} van deze gebruiker te verwijderen, hun account te verwijderen, registraties vanaf hun IP-adres {ip_address} te blokkeren, en hun e-mailadres {email} toe te voegen aan een permanente blokkeerlijst. Weet u het zeker?" + delete_confirm_MF: "Je staat op het punt {POSTS, plural, one {# bericht} other {# berichten}} en {TOPICS, plural, one {# topic} other {# topics}} van deze gebruiker te verwijderen, diens account te verwijderen, registraties vanaf diens IP-adres {ip_address} te blokkeren en diens e-mailadres {email} toe te voegen aan een permanente blokkeerlijst. Weet je het zeker?" yes_delete_spammer: "Ja, spammer verwijderen" ip_address_missing: "(N.v.t.)" hidden_email_address: "(verborgen)" submit_tooltip: "De privémarkering versturen" - take_action_tooltip: "De markeerdrempel direct bereiken, in plaats van op meer markeringen door de gemeenschap te wachten" - cant: "Sorry, u kunt dit bericht momenteel niet markeren." + take_action_tooltip: "De markeerdrempel direct bereiken, in plaats van op meer markeringen door de community te wachten" + cant: "Sorry, je kunt dit bericht momenteel niet markeren." notify_staff: "Beheerders een privémelding sturen" formatted_name: off_topic: "Het is off-topic" inappropriate: "Het is ongepast" spam: "Het is spam" custom_placeholder_notify_user: "Wees specifiek, opbouwend en blijf altijd beleefd." - custom_placeholder_notify_moderators: "Laat ons met name weten waar u zich zorgen om maakt, en stuur relevante koppelingen en voorbeelden mee waar mogelijk." + custom_placeholder_notify_moderators: "Laat ons met name weten waar je je zorgen om maakt en geef relevante links en voorbeelden waar mogelijk." custom_message: at_least: one: "voer minstens %{count} teken in" @@ -3054,19 +3053,19 @@ nl: one: "%{count} resterend" other: "%{count} resterend" flagging_topic: - title: "Bedankt voor het beleefd houden van onze gemeenschap!" + title: "Bedankt voor het beleefd houden van onze community!" action: "Topic markeren" notify_action: "Bericht" topic_map: title: "Topicsamenvatting" participants_title: "Frequente schrijvers" - links_title: "Populaire koppelingen" - links_shown: "meer koppelingen tonen..." + links_title: "Populaire links" + links_shown: "meer links weergeven..." clicks: one: "%{count} klik" other: "%{count} klikken" post_links: - about: "meer koppelingen voor dit bericht uitvouwen" + about: "meer links voor dit bericht uitvouwen" title: one: "nog %{count}" other: "nog %{count}" @@ -3074,7 +3073,7 @@ nl: warning: help: "Dit is een officiële waarschuwing." bookmarked: - help: "U hebt een bladwijzer voor dit topic gemaakt" + help: "Je hebt een bladwijzer voor dit topic gemaakt" locked: help: "Dit topic is gesloten; nieuwe antwoorden zijn niet meer mogelijk" archived: @@ -3091,7 +3090,7 @@ nl: title: "Vastgemaakt" help: "Dit topic is voor u vastgemaakt; het wordt boven in de categorie ervan weergegeven" unlisted: - help: "Dit topic is niet zichtbaar; het verschijnt niet in topiclijsten en kan alleen via een rechtstreekse koppeling worden benaderd" + help: "Dit topic is niet zichtbaar; het wordt niet weergegeven niet in topiclijsten en is alleen toegankelijk via een rechtstreekse link" personal_message: title: "Dit topic is een persoonlijk bericht" help: "Dit topic is een persoonlijk bericht" @@ -3142,7 +3141,7 @@ nl: help: "topics met recente berichten" read: title: "Gelezen" - help: "topics die u hebt gelezen, in de volgorde waarin u ze voor het laatst hebt gelezen" + help: "topics die je hebt gelezen, in de volgorde waarin je ze het laatst hebt gelezen" categories: title: "Categorieën" title_in: "Categorie - %{categoryName}" @@ -3152,7 +3151,7 @@ nl: title_with_count: one: "Ongelezen (%{count})" other: "Ongelezen (%{count})" - help: "topics die u momenteel in de gaten houdt of volgt met ongelezen berichten" + help: "topics die je momenteel observeert of volgt met ongelezen berichten" lower_title_with_count: one: "%{count} ongelezen" other: "%{count} ongelezen" @@ -3171,10 +3170,10 @@ nl: help: "in de afgelopen paar dagen aangemaakte topics" posted: title: "Mijn berichten" - help: "topics waarin u een bericht hebt geplaatst" + help: "topics waarin je een bericht hebt geplaatst" bookmarks: title: "Bladwijzers" - help: "topics waarvoor u een bladwijzer hebt" + help: "topics waarvoor je een bladwijzer hebt" category: title: "%{categoryName}" title_with_count: @@ -3202,11 +3201,11 @@ nl: this_month: "Maand" this_week: "Week" today: "Vandaag" - other_periods: "eerste bekijken:" + other_periods: "top weergeven:" permission_types: - full: "Maken / Antwoorden / Bekijken" - create_post: "Antwoorden / Bekijken" - readonly: "Bekijken" + full: "Maken / Antwoorden / Weergeven" + create_post: "Antwoorden / Weergeven" + readonly: "Weergeven" preloader_text: "Laden" lightbox: download: "downloaden" @@ -3255,7 +3254,7 @@ nl: show_incoming_updated_topics: "%{shortcut} Bijgewerkte topics tonen" search: "%{shortcut} Zoeken" help: "%{shortcut} Hulp voor sneltoetsen openen" - dismiss_new: "%{shortcut} Nieuwe topics negeren" + dismiss_new: "%{shortcut} Nieuwe negeren" dismiss_topics: "%{shortcut} Topics negeren" log_out: "%{shortcut} Afmelden" composing: @@ -3292,12 +3291,12 @@ nl: delete: "%{shortcut} Bericht verwijderen" mark_muted: "%{shortcut} Topic negeren" mark_tracking: "%{shortcut} Topic volgen" - mark_watching: "%{shortcut} Topic in de gaten houden" + mark_watching: "%{shortcut} Topic observeren" print: "%{shortcut} Topic afdrukken" defer: "%{shortcut} Topic negeren" topic_admin_actions: "%{shortcut} Beheeracties voor topic openen" search_menu: - title: "Menu Zoeken" + title: "Zoekmenu" prev_next: "%{shortcut} Selectie omhoog en omlaag verplaatsen" insert_url: "%{shortcut} Selectie in open editorvenster invoegen" full_page_search: "%{shortcut} Start zoeken op volledige pagina" @@ -3308,8 +3307,8 @@ nl: granted_on: "Toegekend op %{date}" others_count: "Anderen met deze badge (%{count})" title: Badges - allow_title: "U kunt deze badge als een titel gebruiken" - multiple_grant: "U kunt dit meerdere keren verdienen" + allow_title: "Je kunt deze badge als een titel gebruiken" + multiple_grant: "Je kunt dit meerdere keren verdienen" badge_count: one: "%{count} badge" other: "%{count} badges" @@ -3321,12 +3320,12 @@ nl: other: "%{count} toegekend" select_badge_for_title: Kies een badge om als uw titel te gebruiken none: "(geen)" - successfully_granted: "%{badge} is succesvol toegekend aan %{username}" + successfully_granted: "%{badge} toegekend aan %{username}" badge_grouping: getting_started: name: Aan de slag community: - name: Gemeenschap + name: Community trust_level: name: Vertrouwensniveau other: @@ -3348,7 +3347,7 @@ nl: info: "Info" default_info: "Deze tag is niet beperkt tot categorieën, en heeft geen synoniemen." staff_info: "Om beperkingen toe te voegen, plaats deze tag in een tag-groep." - category_restricted: "Deze tag is beperkt tot categorieën waartoe u geen toegang hebt." + category_restricted: "Deze tag is beperkt tot categorieën waartoe je geen toegang hebt." synonyms: "Synoniemen" synonyms_description: "Wanneer de volgende tags worden gebruikt, worden deze vervangen door %{base_tag_name}." save: "Sla de naam en omschrijving van de tag op" @@ -3362,16 +3361,16 @@ nl: add_synonyms_label: "Synoniemen toevoegen:" add_synonyms: "Toevoegen" add_synonyms_explanation: - one: "Overal waar deze tag momenteel wordt gebruikt, zal dit worden gewijzigd naar het gebruik van %{tag_name}. Weet u zeker dat u deze wijziging wilt aanbrengen?" - other: "Overal waar deze tags momenteel worden gebruikt, zal dit worden gewijzigd naar het gebruik van %{tag_name}. Weet u zeker dat u deze wijziging wilt aanbrengen?" + one: "Overal waar deze tag momenteel wordt gebruikt, wordt dit gewijzigd naar %{tag_name}. Weet je zeker dat je deze wijziging wilt aanbrengen?" + other: "Overal waar deze tags momenteel worden gebruikt, wordt dit gewijzigd naar %{tag_name}. Weet je zeker dat je deze wijziging wilt aanbrengen?" add_synonyms_failed: "De volgende tags konden niet worden toegevoegd als synoniemen: %{tag_names}. Zorg dat ze geen synoniemen hebben en geen synoniem van een andere tag zijn." remove_synonym: "Synoniem verwijderen" - delete_synonym_confirm: 'Weet u zeker dat u het synoniem ''%{tag_name}'' wilt verwijderen?' + delete_synonym_confirm: 'Weet je zeker dat je het synoniem ''%{tag_name}'' wilt verwijderen?' delete_tag: "Tag verwijderen" delete_confirm: - one: "Weet u zeker dat u deze tag wilt verwijderen en loskoppelen van %{count} topic waaraan deze is toegewezen?" - other: "Weet u zeker dat u deze tag wilt verwijderen en loskoppelen van %{count} topics waaraan deze is toegewezen?" - delete_confirm_no_topics: "Weet u zeker dat u deze tag wilt verwijderen?" + one: "Weet je zeker dat je deze tag wilt verwijderen en loskoppelen van %{count} topic waaraan deze is toegewezen?" + other: "Weet je zeker dat je deze tag wilt verwijderen en loskoppelen van %{count} topics waaraan deze is toegewezen?" + delete_confirm_no_topics: "Weet je zeker dat je deze tag wilt verwijderen?" delete_confirm_synonyms: one: "Het synoniem ervan wordt ook verwijderd." other: "De %{count} synoniemen ervan worden ook verwijderd." @@ -3385,7 +3384,7 @@ nl: upload: "Tags uploaden" upload_description: "Een CSV-bestand uploaden om bulksgewijs tags te maken" upload_instructions: "Eén per regel, optioneel met een tag-groep in de notatie 'tag_name,tag_group'." - upload_successful: "Tags succesvol geüpload" + upload_successful: "Tags geüpload" delete_unused_confirmation: one: "%{count} tag wordt verwijderd: %{tags}" other: "%{count} tags worden verwijderd: %{tags}" @@ -3403,25 +3402,25 @@ nl: untagged_with_category: "%{filter} ongetagde topics in %{category}" notifications: watching: - title: "In de gaten houden" - description: "U houdt automatisch alle nieuwe topics met deze tag in de gaten. U ontvangt meldingen bij alle nieuwe berichten en topics, en het aantal ongelezen en nieuwe berichten verschijnt ook naast het topic." + title: "Geobserveerd" + description: "Je observeert automatisch alle nieuwe topics met deze tag. Je ontvangt meldingen bij alle nieuwe berichten en topics en het aantal ongelezen en nieuwe berichten wordt weergegeven naast het topic." watching_first_post: - title: "Eerste bericht in de gaten houden." - description: "U ontvangt meldingen over nieuwe topics in deze tag, maar niet over antwoorden op de topics." + title: "Eerste bericht geobserveerd" + description: "Je ontvangt meldingen over nieuwe topics in deze tag, maar niet over antwoorden op de topics." tracking: title: "Volgen" - description: "U volgt automatisch alle topics met deze tag. Het aantal ongelezen en nieuwe berichten verschijnt naast het topic." + description: "Je volgt automatisch alle topics met deze tag. Het aantal ongelezen en nieuwe berichten wordt weergegeven naast het topic." regular: title: "Normaal" - description: "U ontvangt een melding als iemand uw @naam noemt of een bericht van u beantwoordt." + description: "Je ontvangt een melding als iemand je @naam noemt of een bericht van je beantwoordt." muted: title: "Genegeerd" - description: "U ontvangt geen enkele melding over nieuwe topics met deze tag, en ze verschijnen niet op uw tabblad Ongelezen." + description: "Je ontvangt geen meldingen over nieuwe topics met deze tag en ze worden niet weergegeven op je tabblad Ongelezen." groups: title: "Tag-groepen" about_heading: "Selecteer een tag-groep of maak een nieuwe aan" about_heading_empty: "Om te beginnen maak een tag-groep aan" - about_description: "Met tag-groepen kunt u toestemmingen voor meerdere tags op één plek beheren." + about_description: "Met taggroepen kun je toestemmingen voor meerdere tags op één plek beheren." new: "Nieuwe groep" new_title: "Maak nieuwe groep" edit_title: "Bewerk tag-groep" @@ -3433,7 +3432,7 @@ nl: name_placeholder: "Naam" save: "Opslaan" delete: "Verwijderen" - confirm_delete: "Weet u zeker dat u deze tag-groep wilt verwijderen?" + confirm_delete: "Weet je zeker dat je deze taggroep wilt verwijderen?" everyone_can_use: "Tags kunnen door iedereen worden gebruikt" usable_only_by_groups: "Tags zijn voor iedereen zichtbaar, maar alleen de volgende groepen kunnen ze gebruiken" visible_only_to_groups: "Tags zijn alleen zichtbaar voor de volgende groepen" @@ -3444,27 +3443,27 @@ nl: disabled: "Taggen is uitgeschakeld. " topics: none: - unread: "U hebt geen ongelezen topics." - unseen: "U hebt geen ongelezen topics." - new: "U hebt geen nieuwe topics." - read: "U hebt nog geen topics gelezen." - posted: "U hebt nog geen berichten in topics geplaatst." + unread: "Je hebt geen ongelezen topics." + unseen: "Je hebt geen ongelezen topics." + new: "Je hebt geen nieuwe topics." + read: "Je hebt nog geen topics gelezen." + posted: "Je hebt nog geen berichten in topics geplaatst." latest: "Er zijn geen nieuwste topics." - bookmarks: "U hebt nog geen topics met bladwijzers." + bookmarks: "Je hebt nog geen topics met bladwijzers." top: "Er zijn geen toptopics." invite: custom_message: "Maak uw uitnodiging iets persoonlijker door een eigen bericht te schrijven." custom_message_placeholder: "Voer uw eigen bericht in" approval_not_required: "De gebruiker wordt automatisch goedgekeurd zodra hij of zij deze uitnodiging accepteert." - custom_message_template_forum: "Hee, u zou aan dit forum moeten deelnemen!" - custom_message_template_topic: "Hee, dit topic lijkt me wel iets voor u!" + custom_message_template_forum: "Hé, je zou moeten deelnemen aan dit forum!" + custom_message_template_topic: "Hé, dit topic lijkt me wat voor jou!" forced_anonymous: "Vanwege overbelasting wordt dit tijdelijk voor iedereen weergegeven zoals niet-aangemelde gebruikers het zien." forced_anonymous_login_required: "De website staat onder extreme belasting en kan op dit moment niet worden geladen, probeer het over een paar minuten opnieuw." footer_nav: back: "Vorige" forward: "Volgende" share: "Delen" - dismiss: "Verwijderen" + dismiss: "Sluiten" safe_mode: enabled: "Veilige modus is ingeschakeld; sluit dit browservenster om de veilige modus te verlaten" image_removed: "(afbeelding verwijderd)" @@ -3488,7 +3487,7 @@ nl: leader: "leider" user_activity: no_activity_title: "Nog geen activiteit" - no_read_topics_body: "Zodra u discussies gaat lezen, ziet u hier een lijst. Om te beginnen met lezen, zoek naar interessante topics onder Top of Categorieën of zoek op trefwoord %{searchIcon}" + no_read_topics_body: "Wanneer je discussies gaat lezen, zie je hier een lijst. Om te beginnen met lezen, kun je zoeken naar interessante topics onder Top of Categorieën of zoeken op trefwoorden %{searchIcon}" sidebar: unread_count: one: "%{count} ongelezen" @@ -3516,7 +3515,7 @@ nl: categories: header_link_text: "Categorieën" community: - header_link_text: "Gemeenschap" + header_link_text: "Community" links: about: content: "Over" @@ -3555,13 +3554,13 @@ nl: last_updated: "Dashboard bijgewerkt:" discourse_last_updated: "Discourse bijgewerkt:" version: "Versie" - up_to_date: "U bent up-to-date!" + up_to_date: "Je bent helemaal bij!" critical_available: "Er is een kritieke update beschikbaar." updates_available: "Er zijn updates beschikbaar." please_upgrade: "Voer een upgrade uit!" no_check_performed: "Er is nog niet op updates gecontroleerd. Zorg dat sidekiq actief is." stale_data: "Er is de laatste tijd niet op updates gecontroleerd. Zorg dat sidekiq actief is." - version_check_pending: "U hebt de software onlangs bijgewerkt. Prachtig!" + version_check_pending: "Je hebt de software onlangs bijgewerkt. Fantastisch!" installed_version: "Geïnstalleerd" latest_version: "Nieuwste" problems_found: "Advies op basis van uw huidige website-instellingen" @@ -3592,7 +3591,7 @@ nl: page_views: "Paginaweergaven" page_views_short: "Paginaweergaven" show_traffic_report: "Gedetailleerd verkeersrapport tonen" - community_health: Status van gemeenschap + community_health: Status van community moderators_activity: Moderator-activiteit whats_new_in_discourse: Wat is er nieuw in Discourse? activity_metrics: Activiteitsgegevens @@ -3605,7 +3604,7 @@ nl: disabled: Uitgeschakeld timeout_error: Sorry, de query duurt te lang. Kies een korter interval. exception_error: Sorry, er is een fout opgetreden bij het uitvoeren van de query. - too_many_requests: U hebt deze actie te vaak uitgevoerd. Wacht even voordat u het opnieuw probeert. + too_many_requests: Je hebt deze actie te vaak uitgevoerd. Wacht voordat je het opnieuw probeert. not_found_error: Dit rapport bestaat niet filter_reports: Rapporten filteren reports: @@ -3632,7 +3631,7 @@ nl: total: "Totaal sinds begin" no_data: "Geen gegevens om weer te geven." trending_search: - more: 'Zoeklogboeken' + more: 'Zoeken in logs' disabled: 'Rapport voor populaire zoekopdrachten is uitgeschakeld. Schakel zoekopdrachten registreren in om gegevens te verzamelen.' average_chart_label: Gemiddeld filters: @@ -3693,8 +3692,8 @@ nl: delete: "Verwijderen" delete_confirm: "Deze groepen verwijderen?" delete_with_messages_confirm: - one: "Als u deze groep verwijdert, wordt %{count} bericht verweesd, groepsleden hebben er geen toegang meer toe.

    Weet u het zeker?" - other: "Als u deze groep verwijdert, worden %{count} berichten verweesd, groepsleden hebben er geen toegang meer toe.

    Weet u het zeker?" + one: "Als je deze groep verwijdert, wordt %{count} bericht verweesd en hebben groepsleden er geen toegang meer toe.

    Weet je het zeker?" + other: "Als je deze groep verwijdert, worden %{count} berichten verweesd en hebben groepsleden er geen toegang meer toe.

    Weet je het zeker?" delete_failed: "Kan groep niet verwijderen. Als dit een automatische groep is, kan deze niet worden verwijderd." delete_automatic_group: Dit is een automatische groep en kan niet worden verwijderd. delete_owner_confirm: "Eigenaarsprivileges van '%{username}' verwijderen?" @@ -3736,12 +3735,12 @@ nl: new_key: Nieuwe API-sleutel revoked: Ingetrokken delete: Definitief verwijderen - not_shown_again: Deze sleutel wordt niet meer weergegeven. Zorg ervoor dat u een kopie maakt voordat u doorgaat. + not_shown_again: Deze sleutel wordt niet meer weergegeven. Zorg dat je een kopie maakt voordat je verder gaat. continue: Doorgaan scopes: description: | - Bij het gebruik van scopes kunt u een API-sleutel tot een bepaalde groep eindpunten beperken. - U kunt ook definiëren welke parameters worden toegestaan. Gebruik komma's om meerdere waarden te scheiden. + Bij het gebruik van bereiken kun je een API-sleutel beperken tot een bepaalde groep eindpunten. + Je kunt ook definiëren welke parameters worden toegestaan. Gebruik komma's om meerdere waarden te scheiden. title: Scopes read_only: Alleen-lezen global: Globaal @@ -3770,8 +3769,8 @@ nl: web_hooks: title: "Webhooks" none: "Er zijn op dit moment geen webhooks." - instruction: "Via webhooks kan Discourse externe services waarschuwen wanneer bepaalde gebeurtenissen op uw website plaatsvinden. Zodra de webhook wordt geactiveerd, wordt een POST-aanvraag naar opgegeven URL's verstuurd." - detailed_instruction: "Zodra de gekozen gebeurtenis plaatsvindt, wordt een POST-aanvraag naar de opgegeven URL verstuurd." + instruction: "Via webhooks kan Discourse externe services waarschuwen wanneer bepaalde gebeurtenissen plaatsvinden op je website. Zodra de webhook wordt geactiveerd, wordt een POST-verzoek gestuurd naar opgegeven URL's." + detailed_instruction: "Zodra de gekozen gebeurtenis plaatsvindt, wordt een POST-aanvraag naar de opgegeven URL gestuurd." new: "Nieuwe webhook" create: "Aanmaken" save: "Opslaan" @@ -3781,11 +3780,10 @@ nl: go_back: "Terug naar lijst" payload_url: "Payload-URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Het lijkt erop dat u probeert de webhook in te stellen op een lokale URL. Gebeurtenissen die op een lokaal adres worden afgeleverd, kunnen bijwerkingen of onverwacht gedrag veroorzaken. Doorgaan?" secret_invalid: "Geheim mag geen lege tekens bevatten." secret_too_short: "Geheim dient uit minimaal 12 tekens te bestaan." secret_placeholder: "Een optionele tekenreeks, gebruikt voor het aanmaken van een ondertekening" - event_type_missing: "U dient minstens één gebeurtenistype in te stellen." + event_type_missing: "Je moet minimaal één gebeurtenistype instellen." content_type: "Inhoudstype" secret: "Geheim" event_chooser: "Welke gebeurtenissen moeten deze webhook activeren?" @@ -3851,7 +3849,7 @@ nl: other: "Voltooid in %{count} seconden." request: "Aanvraag" response: "Reactie" - redeliver_confirm: "Weet u zeker dat u dezelfde payload opnieuw wilt afleveren?" + redeliver_confirm: "Weet je zeker dat je dezelfde payload opnieuw wilt afleveren?" headers: "Koppen" payload: "Payload" body: "Inhoud" @@ -3868,7 +3866,7 @@ nl: title: "Plug-ins" installed: "Geïnstalleerde plug-ins" name: "Naam" - none_installed: "U hebt geen plug-ins geïnstalleerd." + none_installed: "Je hebt geen plug-ins geïnstalleerd." version: "Versie" enabled: "Ingeschakeld?" is_enabled: "J" @@ -3881,18 +3879,18 @@ nl: title: "Back-ups" menu: backups: "Back-ups" - logs: "Logboeken" + logs: "Logs" none: "Geen back-up beschikbaar." read_only: enable: title: "Alleen-lezenmodus inschakelen" label: "Alleen-lezen inschakelen" - confirm: "Weet u zeker dat u de alleen-lezenmodus wilt inschakelen?" + confirm: "Weet je zeker dat je de alleen-lezenmodus wilt inschakelen?" disable: title: "Alleen-lezenmodus uitschakelen" label: "Alleen-lezen uitschakelen" logs: - none: "Nog geen logboeken..." + none: "Nog geen logs..." columns: filename: "Bestandsnaam" size: "Grootte" @@ -3901,47 +3899,47 @@ nl: title: "Een back-up naar deze instantie uploaden" uploading: "Uploaden..." uploading_progress: "Uploaden... %{progress}%" - success: "'%{filename}' is succesvol geüpload. Het bestand wordt nu verwerkt en zal na hoogstens een minuut in de lijst verschijnen." + success: "'%{filename}' is geüpload. Het bestand wordt nu verwerkt, het kan tot een minuut duren voordat het in de lijst wordt weergegeven." error: "Er is een fout opgetreden bij het uploaden van '%{filename}': %{message}" operations: is_running: "Er wordt al een bewerking uitgevoerd..." - failed: "De bewerking %{operation} is mislukt. Controleer de logboeken." + failed: "%{operation} mislukt. Controleer de logs." cancel: label: "Annuleren" title: "De huidige bewerking annuleren" - confirm: "Weet u zeker dat u de huidige bewerking wilt annuleren?" + confirm: "Weet je zeker dat je de huidige bewerking wilt annuleren?" backup: label: "Back-up maken" title: "Een back-up maken" - confirm: "Wilt u een nieuwe back-up starten?" + confirm: "Wil je een nieuwe back-up starten?" without_uploads: "Ja (geen uploads bijvoegen)" download: label: "Downloaden" - title: "E-mail met downloadkoppeling verzenden" - alert: "Er is een koppeling voor het downloaden van deze back-up naar u verzonden via e-mail." + title: "E-mail met downloadlink sturen" + alert: "Er is een link naar je gestuurd via e-mail om deze back-up te downloaden." destroy: title: "De back-up verwijderen" - confirm: "Weet u zeker dat u deze back-up wilt verwijderen?" + confirm: "Weet je zeker dat je deze back-up wilt verwijderen?" restore: is_disabled: "Terugzetten is uitgeschakeld in de website-instellingen." label: "Terugzetten" title: "De back-up terugzetten" - confirm: "Weet u zeker dat u deze back-up wilt terugzetten?" + confirm: "Weet je zeker dat je deze back-up wilt terugzetten?" rollback: label: "Terugdraaien" title: "De database naar de vorige werkende status terugzetten" - confirm: "Weet u zeker dat u de database naar de vorige werkende status wilt terugzetten?" + confirm: "Weet je zeker dat je de database wilt terugzetten naar de vorige werkende status?" location: local: "Lokale opslag" s3: "S3" backup_storage_error: "Toegang tot back-upopslag is mislukt: %{error_message}" export_csv: - success: "Exporteren is gestart; u ontvangt een bericht zodra het proces is voltooid." - failed: "Exporteren is mislukt. Controleer de logboeken." + success: "Exporteren is gestart. Je ontvangt een bericht zodra het proces is voltooid." + failed: "Exporteren mislukt. Controleer de logs." button_text: "Exporteren" button_title: user: "Volledige gebruikerslijst exporteren in CSV-indeling" - staff_action: "Volledig stafactielogboek exporteren in CSV-indeling" + staff_action: "Volledige stafactielog exporteren in CSV-indeling" screened_email: "Volledige lijst van gecontroleerde e-mails exporteren in CSV-indeling" screened_ip: "Volledige lijst van gecontroleerde IP-adressen exporteren in CSV-indeling" screened_url: "Volledige lijst van gecontroleerde URL's exporteren in CSV-indeling" @@ -3960,7 +3958,7 @@ nl: new_style: "Nieuwe stijl" install: "Installeren" delete: "Verwijderen" - delete_confirm: 'Weet u zeker dat u ''%{theme_name}'' wilt verwijderen?' + delete_confirm: 'Weet je zeker dat je ''%{theme_name}'' wilt verwijderen?' color: "Kleur" opacity: "Ondoorzichtigheid" copy_to_clipboard: "Kopiëren naar klembord" @@ -3970,10 +3968,10 @@ nl: email_templates: title: "E-mailadres" subject: "Onderwerp" - multiple_subjects: "Deze e-mailsjabloon heeft meerdere onderwerpen." + multiple_subjects: "Deze e-mailsjabloon heeft meerdere topics." body: "Body" revert: "Wijzigingen ongedaan maken" - revert_confirm: "Weet u zeker dat u uw wijzigingen ongedaan wilt maken?" + revert_confirm: "Weet je zeker dat je je wijzigingen ongedaan wilt maken?" theme: theme: "Thema" component: "Onderdeel" @@ -3984,7 +3982,7 @@ nl: themes_intro_emoji: "kunstenares-emoji" beginners_guide_title: "Beginnersgids voor het gebruik van Discourse-thema's" developers_guide_title: "Ontwikkelaarsgids voor Discourse-thema's" - browse_themes: "Gemeenschapsthema's bekijken" + browse_themes: "Communitythema's bekijken" customize_desc: "Aanpassen:" title: "Thema's" create: "Aanmaken" @@ -3992,8 +3990,8 @@ nl: create_name: "Naam" long_title: "Kleuren, CSS en HTML-inhoud van uw website aanpassen" edit: "Bewerken" - edit_confirm: "Dit is een extern thema; als u CSS/HTML bewerkt, worden uw wijzigingen de volgende keer dat u het thema bijwerkt gewist." - update_confirm: "Deze lokale wijzigingen worden door de update gewist. Weet u zeker dat u wilt doorgaan?" + edit_confirm: "Dit is een extern thema. Als je CSS/HTML bewerkt, worden je wijzigingen gewist de volgende keer dat je het thema bijwerkt." + update_confirm: "Deze lokale wijzigingen worden gewist door de update. Weet je zeker dat je wilt doorgaan?" update_confirm_yes: "Ja, doorgaan met de update" common: "Algemeen" desktop: "Desktop" @@ -4019,11 +4017,11 @@ nl: theme_components: "Themaonderdelen" add_all_themes: "Alle thema's toevoegen" convert: "Converteren" - convert_component_alert: "Weet u zeker dat u dit onderdeel naar een thema wilt converteren? Het wordt als onderdeel van %{relatives} verwijderd." + convert_component_alert: "Weet je zeker dat je dit onderdeel naar een thema wilt converteren? Het wordt verwijderd als onderdeel van %{relatives}." convert_component_tooltip: "Dit onderdeel naar een thema converteren" - convert_component_alert_generic: "Weet u zeker dat u dit onderdeel naar een thema wilt converteren?" - convert_theme_alert: "Weet u zeker dat u dit thema naar een onderdeel wilt converteren? Het wordt als bovenliggend thema van %{relatives} verwijderd." - convert_theme_alert_generic: "Weet u zeker dat u dit thema naar een onderdeel wilt converteren?" + convert_component_alert_generic: "Weet je zeker dat je dit onderdeel naar een thema wilt converteren?" + convert_theme_alert: "Weet je zeker dat je dit thema naar een onderdeel wilt converteren? Het wordt verwijderd als bovenliggend thema van %{relatives}." + convert_theme_alert_generic: "Weet je zeker dat je dit thema naar een onderdeel wilt converteren?" convert_theme_tooltip: "Dit thema naar een onderdeel converteren" inactive_themes: "Inactieve thema's:" inactive_components: "Ongebruikte onderdelen:" @@ -4034,7 +4032,7 @@ nl: and_x_more: "en nog %{count}." collapse: Samenvouwen uploads: "Uploads" - no_uploads: "U kunt aan uw thema gerelateerde assets uploaden, zoals lettertypen en afbeeldingen" + no_uploads: "Je kunt assets behorende bij je thema uploaden, zoals lettertypen en afbeeldingen" add_upload: "Upload toevoegen" upload_file_tip: "Kies een asset om te uploaden (png, woff2, etc...)" variable_name: "SCSS-variabelenaam:" @@ -4045,32 +4043,31 @@ nl: must_be_unique: "Ongeldige naam van variabele. Moet uniek zijn." upload: "Upload" select_component: "Selecteer een onderdeel..." - unsaved_changes_alert: "U hebt uw wijzigingen nog niet opgeslagen. Wilt u ze negeren en verdergaan?" - unsaved_parent_themes: "U hebt het onderdeel niet aan thema's toegewezen. Wilt u verdergaan?" + unsaved_changes_alert: "Je hebt je wijzigingen nog niet opgeslagen. Wil je ze negeren en verder gaan?" + unsaved_parent_themes: "Je hebt het onderdeel niet toegewezen aan thema's. Wil je verder gaan?" discard: "Negeren" stay: "Blijven" css_html: "Aangepaste CSS/HTML" edit_css_html: "CSS/HTML bewerken" - edit_css_html_help: "U hebt geen CSS of HTML bewerkt" - delete_upload_confirm: "Deze upload verwijderen? (Thema-CSS zou kunnen stoppen met werken!)" + edit_css_html_help: "Je hebt geen CSS of HTML bewerkt" + delete_upload_confirm: "Deze upload verwijderen? (Thema-CSS kan stoppen met werken!)" component_on_themes: "Onderdeel voor deze thema's bijvoegen" included_components: "Opgenomen onderdelen" add_all: "Alle toevoegen" import_web_tip: "Repository die thema bevat" - direct_install_tip: "Weet u zeker dat u %{name} uit de onderstaande repository wilt installeren?" + direct_install_tip: "Weet je zeker dat je %{name} uit de onderstaande repository wilt installeren?" import_web_advanced: "Geavanceerd..." import_file_tip: ".tar.gz-, .zip- of .dcstyle.json-bestand dat thema bevat" is_private: "Thema bevindt zich in een privé-git-repository" remote_branch: "Branchnaam (optioneel)" public_key: "De volgende openbare sleutel toegang tot de repo verlenen:" - public_key_note: "Na het hierboven invoeren van een geldig privé repository-URL wordt een SSH-sleutel gegenereerd en hier weergegeven." install: "Installeren" installed: "Geïnstalleerd" install_popular: "Populair" install_upload: "Vanaf uw apparaat" install_git_repo: "Vanaf een git-repository" install_create: "Nieuwe maken" - duplicate_remote_theme: "Het themaonderdeel “%{name}” is al geïnstalleerd, weet u zeker dat u nog een kopie wilt installeren?" + duplicate_remote_theme: "Het themaonderdeel “%{name}” is al geïnstalleerd, weet je zeker dat je nog een exemplaar wilt installeren?" about_theme: "Over" license: "Licentie" version: "Versie:" @@ -4100,7 +4097,7 @@ nl: one: "Thema loopt %{count} commit achter!" other: "Thema loopt %{count} commits achter!" compare_commits: "(Nieuwe commits bekijken)" - remote_theme_edits: "Als u dit thema wilt bewerken, moet u een wijziging indienen in de repository" + remote_theme_edits: "Als je dit thema wilt bewerken, moet je een wijziging indienen in de repository" repo_unreachable: "Kon geen contact krijgen met de Git-repository van dit thema. Foutbericht:" imported_from_archive: "Dit thema is vanuit een .zip-bestand geïmporteerd" scss: @@ -4164,10 +4161,10 @@ nl: description: "De hoofdachtergrondkleur, en tekstkleur van sommige knoppen." tertiary: name: "tertiaire" - description: "Koppelingen, sommige knoppen, meldingen en accentkleur." + description: "Links, sommige knoppen, meldingen en accentkleur." quaternary: name: "quaternaire" - description: "Navigatiekoppelingen." + description: "Navigatielinks." header_background: name: "koptekstachtergrond" description: "Achtergrondkleur van de koptekst van de website." @@ -4202,7 +4199,7 @@ nl: html: "HTML-sjabloon" css: "CSS" reset: "Standaardwaarden terugzetten" - reset_confirm: "Weet u zeker dat u de standaardwaarden van %{fieldName} wilt terugzetten en alle wijzigingen wilt verwerpen?" + reset_confirm: "Weet je zeker dat je de standaardwaarde voor %{fieldName} wilt herstellen en alle wijzigingen wilt verwerpen?" save_error_with_reason: "Uw wijzigingen zijn niet opgeslagen. %{error}" instructions: "De sjabloon waarin alle html-e-mails worden gerenderd aanpassen, en stileren via CSS." email: @@ -4212,12 +4209,12 @@ nl: preview_digest: "Voorbeeld van samenvatting" advanced_test: title: "Geavanceerde test" - desc: "Bekijken hoe Discourse ontvangen e-mails verwerkt. Plak hieronder het volledige oorspronkelijke e-mailbericht om de e-mail goed te kunnen laten verwerken." + desc: "Zie hoe Discourse ontvangen e-mails verwerkt. Plak hieronder het volledige oorspronkelijke e-mailbericht zodat de e-mail goed wordt verwerkt." email: "Oorspronkelijke bericht" run: "Test uitvoeren" text: "Hoofdtekst van geselecteerde tekst" elided: "Weggelaten tekst" - sending_test: "Testmail wordt verstuurd..." + sending_test: "Test-e-mail wordt verzonden..." error: "FOUT - %{server_error}" test_error: "Er is een probleem opgetreden bij het versturen van de testmail. Controleer uw mailinstellingen, controleer of uw host geen mailverbindingen blokkeert, en probeer het daarna opnieuw." sent: "Verzonden" @@ -4234,11 +4231,11 @@ nl: send_test: "Testmail verzenden" sent_test: "verzonden!" delivery_method: "Verzendmethode" - preview_digest_desc: "Een voorbeeld bekijken van de e-mailsamenvattingen die naar inactieve leden worden verzonden." + preview_digest_desc: "Bekijk een voorbeeld van de e-mailsamenvattingen die naar inactieve leden worden gestuurd." refresh: "Vernieuwen" send_digest_label: "Dit resultaat verzenden naar:" send_digest: "Verzenden" - sending_email: "E-mail wordt verzonden..." + sending_email: "E-mail verzenden..." format: "Indeling" html: "html" text: "tekst" @@ -4257,23 +4254,23 @@ nl: modal: title: "Details van inkomende e-mail" error: "Fout" - headers: "Kopregels" + headers: "Headers" subject: "Onderwerp" body: "Tekst" - rejection_message: "Weigeringsmail" + rejection_message: "Afwijzings-e-mail" filters: - from_placeholder: "van@example.com" - to_placeholder: "aan@example.com" - cc_placeholder: "cc@example.com" + from_placeholder: "van@voorbeeld.com" + to_placeholder: "aan@voorbeeld.com" + cc_placeholder: "cc@voorbeeld.com" subject_placeholder: "Onderwerp..." error_placeholder: "Fout" logs: - none: "Geen logboeken gevonden." + none: "Geen logs gevonden." filters: title: "Filter" user_placeholder: "gebruikersnaam" - address_placeholder: "naam@example.com" - type_placeholder: "samenvatting, registratie..." + address_placeholder: "naam@voorbeeld.com" + type_placeholder: "digest, registratie..." reply_key_placeholder: "antwoordsleutel" moderation_history: performed_by: "Uitgevoerd door" @@ -4286,12 +4283,12 @@ nl: delete_topic: "Topic verwijderd" post_approved: "Bericht goedgekeurd" logs: - title: "Logboeken" + title: "Logs" action: "Actie" created_at: "Gemaakt" - last_match_at: "Laatste overeenkomst" - match_count: "Overeenkomsten" - ip_address: "IP" + last_match_at: "Laatst gematcht" + match_count: "Matches" + ip_address: "IP-adres" topic_id: "Topic-ID" post_id: "Bericht-ID" category_id: "Categorie-ID" @@ -4361,14 +4358,14 @@ nl: deactivate_user: "gebruiker deactiveren" change_readonly_mode: "alleen-lezenmodus wijzigen" backup_download: "back-up downloaden" - backup_destroy: "back-up verwijderen" + backup_destroy: "back-up vernietigen" reviewed_post: "bericht beoordeeld" custom_staff: "aangepaste actie voor plug-in" post_locked: "bericht vergrendeld" post_edit: "bericht bewerken" post_unlocked: "bericht ontgrendeld" check_personal_message: "persoonlijk bericht controleren" - disabled_second_factor: "tweefactorauthenticatie uitschakelen" + disabled_second_factor: "tweeledige verificatie uitschakelen" topic_published: "topic gepubliceerd" post_approved: "bericht goedgekeurd" post_rejected: "bericht afgekeurd" @@ -4384,20 +4381,20 @@ nl: web_hook_update: "webhook bijwerken" web_hook_destroy: "webhook verwijderen" web_hook_deactivate: "webhook deactiveren" - embeddable_host_create: "inbedbare host maken" - embeddable_host_update: "inbedbare host bijwerken" - embeddable_host_destroy: "inbedbare host verwijderen" + embeddable_host_create: "insluitbare host maken" + embeddable_host_update: "insluitbare host bijwerken" + embeddable_host_destroy: "insluitbare host verwijderen" change_theme_setting: "thema-instelling wijzigen" disable_theme_component: "themaonderdeel uitschakelen" enable_theme_component: "themaonderdeel inschakelen" revoke_title: "titel intrekken" change_title: "titel wijzigen" - api_key_create: "api-sleutel maken" - api_key_update: "api-sleutel bijwerken" - api_key_destroy: "api-sleutel verwijderen" + api_key_create: "API-sleutel maken" + api_key_update: "API-sleutel bijwerken" + api_key_destroy: "API-sleutel verwijderen" override_upload_secure_status: "status beveiligd uploaden negeren" page_published: "pagina gepubliceerd" - page_unpublished: "pagina niet gepubliceerd" + page_unpublished: "pagina gedepubliceerd" add_email: "e-mailadres toevoegen" update_email: "e-mailadres bijwerken" destroy_email: "e-mailadres verwijderen" @@ -4421,8 +4418,8 @@ nl: domain: "Domein" screened_ips: title: "Gecontroleerde IP-adressen" - description: 'IP-adressen die in de gaten worden gehouden. Gebruik ''Toestaan'' om IP-adressen op de acceptatielijst te zetten.' - delete_confirm: "Weet u zeker dat u de regel voor %{ip_address} wilt verwijderen?" + description: 'IP-adressen die worden geobserveerd. Gebruik ''Toestaan'' om IP-adressen op de acceptatielijst te zetten.' + delete_confirm: "Weet je zeker dat je de regel voor %{ip_address} wilt verwijderen?" actions: block: "Blokkeren" do_nothing: "Toestaan" @@ -4436,7 +4433,7 @@ nl: text: "Samenvoegen" title: "Maakt nieuwe subnet-banvermeldingen als er minstens 'min_ban_entries_for_roll_up'-vermeldingen zijn." search_logs: - title: "Zoeklogboeken" + title: "Zoeken in logs" term: "Term" searches: "Zoekopdrachten" click_through_rate: "CTR" @@ -4447,38 +4444,38 @@ nl: click_through_only: "Alle (alleen doorklikken)" header_search_results: "Zoekresultaten voor koptekst" logster: - title: "Foutlogboeken" + title: "Foutenlogs" watched_words: - title: "In de gaten gehouden woorden" + title: "Geobserveerde woorden" search: "zoeken" clear_filter: "Wissen" show_words: - one: "%{count} woord tonen" - other: "%{count} woorden tonen" + one: "%{count} woord weergeven" + other: "%{count} woorden weergeven" download: Downloaden clear_all: Alles wissen - clear_all_confirm: "Weet u zeker dat u alle in de gaten gehouden woorden wilt verwijderen voor de %{action} actie?" + clear_all_confirm: "Weet je zeker dat je alle geobserveerde woorden wilt verwijderen voor de actie %{action}?" actions: block: "Blokkeren" censor: "Censureren" - require_approval: "Heeft goedkeuring nodig" + require_approval: "Goedkeuring vereisen" flag: "Markeren" replace: "Vervangen" tag: "Taggen" silence: "Dempen" - link: "Koppeling" + link: "Link" action_descriptions: block: "Het plaatsen van berichten die deze woorden bevatten tegengaan. Gebruikers zien een foutbericht wanneer ze hun bericht proberen te verzenden." censor: "Berichten met deze woorden toestaan, maar deze vervangen door tekens die de gecensureerde woorden verbergen." require_approval: "Berichten die deze woorden bevatten, vereisen goedkeuring door stafleden voordat ze kunnen worden bekeken." - flag: "Berichten met deze woorden toestaan, maar deze markeren als ongepast zodat moderators ze kunnen beoordelen." - tag: "Automatisch topics taggen op basis van het eerste bericht" + flag: "Sta berichten met deze woorden toe, maar markeer ze als ongepast, zodat moderators ze kunnen beoordelen." + tag: "Tag topics automatisch op basis van het eerste bericht" form: placeholder_regexp: "reguliere expressie" replace_label: "Vervanging" tag_label: "Tag" - link_label: "Koppeling" - link_placeholder: "https://example.com" + link_label: "Link" + link_placeholder: "https://voorbeeld.com" add: "Toevoegen" success: "Succes" exists: "Bestaat al" @@ -4486,22 +4483,22 @@ nl: upload_successful: "Uploaden geslaagd. Woorden zijn toegevoegd." test: button_label: "Testen" - description: "Voer hieronder tekst in voor controle op in de gaten gehouden woorden" + description: "Voer hieronder tekst in om te controleren op matches met geobserveerde woorden" found_matches: "Gevonden overeenkomsten:" no_matches: "Geen overeenkomsten gevonden" impersonate: - title: "Aanmelden als gebruiker" - help: "Gebruik dit hulpmiddel om een gebruikersaccount voor debugdoeleinden te imiteren. U moet zich afmelden als u klaar bent." - not_found: "Die gebruiker kan niet worden gevonden." - invalid: "Sorry, u mag zich niet aanmelden als die gebruiker." + title: "Imiteren" + help: "Gebruik deze tool om een gebruikersaccount te imiteren voor debugdoeleinden. Je moet je afmelden wanneer je klaar bent." + not_found: "Die gebruiker kon niet worden gevonden." + invalid: "Sorry, je mag je niet aanmelden als die gebruiker." users: title: "Gebruikers" create: "Beheerder toevoegen" - last_emailed: "Laatst gemaild" + last_emailed: "Laatst ge-e-maild" not_found: "Sorry, die gebruikersnaam bestaat niet in ons systeem." id_not_found: "Sorry, die gebruikers-ID bestaat niet in ons systeem." active: "Geactiveerd" - show_emails: "E-mailadressen tonen" + show_emails: "E-mailadressen weergeven" hide_emails: "E-mailadressen verbergen" nav: new: "Nieuw" @@ -4509,26 +4506,26 @@ nl: staff: "Stafleden" suspended: "Geschorst" silenced: "Gedempt" - staged: "Staged" + staged: "Gefaseerd" approved: "Goedgekeurd?" titles: active: "Actieve gebruikers" new: "Nieuwe gebruikers" - pending: "Nog niet goedgekeurde gebruikers" - newuser: "Gebruikers op vertrouwensniveau 0 (Nieuwe gebruiker)" - basic: "Gebruikers op vertrouwensniveau 1 (Basisgebruiker)" - member: "Gebruikers op vertrouwensniveau 2 (Lid)" - regular: "Gebruikers op vertrouwensniveau 3 (Vaste gebruiker)" - leader: "Gebruikers op vertrouwensniveau 4 (Leider)" + pending: "Gebruikers in afwachting van beoordeling" + newuser: "Gebruikers op vertrouwensniveau 0 (nieuwe gebruiker)" + basic: "Gebruikers op vertrouwensniveau 1 (basisgebruiker)" + member: "Gebruikers op vertrouwensniveau 2 (lid)" + regular: "Gebruikers op vertrouwensniveau 3 (regelmatige gebruiker)" + leader: "Gebruikers op vertrouwensniveau 4 (leider)" staff: "Stafleden" admins: "Beheerders" moderators: "Moderators" silenced: "Gedempte gebruikers" suspended: "Geschorste gebruikers" - staged: "Staged gebruikers" + staged: "Gefaseerde gebruikers" not_verified: "Niet geverifieerd" check_email: - title: "E-mailadres van deze gebruiker tonen" + title: "E-mailadres van deze gebruiker weergeven" text: "Tonen" check_sso: title: "SSO-payload tonen" @@ -4537,24 +4534,24 @@ nl: suspend_failed: "Er is iets misgegaan bij het schorsen van deze gebruiker: %{error}" unsuspend_failed: "Er is iets misgegaan bij het opheffen van de schorsing van deze gebruiker: %{error}" suspend_duration: "Hoelang wordt de gebruiker geschorst?" - suspend_reason_label: "Waarom schorst u deze gebruiker? Deze tekst zal voor iedereen zichtbaar zijn op de profielpagina van deze gebruiker, en zal aan de gebruiker worden getoond als deze zich probeert aan te melden. Houd het kort." - suspend_reason_hidden_label: "Waarom schorst u? Deze tekst wordt aan de gebruiker getoond wanneer deze zich probeert aan te melden. Hou het kort." + suspend_reason_label: "Waarom schors je deze gebruiker? Deze tekst is zichtbaar voor iedereen op de profielpagina van deze gebruiker en wordt getoond aan de gebruiker wanneer deze zich probeert aan te melden. Houd het kort." + suspend_reason_hidden_label: "Waarom schors je? Deze tekst wordt aan de gebruiker getoond wanneer deze zich probeert aan te melden. Houd het kort." suspend_reason: "Reden" suspend_reason_title: "Reden van schorsing" suspend_reasons: not_listening_to_staff: "Wilde niet naar feedback van stafleden luisteren" consuming_staff_time: "Heeft onevenredig veel tijd van stafleden verbruikt" in_wrong_place: "Op de verkeerde plek" - no_constructive_purpose: "Geen constructief doel voor hun acties, anders dan het creëren van onenigheid binnen de gemeenschap" + no_constructive_purpose: "Geen constructief doel voor hun acties, anders dan het creëren van onenigheid binnen de community" custom: "Aangepast..." suspend_message: "E-mailbericht" - suspend_message_placeholder: "Optioneel kunt u meer informatie over de schorsing geven, die naar de gebruiker wordt gemaild." + suspend_message_placeholder: "Optioneel kun je meer informatie over de schorsing geven, wat naar de gebruiker wordt gestuurd via e-mail." suspended_by: "Geschorst door" silence_reason: "Reden" silenced_by: "Gedempt door" silence_modal_title: "Gebruiker dempen" silence_duration: "Hoelang wordt de gebruiker gedempt?" - silence_reason_label: "Waarom dempt u deze gebruiker?" + silence_reason_label: "Waarom demp je deze gebruiker?" silence_reason_placeholder: "Reden van dempen" silence_message: "E-mailbericht" silence_message_placeholder: "(laat leeg om standaardbericht te sturen)" @@ -4562,8 +4559,8 @@ nl: cant_suspend: "Deze gebruiker kan niet worden geschorst." delete_posts_failed: "Er is een probleem opgetreden bij het verwijderen van de berichten." post_edits: "Berichtbewerkingen" - view_edits: "Bewerkingen bekijken" - penalty_post_actions: "Wat wilt u met het gekoppelde bericht doen?" + view_edits: "Bewerkingen weergeven" + penalty_post_actions: "Wat wil je doen met het bijbehorende bericht?" penalty_post_delete: "Het bericht verwijderen" penalty_post_delete_replies: "Het bericht + alle antwoorden verwijderen" penalty_post_edit: "Het bericht bewerken" @@ -4572,7 +4569,7 @@ nl: clear_penalty_history: title: "Minpuntengeschiedenis wissen" description: "gebruikers met minpunten kunnen geen TL3 bereiken" - delete_all_posts_confirm_MF: "U staat op het punt {POSTS, plural, one {# bericht} other {# berichten}} en {TOPICS, plural, one {# topic} other {# topics}} te verwijderen. Weet u het zeker?" + delete_all_posts_confirm_MF: "Je staat op het punt {POSTS, plural, one {# bericht} other {# berichten}} en {TOPICS, plural, one {# topic} other {# topics}} te verwijderen. Weet je het zeker?" silence: "Dempen" unsilence: "Dempen opheffen" silenced: "Gedempt?" @@ -4581,31 +4578,31 @@ nl: suspended: "Geschorst?" staged: "Staged?" show_admin_profile: "Beheerder" - show_public_profile: "Openbaar profiel tonen" - impersonate: "Aanmelden als gebruiker" - action_logs: "Actielogboeken" + show_public_profile: "Openbaar profiel weergeven" + impersonate: "Imiteren" + action_logs: "Actielogs" ip_lookup: "IP-adres zoeken" log_out: "Afmelden" - logged_out: "Gebruiker is op alle apparaten afgemeld" + logged_out: "Gebruiker is afgemeld van alle apparaten" revoke_admin: "Beheerdersrechten intrekken" grant_admin: "Beheerdersrechten toekennen" - grant_admin_confirm: "We hebben u een e-mail gestuurd om de nieuwe beheerder te verifiëren. Open deze en volg de instructies." + grant_admin_confirm: "We hebben je een e-mail gestuurd om de nieuwe beheerder te verifiëren. Open deze en volg de instructies." revoke_moderation: "Moderatierechten intrekken" grant_moderation: "Moderatierechten toekennen" unsuspend: "Schorsing opheffen" suspend: "Schorsen" - show_flags_received: "Ontvangen markeringen tonen" + show_flags_received: "Ontvangen markeringen weergeven" flags_received_by: "Ontvangen markeren van %{username}" flags_received_none: "Deze gebruiker heeft geen markeringen ontvangen." reputation: Reputatie permissions: Toestemmingen activity: Activiteit like_count: Gegeven / ontvangen likes - last_100_days: "in de laatste 100 dagen" + last_100_days: "de afgelopen 100 dagen" private_topics_count: Privétopics posts_read_count: Gelezen berichten post_count: Gemaakte berichten - second_factor_enabled: Tweefactorauthenticatie ingeschakeld + second_factor_enabled: Tweeledige verificatie ingeschakeld topics_entered: Bekeken topics flags_given_count: Gegeven markeringen flags_received_count: Ontvangen markeringen @@ -4618,27 +4615,27 @@ nl: time_read: "Leestijd" post_edits_count: "Berichtbewerkingen" anonymize: "Gebruiker anonimiseren" - anonymize_confirm: "Weet u ZEKER dat u deze account wilt anonimiseren? Hierdoor worden de gebruikersnaam en het e-mailadres gewijzigd, en alle profielgegevens opnieuw ingesteld." - anonymize_yes: "Ja, deze account anonimiseren" - anonymize_failed: "Er is een probleem opgetreden bij het anonimiseren van de account." + anonymize_confirm: "Weet je ZEKER dat je dit account wilt anonimiseren? Hierdoor worden de gebruikersnaam en het e-mailadres gewijzigd en worden alle profielgegevens opnieuw ingesteld." + anonymize_yes: "Ja, dit account anonimiseren" + anonymize_failed: "Er is een probleem opgetreden bij het anonimiseren van het account." delete: "Gebruiker verwijderen" delete_posts: button: "Alle berichten verwijderen" progress: - title: "Voortgang van het verwijderen van berichten" + title: "Voortgang van berichten verwijderen" description: "Berichten verwijderen..." confirmation: cancel: "Annuleren" merge: button: "Samenvoegen" prompt: - title: "Overdragen & @%{username} verwijderen" + title: "Overdragen en @%{username}en verwijderen" description: |

    Kies een nieuwe eigenaar voor de inhoud van @%{username}.

    -

    Alle topics, berichten en andere inhoud die door @%{username} is gemaakt, wordt overgedragen.

    +

    Alle topics, berichten en andere inhoud gemaakt door @%{username} worden overgedragen.

    target_username_placeholder: "Gebruikersnaam van nieuwe eigenaar" - transfer_and_delete: "Overdragen & @%{username} verwijderen" + transfer_and_delete: "Overdragen en @%{username}en verwijderen" cancel: "Annuleren" progress: title: "Voortgang met samenvoegen" @@ -4658,8 +4655,8 @@ nl: delete_forbidden_because_staff: "Beheerders en moderators kunnen niet worden verwijderd." delete_posts_forbidden_because_staff: "Kan niet alle berichten van beheerders en moderators verwijderen." delete_forbidden: - one: "Gebruikers kunnen niet worden verwijderd als ze berichten hebben geplaatst. Verwijder alle berichten voordat u een gebruiker probeert te verwijderen. (Berichten ouder dan %{count} dag kunnen niet worden verwijderd.)" - other: "Gebruikers kunnen niet worden verwijderd als ze berichten hebben geplaatst. Verwijder alle berichten voordat u een gebruiker probeert te verwijderen. (Berichten ouder dan %{count} dagen kunnen niet worden verwijderd.)" + one: "Gebruikers kunnen niet worden verwijderd als ze berichten hebben geplaatst. Verwijder alle berichten voordat je een gebruiker probeert te verwijderen. (Berichten ouder dan %{count} dag kunnen niet worden verwijderd.)" + other: "Gebruikers kunnen niet worden verwijderd als ze berichten hebben geplaatst. Verwijder alle berichten voordat je een gebruiker probeert te verwijderen. (Berichten ouder dan %{count} dagen kunnen niet worden verwijderd.)" cant_delete_all_posts: one: "Kan niet alle berichten verwijderen. Sommige berichten zijn ouder dan %{count} dag. (De instelling delete_user_max_post_age.)" other: "Kan niet alle berichten verwijderen. Sommige berichten zijn ouder dan %{count} dagen. (De instelling delete_user_max_post_age.)" @@ -4670,9 +4667,9 @@ nl: delete_dont_block: "Alleen verwijderen" deleting_user: "Gebruiker verwijderen..." deleted: "De gebruiker is verwijderd." - delete_failed: "Er is een fout opgetreden bij het verwijderen van die gebruiker. Zorg ervoor dat alle berichten zijn verwijderd voordat u de gebruiker probeert te verwijderen." + delete_failed: "Er is een fout opgetreden bij het verwijderen van de gebruiker. Zorg dat alle berichten zijn verwijderd voordat je de gebruiker probeert te verwijderen." send_activation_email: "Activeringsmail versturen" - activation_email_sent: "Er is een activeringsmail verstuurd." + activation_email_sent: "Er is een activerings-e-mail gestuurd." send_activation_email_failed: "Er is een probleem opgetreden bij het versturen van de activeringsmail. %{error}" activate: "Account activeren" activate_failed: "Er is een probleem opgetreden bij het activeren van de gebruiker." @@ -4680,7 +4677,7 @@ nl: deactivate_failed: "Er is een probleem opgetreden bij het deactiveren van de gebruiker." unsilence_failed: "Er is een probleem opgetreden bij het opheffen van het dempen van de gebruiker." silence_failed: "Er is een probleem opgetreden bij het dempen van de gebruiker." - silence_confirm: "Weet u zeker dat u deze gebruiker wilt dempen? De gebruiker zal dan geen nieuwe topics of berichten kunnen plaatsen." + silence_confirm: "Weet je zeker dat je deze gebruiker wilt dempen? De gebruiker kan dan geen nieuwe topics of berichten plaatsen." silence_accept: "Ja, deze gebruiker dempen" bounce_score: "Bouncescore" reset_bounce_score: @@ -4697,11 +4694,10 @@ nl: threshold_reached: "Er zijn te veel bounceberichten van dat e-mailadres ontvangen." trust_level_change_failed: "Er is een probleem opgetreden bij het wijzigen van het vertrouwensniveau van de gebruiker." suspend_modal_title: "Gebruiker schorsen" - confirm_cancel_penalty: "Weet u zeker dat u het minpunt wilt negeren?" + confirm_cancel_penalty: "Weet je zeker dat je de straf wilt wissen?" trust_level_2_users: "Gebruikers met vertrouwensniveau 2" trust_level_3_requirements: "Vereisten voor vertrouwensniveau 3" - trust_level_locked_tip: "vertrouwensniveau is vergrendeld; het systeem zal geen gebruikers promoveren of degraderen" - trust_level_unlocked_tip: "vertrouwensniveau is ontgrendeld; het systeem kan gebruikers promoveren of degraderen" + trust_level_locked_tip: "vertrouwensniveau is vergrendeld; het systeem promoveert en degradeert geen gebruikers" lock_trust_level: "Vertrouwensniveau vergrendelen" unlock_trust_level: "Vertrouwensniveau ontgrendelen" silenced_count: "Gedempt" @@ -4710,12 +4706,12 @@ nl: title: "Vereisten voor vertrouwensniveau 3" table_title: one: "De afgelopen dag:" - other: "In de afgelopen %{count} dagen:" + other: "De afgelopen %{count} dagen:" value_heading: "Waarde" requirement_heading: "Vereiste" visits: "Bezoeken" days: "dagen" - topics_replied_to: "Topics waarin is geantwoord" + topics_replied_to: "Beantwoorde topics" topics_viewed: "Bekeken topics" topics_viewed_all_time: "Bekeken topics (sinds begin)" posts_read: "Gelezen berichten" @@ -4730,13 +4726,13 @@ nl: silenced: "Gedempt (afgelopen 6 maanden)" qualifies: "Komt in aanmerking voor vertrouwensniveau 3." does_not_qualify: "Komt niet in aanmerking voor vertrouwensniveau 3." - will_be_promoted: "Zal binnenkort worden gepromoveerd." - will_be_demoted: "Zal binnenkort worden gedegradeerd." - on_grace_period: "Op dit moment in promotiewachtperiode, zal niet worden gedegradeerd." - locked_will_not_be_promoted: "Vertrouwensniveau vergrendeld. Zal nooit worden gepromoveerd." - locked_will_not_be_demoted: "Vertrouwensniveau vergrendeld. Zal nooit worden gedegradeerd." + will_be_promoted: "Wordt binnenkort gepromoveerd." + will_be_demoted: "Wordt binnenkort gedegradeerd." + on_grace_period: "Momenteel in promotiewachtperiode, wordt niet gedegradeerd." + locked_will_not_be_promoted: "Vertrouwensniveau vergrendeld. Wordt nooit gepromoveerd." + locked_will_not_be_demoted: "Vertrouwensniveau vergrendeld. Wordt nooit gedegradeerd." discourse_connect: - title: "DiscourseConnect Single Sign On" + title: "DiscourseConnect eenmalige aanmelding" external_id: "Externe ID" external_username: "Gebruikersnaam" external_name: "Naam" @@ -4744,39 +4740,34 @@ nl: external_avatar_url: "URL van profielafbeelding" last_payload: "Laatste payload" delete_sso_record: "SSO-record verwijderen" - confirm_delete: "Weet u zeker dat u dit DiscourseConnect-record wilt verwijderen?" + confirm_delete: "Weet je zeker dat je dit DiscourseConnect-record wilt verwijderen?" user_fields: title: "Gebruikersvelden" - help: "Voeg velden toe die uw gebruikers kunnen invullen." + help: "Voeg velden toe die je gebruikers kunnen invullen." create: "Gebruikersveld maken" untitled: "Geen titel" name: "Veldnaam" type: "Veldtype" - description: "Veldomschrijving" + description: "Veldbeschrijving" save: "Opslaan" edit: "Bewerken" delete: "Verwijderen" cancel: "Annuleren" - delete_confirm: "Weet u zeker dat u dat gebruikersveld wilt verwijderen?" + delete_confirm: "Weet je zeker dat je dat gebruikersveld wilt verwijderen?" options: "Opties" required: - title: "Vereist bij registratie?" enabled: "vereist" disabled: "niet vereist" editable: - title: "Bewerkbaar na registratie?" enabled: "bewerkbaar" disabled: "niet bewerkbaar" show_on_profile: - title: "Tonen in openbaar profiel?" - enabled: "getoond in profiel" - disabled: "niet getoond in profiel" + enabled: "weergegeven in profiel" + disabled: "niet weergegeven in profiel" show_on_user_card: - title: "Tonen op gebruikerskaart?" enabled: "getoond op gebruikerskaart" disabled: "niet getoond op gebruikerskaart" searchable: - title: "Doorzoekbaar?" enabled: "doorzoekbaar" disabled: "niet doorzoekbaar" field_types: @@ -4784,12 +4775,12 @@ nl: confirm: "Bevestiging" dropdown: "Vervolgkeuzelijst" site_text: - description: "U kunt alle tekst op uw forum aanpassen. Begin door hieronder te zoeken:" - search: "Zoek de tekst die u wilt bewerken" + description: "Je kunt alle tekst op je forum aanpassen. Begin door hieronder te zoeken:" + search: "Zoek de tekst die je wilt bewerken" title: "Tekst" edit: "bewerken" revert: "Wijzigingen ongedaan maken" - revert_confirm: "Weet u zeker dat u uw wijzigingen ongedaan wilt maken?" + revert_confirm: "Weet je zeker dat je je wijzigingen ongedaan wilt maken?" go_back: "Terug naar Zoeken" recommended: "We raden aan de volgende tekst naar wens aan te passen:" show_overriden: "Alleen aangepaste tonen" @@ -4797,7 +4788,7 @@ nl: more_than_50_results: "Er zijn meer dan 50 resultaten. Verfijn uw zoekopdracht." settings: show_overriden: "Alleen aangepaste tonen" - history: "Wijzigingsoverzicht bekijken" + history: "Wijzigingsgeschiedenis weergeven" reset: "terugzetten" none: "geen" site_settings: @@ -4836,11 +4827,11 @@ nl: spam: "Spam" rate_limits: "Frequentielimieten" developer: "Ontwikkelaar" - embedding: "Inbedding" + embedding: "Insluiting" legal: "Juridisch" api: "API" user_api: "Gebruikers-API" - uncategorized: "Overige" + uncategorized: "Overig" backups: "Back-ups" login: "Aanmelden" plugins: "Plug-ins" @@ -4852,7 +4843,7 @@ nl: secret_list: invalid_input: "Invoervelden mogen niet leeg zijn of verticale-streeptekens bevatten." default_categories: - modal_description: "Wilt u deze wijziging op het verleden toepassen? Hierdoor worden voor %{count} bestaande gebruikers voorkeuren gewijzigd." + modal_description: "Wil je deze wijziging op het verleden toepassen? Hierdoor worden voorkeuren gewijzigd voor %{count} bestaande gebruikers." modal_yes: "Ja" modal_no: "Nee, wijziging alleen vanaf nu toepassen" simple_list: @@ -4867,22 +4858,22 @@ nl: name: Naam badge: Badge display_name: Weergavenaam - description: Omschrijving - long_description: Lange omschrijving + description: Beschrijving + long_description: Lange beschrijving badge_type: Badgetype badge_grouping: Groep badge_groupings: modal_title: Badgegroeperingen granted_by: Toegekend door granted_at: Toegekend op - reason_help: (Een koppeling naar een bericht of topic) + reason_help: (Een link naar een bericht of topic) save: Opslaan delete: Verwijderen - delete_confirm: Weet u zeker dat u deze badge wilt verwijderen? + delete_confirm: Weet je zeker dat je deze badge wilt verwijderen? revoke: Intrekken reason: Reden expand: Uitvouwen … - revoke_confirm: Weet u zeker dat u deze badge wilt intrekken? + revoke_confirm: Weet je zeker dat je deze badge wilt intrekken? edit_badges: Badges bewerken grant_badge: Badge toekennen granted_badges: Toegekende badges @@ -4892,7 +4883,7 @@ nl: none_selected: "Selecteer een badge om te beginnen" allow_title: Badge mag als titel worden gebruikt multiple_grant: Kan meerdere malen worden toegekend - listable: Badge op de openbare badgespagina tonen + listable: Badge weergeven op de openbare badgespagina enabled: Badge inschakelen icon: Pictogram image: Afbeelding @@ -4918,7 +4909,7 @@ nl: plan_text: "Voorbeeld met queryplan" modal_title: "Voorbeeld van badgequery" sql_error_header: "Er is een fout opgetreden bij de query." - error_help: "Bekijk de volgende koppelingen voor hulp bij badgequery's." + error_help: "Zie de volgende links voor hulp bij vragen over badges." bad_count_warning: header: "WAARSCHUWING!" text: "Er ontbreken toekenningsvoorbeelden. Dit gebeurt als de badgequery gebruikers- of bericht-ID's retourneert die niet bestaan. Dit kan later tot onverwachte resultaten leiden - controleer uw query." @@ -4953,33 +4944,33 @@ nl: name: "Naam" group: "Groep" image: "Afbeelding" - alt: "voorbeeld van eigen emoji" - delete_confirm: "Weet u zeker dat u de emoji :%{name}: wilt verwijderen?" + alt: "voorbeeld van aangepaste emoji" + delete_confirm: "Weet je zeker dat je de emoji :%{name}: wilt verwijderen?" embedding: - get_started: "Als u Discourse in een andere website wilt inbedden, begin dan door de host ervan toe te voegen." - confirm_delete: "Weet u zeker dat u die host wilt verwijderen?" - title: "Inbedding" + get_started: "Als je Discourse wilt insluiren op een andere website, begin dan door de host ervan toe te voegen." + confirm_delete: "Weet je zeker dat je die host wilt verwijderen?" + title: "Insluiting" host: "Toegestane hosts" class_name: "Klassenaam" allowed_paths: "Pad-acceptatielijst" edit: "bewerken" category: "Bericht naar categorie" add_host: "Host toevoegen" - settings: "Inbeddingsinstellingen" + settings: "Insluitingsinstellingen" crawling_settings: "Crawlerinstellingen" - crawling_description: "Als Discourse topics voor uw berichten aanmaakt en er geen RSS/ATOM-feed aanwezig is, wordt geprobeerd uw inhoud vanuit HTML te parsen. Omdat het soms een uitdaging kan zijn om inhoud te extraheren, bieden we de mogelijkheid voor het opgeven van CSS-regels om de extractie makkelijker te maken." + crawling_description: "Als Discourse topics maakt voor je berichten en er geen RSS/ATOM-feed aanwezig is, wordt geprobeerd je content te parsen vanuit HTML. Omdat het soms een uitdaging kan zijn om content te extraheren, bieden we de mogelijkheid voor het opgeven van CSS-regels om de extractie makkelijker te maken." embed_by_username: "Gebruikersnaam voor het maken van topics" - embed_post_limit: "Maximale aantal berichten om in te bedden" - embed_title_scrubber: "Reguliere expressie voor het afleiden van de titels van berichten" - embed_truncate: "Ingebedde berichten inkorten" - embed_unlisted: "Geïmporteerde topics worden onzichtbaar totdat er antwoord is." - allowed_embed_selectors: "CSS-selector voor elementen die bij inbedding worden toegestaan" - blocked_embed_selectors: "CSS-selector voor elementen die bij inbedding worden verwijderd" - allowed_embed_classnames: "Toegestane CSS-klassenamen" - save: "Inbeddingsinstellingen opslaan" + embed_post_limit: "Maximaal aantal in te sluiten berichten" + embed_title_scrubber: "Reguliere expressie voor het afleiden van de titel van berichten" + embed_truncate: "Ingesloten berichten afkappen" + embed_unlisted: "Geïmporteerde topics worden onzichtbaar totdat er een antwoord is." + allowed_embed_selectors: "CSS-kiezer voor elementen die zijn toegestaan in insluitingen" + blocked_embed_selectors: "CSS-kiezer voor elementen die worden verwijderd uit insluitingen" + allowed_embed_classnames: "Namen toegestane CSS-klassen" + save: "Insluitingsinstellingen opslaan" permalink: title: "Permalinks" - description: "Toe te passen omleiding voor URL's die voor het forum niet bekend zijn." + description: "Toe te passen omleiding voor URL's die onbekend zijn voor het forum." url: "URL" topic_id: "Topic-ID" topic_title: "Topic" @@ -4990,8 +4981,8 @@ nl: tag_name: "Tagnaam" external_url: "Externe of relatieve URL" destination: "Bestemming" - copy_to_clipboard: "Permalink naar klembord kopiëren" - delete_confirm: Weet u zeker dat u deze permalink wilt verwijderen? + copy_to_clipboard: "Permalink kopiëren naar klembord" + delete_confirm: Weet je zeker dat je deze permalink wilt verwijderen? form: label: "Nieuw:" add: "Toevoegen" @@ -5008,7 +4999,7 @@ nl: replace: "Vervangen" wizard_js: wizard: - back: "Vorige" + back: "Terug" next: "Volgende" step-text: "Stap" step: "%{current} van %{total}" @@ -5016,11 +5007,11 @@ nl: uploading: "Uploaden..." upload_error: "Er is een fout opgetreden bij het uploaden van dat bestand. Probeer het opnieuw." staff_count: - one: "Uw gemeenschap heeft %{count} staflid (u)." - other: "Uw gemeenschap heeft %{count} stafleden, waaronder u." + one: "Uw community heeft %{count} staflid (u)." + other: "Uw community heeft %{count} stafleden, waaronder u." invites: add_user: "toevoegen" - none_added: "U hebt geen stafleden uitgenodigd. Weet u zeker dat u wilt verdergaan?" + none_added: "Je hebt geen stafleden uitgenodigd. Weet je zeker dat je door wilt gaan?" roles: admin: "Beheerder" moderator: "Moderator" diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index 1c7306782b..a8a18ab753 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -290,6 +290,7 @@ pl_PL: delete: "Usuń" generic_error: "Przepraszamy, wystąpił błąd." generic_error_with_reason: "Wystąpił błąd: %{error}" + multiple_errors: "Wystąpiło wiele błędów: %{errors}" sign_up: "Rejestracja" log_in: "Logowanie" age: "Wiek" @@ -1086,6 +1087,7 @@ pl_PL: user_fields: none: "(wybierz opcję)" required: 'Wprowadź wartość dla "%{name}”' + same_as_password: "Twoje hasło nie powinno się powtarzać w innych polach." user: said: "%{username}:" profile: "Profil" @@ -1182,6 +1184,8 @@ pl_PL: no_notifications_title: "Nie masz jeszcze żadnych powiadomień" no_notifications_body: > W tym panelu zostaniesz powiadomiony o aktywności bezpośrednio związanej z Tobą, w tym o odpowiedziach na Twoje tematy i posty, gdy ktoś @oznaczy lub zacytuje Cię i odpowie na obserwowane przez Ciebie tematy. Powiadomienia będą również wysyłane na Twój adres e-mail, gdy nie jesteś zalogowany przez jakiś czas.

    Poszukaj %{icon} aby zdecydować, o których konkretnych tematach, kategoriach i tagach chcesz otrzymywać powiadomienia. Więcej informacji znajdziesz w preferencjach powiadomień. + no_other_notifications_body: > + W tym panelu będziesz otrzymywać powiadomienia o innych rodzajach aktywności, które mogą być dla Ciebie istotne - na przykład gdy ktoś zamieści link do jednego z Twoich postów lub go edytuje. no_notifications_page_title: "Nie masz jeszcze żadnych powiadomień" no_notifications_page_body: > Zostaniesz powiadomiony o działaniach bezpośrednio związanych z Tobą, w tym o odpowiedziach na Twoje tematy i posty, gdy ktoś @oznaczy Ciebie lub cytuje Ciebie, i odpowiada na tematy, które oglądasz. Powiadomienia będą również wysyłane na Twój adres e-mail, gdy nie zalogujesz się przez jakiś czas.

    Poszukaj %{icon} , aby zdecydować, o których konkretnych tematach, kategoriach i tagach chcesz być powiadamiany. Aby dowiedzieć się więcej, zobacz swoje preferencje powiadomień. @@ -1192,6 +1196,7 @@ pl_PL: not_first_time: "To nie twój pierwszy raz?" skip_link: "Pomiń te wskazówki" read_later: "Przeczytam to później." + reset_seen_popups: "Pokaż ponownie wskazówki wprowadzające" theme_default_on_all_devices: "Ustaw to jako domyślny motyw na wszystkich urządzeniach" color_scheme_default_on_all_devices: "Ustaw domyślne schematy kolorów na wszystkich moich urządzeniach" color_scheme: "Schemat kolorów" @@ -1218,8 +1223,6 @@ pl_PL: tags_section: "Sekcja tagów" tags_section_instruction: "Wybrane tagi będą wyświetlane w sekcji tagów paska bocznego." navigation_section: "Nawigacja" - list_destination_default: "Domyślny" - list_destination_unread_new: "Nowe/Nieprzeczytane" change: "zmień" featured_topic: "Wyróżniony temat" moderator: "%{user} jest moderatorem" @@ -1805,8 +1808,13 @@ pl_PL: remove_status: "Usuń status" popup: primary: "Zrozumiano!" + secondary: "nie pokazuj mi tych wskazówek" first_notification: title: "Twoje pierwsze powiadomienie!" + content: "Powiadomienia służą do informowania na bieżąco o tym, co dzieje się w społeczności." + topic_timeline: + title: "Oś czasu tematu" + content: "Przewiń szybko post, korzystając z osi czasu tematu." loading: "Wczytuję…" errors: prev_page: "podczas próby wczytania" @@ -1839,6 +1847,8 @@ pl_PL: enabled: "Strona jest w trybie tylko-do-odczytu. Możesz ją nadal przeglądać, ale operacje takie jak publikowanie postów, polubianie i inne są wyłączone." login_disabled: "Logowanie jest zablokowane, gdy strona jest w trybie tylko do odczytu." logout_disabled: "Wylogowanie jest zablokowane gdy strona jest w trybie tylko do odczytu." + staff_writes_only_mode: + enabled: "Ta strona jest w trybie tylko dla personelu. Kontynuuj przeglądanie, ale odpowiadanie, polubienia i inne działania są ograniczone tylko do członków personelu." too_few_topics_and_posts_notice_MF: >- Zacznijmy dyskutować! Mamy {currentTopics, plural, one {# temat} few {# tematy} other {# tematów}} i {currentPosts, plural, one {# wpis} few {# wpisy} other {# wpisów}}. Odwiedzający potrzebują więcej do dyskusji – zalecamy przynajmniej {requiredTopics, plural, one {# temat} few {# tematy} other {# tematów}} i {requiredPosts, plural, one {# wpis} few {# wpisy} other {# wpisów}}. Tylko członkowie zespołu widzą tę wiadomość. too_few_topics_notice_MF: >- @@ -1872,6 +1882,7 @@ pl_PL: hide_forever: "nie, dziękuję" hidden_for_session: "OK, zapytamy Cię jutro. Zawsze możesz też użyć opcji „Zaloguj się”, aby utworzyć konto." intro: "Hej! Wygląda na to, że zainteresowała Cię ta dyskusja, ale nie posiadasz jeszcze konta." + value_prop: "Masz dość przewijania tych samych postów? Kiedy stworzysz konto, zawsze wrócisz do miejsca, w którym przerwałeś. Z kontem możesz również otrzymywać powiadomienia o nowych odpowiedziach, zapisywać zakładki i używać polubień, aby podziękować innym. Wszyscy możemy współpracować, aby ta społeczność była świetna. :heart:" summary: enabled_description: "Przeglądasz podsumowanie tego tematu: widoczne są jedynie najbardziej wartościowe wpisy zdaniem uczestników. " description: @@ -2554,17 +2565,54 @@ pl_PL: view_all: "zobacz wszystkie %{tab}" user_menu: generic_no_items: "Na tej liście nie ma żadnych pozycji." + sr_menu_tabs: "Zakładki menu użytkownika" view_all_notifications: "zobacz wszystkie powiadomienia" view_all_bookmarks: "zobacz wszystkie zakładki" view_all_messages: "wyświetl wszystkie wiadomości osobiste" tabs: all_notifications: "Wszystkie powiadomienia" replies: "Odpowiedzi" + replies_with_unread: + one: "Odpowiedzi - %{count} nieprzeczytana odpowiedź" + few: "Odpowiedzi - %{count} nieprzeczytane odpowiedzi" + many: "Odpowiedzi - %{count} nieprzeczytanych odpowiedzi" + other: "Odpowiedzi - %{count} nieprzeczytanych odpowiedzi" mentions: "Wzmianki" + mentions_with_unread: + one: "Wzmianki - %{count} nieprzeczytana wzmianka" + few: "Wzmianki - %{count} nieprzeczytane wzmianki" + many: "Wzmianki - %{count} nieprzeczytanych wzmianek" + other: "Wzmianki - %{count} nieprzeczytanych wzmianek" likes: "Otrzymane polubienia" + likes_with_unread: + one: "Polubienia - %{count} nieprzeczytane polubienie" + few: "Polubienia - %{count} nieprzeczytane polubienia" + many: "Polubienia - %{count} nieprzeczytanych polubień" + other: "Polubienia - %{count} nieprzeczytanych polubień" watching: "Obserwowane tematy" + watching_with_unread: + one: "Obserwowane tematy - %{count} nieprzeczytany obserwowany temat" + few: "Obserwowane tematy - %{count} nieprzeczytane obserwowane tematy" + many: "Obserwowane tematy - %{count} nieprzeczytanych obserwowanych tematów" + other: "Obserwowane tematy - %{count} nieprzeczytanych obserwowanych tematów" messages: "Wiadomości osobiste" + messages_with_unread: + one: "Wiadomości osobiste - %{count} nieprzeczytana wiadomość" + few: "Wiadomości osobiste - %{count} nieprzeczytane wiadomości" + many: "Wiadomości osobiste - %{count} nieprzeczytanych wiadomości" + other: "Wiadomości osobiste - %{count} nieprzeczytanych wiadomości" bookmarks: "Zakładki" + bookmarks_with_unread: + one: "Zakładki - %{count} nieprzeczytana zakładka" + few: "Zakładki - %{count} nieprzeczytane zakładki" + many: "Zakładki - %{count} nieprzeczytanych zakładek" + other: "Zakładki - %{count} nieprzeczytanych zakładek" + review_queue: "Kolejka do przeglądu" + review_queue_with_unread: + one: "Kolejka do przeglądu - %{count} pozycja wymaga przeglądu" + few: "Kolejka do przeglądu - %{count} pozycje wymagają przeglądu" + many: "Kolejka recenzji - %{count} pozycji wymaga przeglądu" + other: "Kolejka recenzji - %{count} pozycji wymaga przeglądu" other_notifications: "Inne powiadomienia" other_notifications_with_unread: one: "Inne powiadomienia - %{count} nieprzeczytane powiadomienie" @@ -2573,6 +2621,7 @@ pl_PL: other: "Inne powiadomienia - %{count} nieprzeczytanych powiadomień" profile: "Profil" reviewable: + view_all: "zobacz wszystkie elementy do przeglądu" queue: "Kolejka" deleted_user: "(usunięty użytkownik)" deleted_post: "(usunięty post)" @@ -3145,6 +3194,7 @@ pl_PL: few: "pokaż %{count} ukryte odpowiedzi" many: "pokaż %{count} ukrytych odpowiedzi" other: "pokaż %{count} ukrytych odpowiedzi" + sr_reply_to: "Odpowiedz na post #%{post_number} od @%{username}" notice: new_user: "%{user} opublikował(a) coś po raz pierwszy - powitajmy tę osobę w naszej społeczności!" returning_user: "Minęło trochę czasu, odkąd widzieliśmy %{user} - ostatni wpis tego użytkownika był %{time}." @@ -3392,6 +3442,7 @@ pl_PL: all: "Wszystkie kategorie" choose: "kategoria…" edit: "Edytuj" + edit_title: "Edytuj tę kategorię" edit_dialog_title: "Edytuj: %{categoryName}" view: "Pokaż Tematy w Kategorii" back: "Powrót do kategorii" @@ -4151,12 +4202,20 @@ pl_PL: unread_with_count: "Nieprzeczytane (%{count})" archive: "Archiwum" tags: + links: + add_tags: + content: "Dodaj tagi" + title: "Nie dodałeś żadnych tagów. Kliknij, aby rozpocząć." none: "Nie dodałeś żadnych tagów." click_to_get_started: "Kliknij tutaj, aby rozpocząć." header_link_text: "Etykiety" header_action_title: "edytuj tagi paska bocznego" configure_defaults: "Skonfiguruj ustawienia domyślne" categories: + links: + add_categories: + content: "Dodaj kategorie" + title: "Nie dodano żadnych kategorii. Kliknij, aby rozpocząć." none: "Nie dodałeś żadnych kategorii." click_to_get_started: "Kliknij tutaj, aby rozpocząć." header_link_text: "Kategorie" @@ -4197,8 +4256,10 @@ pl_PL: review: content: "Sprawdź" title: "sprawdź" + pending_count: "%{count} oczekujących" welcome_topic_banner: title: "Stwórz swój temat powitalny" + description: "Twój temat powitalny to pierwsza rzecz, którą przeczytają nowi członkowie. Pomyśl o tym jako o swoim „przemówieniu” lub „deklaracji misji”. Poinformuj wszystkich, dla kogo jest ta społeczność, czego mogą się tu spodziewać, i co chcesz, aby zrobili najpierw." button_title: "Rozpocznij edycję" until: "Aż do:" admin_js: @@ -4479,7 +4540,6 @@ pl_PL: go_back: "Powrót do listy" payload_url: "Zawartość URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Wygląda na to, że próbujesz skonfigurować webhook na lokalny adres URL. Event dostarczony na adres lokalny może spowodować efekt uboczny lub nieoczekiwane zachowanie. Kontynuować?" secret_invalid: "Sekret nie może zawierać pustych znaków." secret_too_short: "Sekret musi zawierać przynajmniej 12 znaków." secret_placeholder: "Opcjonalna fraza użyta do generowania podpisu" @@ -4524,6 +4584,7 @@ pl_PL: name: "Zdarzenie powiadomienia" details: "Kiedy użytkownik otrzymuje powiadomienie." user_promoted_event: + name: "Wydarzenie promowania użytkownika" details: "Gdy użytkownik awansuje z jednego poziomu zaufania na inny." user_badge_event: name: "Wydarzenie przyznania odznak" @@ -4771,7 +4832,6 @@ pl_PL: last_attempt: "Proces instalacji nie został zakończony, ostatnia próba:" remote_branch: "Nazwa oddziału (opcjonalnie)" public_key: "Podaj następujący klucz publiczny pozwalający na dostęp do repozytorium:" - public_key_note: "Po wprowadzeniu powyżej poprawnego adresu URL prywatnego repozytorium klucz SSH zostanie wygenerowany i wyświetlony tutaj." install: "Zainstaluj" installed: "Zainstalowana" install_popular: "Popularne" @@ -4803,6 +4863,7 @@ pl_PL: has_overwritten_history: "Bieżąca wersja motywu już nie istnieje, ponieważ historia Git została nadpisana przez wymuszony push." add: "Dodaj" theme_settings: "Ustawienia motywu" + overriden_settings_explanation: "Zastąpione ustawienia są oznaczone kropką i mają podświetlony kolor. Aby zresetować te ustawienia do wartości domyślnych, naciśnij przycisk resetowania obok nich." no_settings: "Ten temat nie ma żanych ustawień" theme_translations: "Tłumaczenia tematu" empty: "Brak elementu" @@ -5453,7 +5514,6 @@ pl_PL: trust_level_2_users: "Użytkownicy o 2. poziomie zaufania" trust_level_3_requirements: "Wymagania 3. poziomu zaufania" trust_level_locked_tip: "poziom zaufania jest zablokowany, system nie będzie awansować lub degradować tego użytkownika" - trust_level_unlocked_tip: "poziom zaufania jest odblokowany, system może awansować lub degradować tego użytkownika" lock_trust_level: "Zablokuj poziom zaufania" unlock_trust_level: "Odblokuj poziom zaufania" silenced_count: "Wyciszony" @@ -5515,23 +5575,23 @@ pl_PL: delete_confirm: "Czy na pewno chcesz usunąć to pole użytkownika?" options: "Opcje" required: - title: "Wymagane przy rejestracji?" + title: "Wymagane przy rejestracji" enabled: "wymagane" disabled: "niewymagane" editable: - title: "Edytowalne po rejestracji?" + title: "Edytowalne po rejestracji" enabled: "edytowalne" disabled: "nieedytowalne" show_on_profile: - title: "Widoczne w publicznym profilu?" + title: "Widoczne w publicznym profilu" enabled: "widoczne w profilu" disabled: "niewidoczne w profilu" show_on_user_card: - title: "Pokaż na karcie użytkownika?" + title: "Pokaż na karcie użytkownika" enabled: "wyświetlane na karcie użytkownika" disabled: "nie pokazany na karcie użytkownika" searchable: - title: "Możliwe do wyszukania?" + title: "Możliwe do wyszukania" enabled: "możliwe do wyszukania" disabled: "nie można wyszukiwać" field_types: diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index 6374339d30..e00f20cf89 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -1049,7 +1049,6 @@ pt: experimental_sidebar: options: "Opções" navigation_section: "Navegação" - list_destination_default: "Predefinição" change: "alterar" featured_topic: "Tópico em Destaque" moderator: "%{user} é um moderador" @@ -4393,7 +4392,6 @@ pt: trust_level_2_users: "Utilizadores no Nível de Confiança 2" trust_level_3_requirements: "Requisitos do Nível de Confiança 3" trust_level_locked_tip: "o Nível de Confiança está bloqueado, o sistema não irá promover ou despromover o utilizador" - trust_level_unlocked_tip: "o Nível de Confiança está desbloqueado, o sistema poderá promover ou despromover o utilizador" lock_trust_level: "Bloquear Nível de Confiança" unlock_trust_level: "Desbloquear Nível de Confiança" silenced_count: "Silenciado" @@ -4446,19 +4444,19 @@ pt: delete_confirm: "Tem a certeza que quer eliminar esse campo de utilizador?" options: "Opções" required: - title: "Obrigatório na inscrição?" + title: "Obrigatório na inscrição" enabled: "obrigatório" disabled: "não obrigatório" editable: - title: "Editável depois da inscrição?" + title: "Editável depois da inscrição" enabled: "editável" disabled: "não editável" show_on_profile: - title: "Exibir no perfil público?" + title: "Exibir no perfil público" enabled: "exibido no perfil" disabled: "não exibido no perfil" show_on_user_card: - title: "Mostrar no cartão de utilizador?" + title: "Mostrar no cartão de utilizador" enabled: "mostrar no cartão de utilizador" disabled: "não apresentado no cartão de utilizador" field_types: diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index 1351fb79b4..8f5c044d84 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -326,8 +326,8 @@ pt_BR: list_permission_denied: "Você não tem permissão para visualizar os favoritos deste usuário(a)." no_user_bookmarks: "Você não tem postagens favoritas. Os favoritos permitem que você consulte rapidamente postagens específicas." auto_delete_preference: - label: "Após ser notificado" - never: "Manter Marcador" + label: "Após receber notificação" + never: "Manter marcador" when_reminder_sent: "Excluir favorito" on_owner_reply: "Excluir marcador, após responder" clear_reminder: "Manter marcador e limpar lembretes" @@ -974,7 +974,7 @@ pt_BR: user_fields: none: "(selecione uma opção)" required: 'Digite um valor para "%{name}"' - same_as_password: 'Sua senha não deve ser repetida em outros campos.' + same_as_password: "Sua senha não deve ser repetida em outros campos." user: said: "%{username}:" profile: "Perfil" @@ -1113,9 +1113,6 @@ pt_BR: tags_section: "Seção de Etiquetas" tags_section_instruction: "As etiquetas selecionadas serão exibidas na seção de etiquetas da barra lateral." navigation_section: "Navegação" - list_destination_instruction: "Quando eu clicar em um link de lista de tópicos na barra lateral com tópicos novos ou não lidos, leve-me para" - list_destination_default: "Padrão" - list_destination_unread_new: "Novo/Não lido" change: "alterar" featured_topic: "Tópico em destaque" moderator: "%{user} é moderador(a)" @@ -1336,7 +1333,7 @@ pt_BR: upload_title: "Enviar sua imagem" image_is_not_a_square: "Aviso: cortamos a sua imagem. A largura e a altura não eram iguais." logo_small: "Logotipo pequeno do site. Usado por padrão." - use_custom: "Ou envie um avatar customizado:" + use_custom: "Ou envie um avatar personalizado:" change_profile_background: title: "Cabeçalho do perfil" instructions: "Os cabeçalhos do perfil serão centralizados e terão largura padrão de 1110px." @@ -1671,7 +1668,7 @@ pt_BR: the_topic: "o tópico" user_status: save: "Salvar" - set_custom_status: "Definir status customizados" + set_custom_status: "Definir status personalizados" what_are_you_doing: "O que você está fazendo?" remove_status: "Remover status" popup: @@ -2111,7 +2108,7 @@ pt_BR: slow_mode: error: "Este tópico está no modo lento. Você já postou recentemente. É possível postar outra vez em %{timeLeft}." user_not_seen_in_a_while: - single: "A pessoa para quem você está enviando mensagens, %{usernames}, não é vista aqui há muito tempo – %{time_ago}. Eles podem não receber sua mensagem. Você pode procurar métodos alternativos de contato %{usernames}." + single: "A pessoa para quem você está enviando mensagens, %{usernames}, não é vista aqui há muito tempo – %{time_ago}. Elas podem não receber sua mensagem. Você pode procurar métodos alternativos de contato %{usernames}." multiple: "As seguintes pessoas para quem você está enviando mensagens: %{usernames}, não são vistas aqui há muito tempo – %{time_ago}. Elas podem não receber sua mensagem. Você pode procurar métodos alternativos de contatá-las." admin_options_title: "Configurações opcionais da equipe para este tópico" composer_actions: @@ -2411,10 +2408,10 @@ pt_BR: likes_with_unread: one: "Curtidas - %{count} curtida não lida" other: "Curtidas - %{count} curtidas não lidas" - watching: "Tópicos assistidos" + watching: "Tópicos acompanhados" watching_with_unread: - one: "Tópicos assistidos - %{count} tópico assistido não lido" - other: "Tópicos assistidos - %{count} tópicos assistidos não lidos" + one: "Tópicos acompanhados - %{count} tópico acompanhado não lido" + other: "Tópicos acompanhados - %{count} tópicos assistidos não lidos" messages: "Mensagens pessoais" messages_with_unread: one: "Mensagens pessoais - %{count} mensagem não lida" @@ -3183,7 +3180,7 @@ pt_BR: description: "Exigir que novos tópicos tenham etiquetas de grupos de etiquetas:" delete: "Excluir" add: "Adicionar grupo de tags necessário" - placeholder: "selecionar grupo de tags..." + placeholder: "selecionar grupo de etiquetas..." topic_featured_link_allowed: "Permitir links em destaque nesta categoria" delete: "Excluir categoria" create: "Nova categoria" @@ -3862,9 +3859,9 @@ pt_BR: inbox: "Caixa de entrada" sent: "Enviadas" new: "Novo" - new_with_count: "Novo (%{count})" + new_with_count: "Novos(s) (%{count})" unread: "Não lidos(as)" - unread_with_count: "Não lido (%{count})" + unread_with_count: "Não lido(s) (%{count})" archive: "Arquivo" tags: none: "Você não adicionou nenhuma etiqueta." @@ -3895,7 +3892,7 @@ pt_BR: content: "FAQ" tracked: content: "Monitorados(as)" - title: "Todos os Tópicos Rastreados" + title: "Todos os tópicos monitorados" groups: content: "Grupos" title: "Todos os grupos" @@ -3904,7 +3901,7 @@ pt_BR: title: "Todos(as) os(as) usuários(as)" my_posts: content: "Minhas postagens" - title: "Minhas publicações" + title: "Minhas postagens" draft_count: one: "%{count} rascunho" other: "%{count} rascunhos" @@ -3913,8 +3910,8 @@ pt_BR: title: "revisar" pending_count: "%{count} pendente" welcome_topic_banner: - title: "Crie seu tópico de Boas-Vindas" - description: 'Seu tópico de boas-vindas é a primeira coisa que os novos membros vão ler. Pense nisso como seu “argumento de elevador” ou “declaração de missão”. Informe a todos sobre quem é o público desta comunidade, o que eles podem esperar encontrar aqui e o que você gostaria que eles fizessem primeiro.' + title: "Crie seu Tópico de Boas-Vindas" + description: "Seu tópico de boas-vindas é a primeira coisa que os novos membros vão ler. Pense nisso como seu “argumento de elevador” ou “declaração de missão”. Informe a todos sobre quem é o público desta comunidade, o que eles podem esperar encontrar aqui e o que você gostaria que eles fizessem primeiro." button_title: "Começar a Editar" until: "Até:" admin_js: @@ -4189,7 +4186,6 @@ pt_BR: go_back: "Voltar para a lista" payload_url: "URL do conteúdo" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Parece que você está tentando configurar um webhook para uma URL local. Eventos entregues a endereços locais podem causar efeitos colaterais ou comportamentos inesperados. Continuar?" secret_invalid: "O segredo não deve conter caracteres em branco." secret_too_short: "O segredo deve ter pelo menos 12 caracteres." secret_placeholder: "Uma linha opcional, usado para gerar a assinatura" @@ -4478,7 +4474,6 @@ pt_BR: last_attempt: "O processo de instalação não foi concluído, última tentativa:" remote_branch: "Nome da unidade (opcional)" public_key: "Conceder o seguinte acesso da chave pública ao repo:" - public_key_note: "Após digitar uma URL do repositório privada válida, uma chave SSH será gerada e exibida aqui." install: "Instalar" installed: "Instalado(a)" install_popular: "Mais acessados(as)" @@ -5157,7 +5152,6 @@ pt_BR: trust_level_2_users: "Usuários(as) de nível de confiança 2" trust_level_3_requirements: "Requisitos de nível de confiança 3" trust_level_locked_tip: "o nível de confiança está bloqueado, o sistema não irá promover ou rebaixará o(a) usuário(a)" - trust_level_unlocked_tip: "o nível de confiança está desbloqueado, o sistema poderá promover ou rebaixar o(a) usuário(a)" lock_trust_level: "Bloquear nível de confiança" unlock_trust_level: "Desbloquear nível de confiança" silenced_count: "Silenciado(a)" @@ -5217,23 +5211,18 @@ pt_BR: delete_confirm: "Tem certeza que quer excluir este campo de usuário(a)?" options: "Opções" required: - title: "Necessário para cadastro?" enabled: "necessário(a)" disabled: "não necessário(a)" editable: - title: "Editável após criar conta?" enabled: "editável" disabled: "não editável" show_on_profile: - title: "Exibir no perfil público?" enabled: "exibido(a) no perfil" disabled: "não exibido(a) no perfil" show_on_user_card: - title: "Exibir no cartão de usuário(a)?" enabled: "exibir no cartão de usuário(a)" disabled: "não exibido no cartão de usuário(a)" searchable: - title: "Pesquisável?" enabled: "pesquisável" disabled: "não pesquisável" field_types: diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index 9b79d81278..2434fb07ef 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -1017,7 +1017,6 @@ ro: experimental_sidebar: options: "Opțiuni" navigation_section: "Navigare" - list_destination_default: "Implicit" change: "Schimbă" featured_topic: "Subiect recomandat" moderator: "%{user} este moderator" @@ -3721,7 +3720,6 @@ ro: trust_level_2_users: "utilizatori de nivel de încredere 2 " trust_level_3_requirements: "Cerințe pentru nivelul 3 de încredere" trust_level_locked_tip: "Nivelul de încredere este blocat, sistemul nu va promova sau retrograda utilizatorii" - trust_level_unlocked_tip: "Nivelul de încredere este deblocat, sistemul poate promova sau retrograda utilizatorii" lock_trust_level: "Blochează nivelul de încredere" unlock_trust_level: "Deblochează nivelul de încredere" suspended_count: "Suspendați" @@ -3774,19 +3772,19 @@ ro: delete_confirm: "Ești sigur că vrei să ștergi acest câmp utilizator?" options: "Opțiuni" required: - title: "Necesar la înscriere?" + title: "Necesar la înscriere" enabled: "necesar" disabled: "opţional" editable: - title: "Editabil după înregistrare?" + title: "Editabil după înregistrare" enabled: "editabil" disabled: "nu este editabil" show_on_profile: - title: "Arată în profilul public?" + title: "Arată în profilul public" enabled: "se afișează în profil" disabled: "nu se afișează în profil" show_on_user_card: - title: "Afișează pe pagina cu date personale utilizatorului?" + title: "Afișează pe pagina cu date personale utilizatorului" enabled: "se afișează" disabled: "nu se afișează" field_types: diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index c17ec0b55a..67c9eb32b9 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -35,9 +35,9 @@ ru: long_no_year_no_time: "D MMM" full_no_year_no_time: "D MMM" long_with_year: "D MMM YYYY, HH:mm" - long_with_year_no_time: "D MMM, YYYY" - full_with_year_no_time: "LL" - long_date_with_year: "D MMM YY, LT" + long_with_year_no_time: "D MMM YYYY" + full_with_year_no_time: "D MMMM YYYY" + long_date_with_year: "D MMM YYYY, LT" long_date_without_year: "D MMM, LT" long_date_with_year_without_time: "D MMM YYYY" long_date_without_year_with_linebreak: "D MMM
    LT" @@ -45,57 +45,57 @@ ru: wrap_ago: "%{date} назад" wrap_on: "%{date}" tiny: - half_a_minute: "< 1мин" + half_a_minute: "< 1 мин" less_than_x_seconds: - one: "< %{count}сек" - few: "< %{count}сек" - many: "< %{count}сек" - other: "< %{count}с" + one: "< %{count} с" + few: "< %{count} с" + many: "< %{count} с" + other: "< %{count} с" x_seconds: - one: "%{count}с" - few: "%{count}с" - many: "%{count}с" - other: "%{count}с" + one: "%{count} с" + few: "%{count} с" + many: "< %{count} с" + other: "< %{count} с" less_than_x_minutes: - one: "< %{count}мин" - few: "< %{count}м" - many: "< %{count}м" - other: "< %{count}мин" + one: "< %{count} мин" + few: "< %{count} мин" + many: "< %{count} мин" + other: "< %{count} мин" x_minutes: - one: "%{count}м" - few: "%{count}мин" - many: "%{count}мин" - other: "%{count}мин" + one: "%{count} мин" + few: "%{count} мин" + many: "%{count} мин" + other: "%{count} мин" about_x_hours: - one: "%{count}ч" - few: "%{count}ч" - many: "%{count}ч" - other: "%{count}ч" + one: "%{count} ч" + few: "%{count} ч" + many: "%{count} ч" + other: "%{count} ч" x_days: - one: "%{count}д" - few: "%{count}д" - many: "%{count}д" - other: "%{count}д" + one: "%{count} д" + few: "%{count} дн" + many: "%{count} дн" + other: "%{count} дн" x_months: - one: "%{count}мес" - few: "%{count}мес" - many: "%{count}мес" - other: "%{count}мес" + one: "%{count} мес" + few: "%{count} мес" + many: "%{count} мес" + other: "%{count} мес" about_x_years: - one: "%{count}год" - few: "%{count}года" - many: "%{count}лет" - other: "%{count}лет" + one: "%{count} год" + few: "%{count} года" + many: "%{count} лет" + other: "%{count} года" over_x_years: - one: "> %{count}года" - few: "> %{count}лет" - many: "> %{count}лет" - other: "> %{count}лет" + one: "> %{count} года" + few: "> %{count} лет" + many: "> %{count} лет" + other: "> %{count} года" almost_x_years: - one: "%{count}год" - few: "%{count}года" - many: "%{count}лет" - other: "%{count}лет" + one: "%{count} год" + few: "%{count} года" + many: "%{count} лет" + other: "%{count} года" date_month: "D MMM" date_year: "MMM YYYY" medium: @@ -103,7 +103,7 @@ ru: one: "менее %{count} минуты назад" few: "менее %{count} минут назад" many: "менее %{count} минут назад" - other: "менее %{count} минут назад" + other: "менее %{count} минуты назад" x_minutes: one: "%{count} мин" few: "%{count} мин" @@ -113,37 +113,37 @@ ru: one: "%{count} час" few: "%{count} часа" many: "%{count} часов" - other: "%{count} часов" + other: "%{count} часа" about_x_hours: one: "около %{count} часа" few: "около %{count} часов" many: "около %{count} часов" - other: "около %{count} часов" + other: "около %{count} часа" x_days: one: "%{count} день" few: "%{count} дня" many: "%{count} дней" - other: "%{count} дней" + other: "%{count} дня" x_months: one: "%{count} месяц" few: "%{count} месяца" many: "%{count} месяцев" - other: "%{count} месяцев" + other: "%{count} месяца" about_x_years: one: "около %{count} года" few: "около %{count} лет" many: "около %{count} лет" - other: "около %{count} лет" + other: "около %{count} года" over_x_years: one: "более %{count} года" few: "более %{count} лет" many: "более %{count} лет" - other: "более %{count} лет" + other: "более %{count} года" almost_x_years: one: "почти %{count} год" few: "почти %{count} года" many: "почти %{count} лет" - other: "почти %{count} лет" + other: "почти %{count} года" date_year: "D MMM, YYYY" medium_with_ago: x_minutes: @@ -202,7 +202,7 @@ ru: url: "Копировать и поделиться ссылкой" action_codes: public_topic: "Сделал тему публичной %{when}" - open_topic: "converted this to a topic %{when}" + open_topic: "преобразовал это в тему %{when}" private_topic: "Сделал тему личным сообщением %{when}" split_topic: "Разделил эту тему %{when}" invited_user: "Пригласил %{who} %{when}" @@ -1088,7 +1088,7 @@ ru: user_fields: none: "(выберите)" required: 'Пожалуйста, введите значение для "%{name}"' - same_as_password: 'Указанный пароль не должен фигурировать в других полях.' + same_as_password: "Указанный пароль не должен фигурировать в других полях." user: said: "%{username}:" profile: "Профиль" @@ -1227,9 +1227,6 @@ ru: tags_section: "Теги" tags_section_instruction: "Выбранные теги будут отображаться в соответствующей секции боковой панели." navigation_section: "Навигация" - list_destination_instruction: "При нажатии на ссылку списка тем на боковой панели с новыми или непрочитанными темами открывать" - list_destination_default: "Домашнюю страницу" - list_destination_unread_new: "Новые/Непрочитанные темы" change: "изменить" featured_topic: "Избранная тема" moderator: "%{user} — модератор" @@ -4278,7 +4275,7 @@ ru: pending_count: "В ожидании: %{count}" welcome_topic_banner: title: "Создать приветственную тему" - description: 'Ваша приветственная тема — это первое, что прочитают новички. Постарайтесь максимально коротко и ярко выразить в ней наиболее важную информацию, которую вы хотите донести до новых пользователей форума.' + description: "Ваша приветственная тема — это первое, что прочитают новички. Постарайтесь максимально коротко и ярко выразить в ней наиболее важную информацию, которую вы хотите донести до новых пользователей форума." button_title: "Начать редактирование" until: "До:" admin_js: @@ -4559,7 +4556,6 @@ ru: go_back: "Вернуться к списку" payload_url: "Ссылка для отправки" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "По-видимому вы пытаетесь настроить вебхук на локальный URL. Событие, отправляемое на локальный адрес, может иметь побочное действие или неожиданное поведение. Продолжить?" secret_invalid: "Ключ не должен содержать пробелов." secret_too_short: "Ключ должен быть не менее 12 символов." secret_placeholder: "Создание подписи (необязательно)" @@ -4852,7 +4848,6 @@ ru: last_attempt: "Процесс установки не завершен, последняя попытка:" remote_branch: "Имя ветки (необязательно)" public_key: "Предоставьте доступ к репозиторию со следующим открытым ключом:" - public_key_note: "После ввода корректного URL-адреса приватного репозитория, будет сгенерирован ключ SSH, который будет отображаться здесь." install: "Установить" installed: "Установленные" install_popular: "Популярные" @@ -5533,7 +5528,6 @@ ru: trust_level_2_users: "Пользователи с 2 уровнем доверия" trust_level_3_requirements: "Требования для 3 уровня доверия" trust_level_locked_tip: "Уровень доверия заблокирован, система не сможет изменять уровень доверия пользователя" - trust_level_unlocked_tip: "Уровень доверия разблокирован, система сможет изменять уровень доверия пользователя" lock_trust_level: "Заблокировать изменение уровня доверия" unlock_trust_level: "Разблокировать изменение уровня доверия" silenced_count: "Заблокированные" @@ -5595,23 +5589,18 @@ ru: delete_confirm: "Вы действительно хотите удалить это поле?" options: "Парамерты" required: - title: "Обязательное при регистрации?" enabled: "Обязательное" disabled: "Необязательное" editable: - title: "Редактируемое после регистрации?" enabled: "Редактируемое" disabled: "Нередактируемое" show_on_profile: - title: "Показывать в публичном профиле?" enabled: "Показывать в профиле" disabled: "Не показывать в профиле" show_on_user_card: - title: "Показывать в карточке пользователя?" enabled: "Показывать в карточке пользователя" disabled: "Не показывать в карточке пользователя" searchable: - title: "Доступно для поиска?" enabled: "Доступно для поиска" disabled: "Не доступно для поиска" field_types: diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 3c44dae813..19027ef8ae 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -732,7 +732,6 @@ sk: experimental_sidebar: options: "Možnosti" navigation_section: "Navigácia" - list_destination_default: "Predvolené" change: "zmeniť" moderator: "%{user} je moderátor" admin: "%{user} je administrátor" @@ -2942,7 +2941,6 @@ sk: trust_level_2_users: "Používatelia na stupni dôvery 2" trust_level_3_requirements: "Požiadavky pre 3 stupeň dôvery" trust_level_locked_tip: "stupeň dôvery je zamknutý, systém používateľovi stupeň nezvýši ani nezníži " - trust_level_unlocked_tip: "stupeň dôvery je odomknutý, systém môže používateľovi stupeň zvýšiť alebo znížiť" lock_trust_level: "Zamknúť stupeň dôvery" unlock_trust_level: "Odomknúť stupeň dôvery" suspended_count: "Odobrate práva" @@ -2991,15 +2989,15 @@ sk: delete_confirm: "Ste si istý, že chcete zmazať toto používateľské pole?" options: "Možnosti" required: - title: "Požadované pri registrácii?" + title: "Požadované pri registrácii" enabled: "povinné" disabled: "nepovinné" editable: - title: "Upravovateľné po registrácii?" + title: "Upravovateľné po registrácii" enabled: "upravovateľné " disabled: "neupravovateľné " show_on_profile: - title: "Ukázať na verejnom profile?" + title: "Ukázať na verejnom profile" enabled: "zobrazené na profile" disabled: "nezobrazené na profile" field_types: diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index d619528a9b..03f1b3697b 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -1022,7 +1022,6 @@ sl: experimental_sidebar: options: "Možnosti" navigation_section: "Navigacija" - list_destination_default: "Privzeto" change: "spremeni" featured_topic: "Izpostavljena tema" moderator: "%{user} je moderator" @@ -4099,7 +4098,6 @@ sl: trust_level_2_users: "Uporabniki na nivoju zaupanja 2" trust_level_3_requirements: "Zahteve za nivo zaupanja 3" trust_level_locked_tip: "nivo zaupanja je zaklenjen, sistem ne bo spreminjal nivo zaupanja uporabnika" - trust_level_unlocked_tip: "nivo zaupanja je odklenjen, sistem bo lahko spreminjal nivo zaupanja uporabnika" lock_trust_level: "Zakleni nivo zaupanja" unlock_trust_level: "Odkleni nivo zaupanja" silenced_count: "Utišani" @@ -4155,19 +4153,19 @@ sl: delete_confirm: "Ali ste prepričani, da želite izbrisati to polje?" options: "Možnosti" required: - title: "Zahtevano ob prijavi?" + title: "Zahtevano ob prijavi" enabled: "zahtevano" disabled: "ni zahtevano" editable: - title: "Uredljivo po prijavi?" + title: "Uredljivo po prijavi" enabled: "uredljivo" disabled: "ni uredljivo" show_on_profile: - title: "Prikaži na javnem profilu?" + title: "Prikaži na javnem profilu" enabled: "prikazano na profilu" disabled: "ni prikazano na profilu" show_on_user_card: - title: "Prikaži na uporabnikovi izkaznici?" + title: "Prikaži na uporabnikovi izkaznici" enabled: "prikazano na uporabnikovi izkaznici" disabled: "ni prikazano na uporabnikovi izkaznici" field_types: diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index d091a7f54c..0881f07f5d 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -554,7 +554,6 @@ sq: experimental_sidebar: options: "Opsione" navigation_section: "Shfletimi" - list_destination_default: "Paracaktuar" change: "ndrysho" moderator: "%{user} është moderator" admin: "%{user} është admin" @@ -2549,7 +2548,6 @@ sq: trust_level_2_users: "Përdorues me Nivel Besimi 2" trust_level_3_requirements: "Kërkesat për përdorues me Nivel Besimi 3" trust_level_locked_tip: "trust level is locked, system will not promote or demote user" - trust_level_unlocked_tip: "trust level is unlocked, system will may promote or demote user" lock_trust_level: "Kyç nivelin e besimit" unlock_trust_level: "Çkyç nivelin e besimit" suspended_count: "Të pezulluar" @@ -2601,19 +2599,18 @@ sq: delete_confirm: "Are you sure you want to delete that user field?" options: "Opsione" required: - title: "Required at signup?" + title: "Required at signup" enabled: "i nevojshëm" disabled: "fakultativ" editable: - title: "Editable after signup?" + title: "Editable after signup" enabled: "e modifikueshme" disabled: "jo e modifikueshme" show_on_profile: - title: "Trego në profilin publik" enabled: "e treguar në profilin publik" disabled: "nuk tregohet në profilin publik" show_on_user_card: - title: "Trego në kartën e anëtarit?" + title: "Trego në kartën e anëtarit" enabled: "e treguar në kartën e anëtarit" disabled: "nuk tregohet në kartën e anëtarit" field_types: diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 9eecb8f87c..098826baf1 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -697,7 +697,6 @@ sr: experimental_sidebar: options: "Opcije" navigation_section: "Navigacija" - list_destination_default: "zadato" change: "promeni" moderator: "%{user} je moderator" admin: "%{user} je administrator" @@ -2329,7 +2328,6 @@ sr: trust_level_2_users: "Korisnici Na Nivou Poverenja 2" trust_level_3_requirements: "Predispozicije Za Nivo Poverenja 3" trust_level_locked_tip: "Nivo poverenja zaključan, sistem neće unapređivati ili skidati korisnika" - trust_level_unlocked_tip: "Nivo poverenja zaključan, sistem neće unapređivati ili skidati korisnika" lock_trust_level: "Zaključaj Nivo Poverenja" unlock_trust_level: "Odključaj Nivo Poverenja" suspended_count: "Suspendovani" @@ -2378,15 +2376,15 @@ sr: delete_confirm: "Jeste li sigurni da želite obrisati to korisničko polje?" options: "Opcije" required: - title: "Potrebno pri registraciji?" + title: "Potrebno pri registraciji" enabled: "potrebno" disabled: "nepotrebno" editable: - title: "Izmenljivo nakon registracije?" + title: "Izmenljivo nakon registracije" enabled: "izmenljivo" disabled: "nije izmenljivo" show_on_profile: - title: "Pokaži na javnom profilu?" + title: "Pokaži na javnom profilu" enabled: "pokaži na profilu" disabled: "nije prikazano na profilu" field_types: diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index 4d8be59a13..5eae85842a 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -232,6 +232,7 @@ sv: delete: "Radera" generic_error: "Tyvärr, ett fel har inträffat." generic_error_with_reason: "Ett fel inträffade: %{error}" + multiple_errors: "Flera fel inträffade: %{errors}" sign_up: "Registrera" log_in: "Logga in" age: "Ålder" @@ -974,7 +975,7 @@ sv: user_fields: none: "(välj ett alternativ)" required: 'Ange ett värde för "%{name}"' - same_as_password: 'Upprepa inte ditt lösenord i andra fält.' + same_as_password: "Upprepa inte ditt lösenord i andra fält." user: said: "%{username}:" profile: "Profil" @@ -1113,9 +1114,9 @@ sv: tags_section: "Sektion Taggar" tags_section_instruction: "Valda taggar kommer att visas under sidofältets tagg-sektion." navigation_section: "Navigering" - list_destination_instruction: "När jag klickar på en länk till en ämneslista i sidofältet med nya eller olästa ämnen kommer jag till" - list_destination_default: "Standard" - list_destination_unread_new: "Nya/olästa" + list_destination_instruction: "När det finns nytt innehåll i sidofältet..." + list_destination_default: "använd standardlänken och visa en utmärkelse för nya objekt" + list_destination_unread_new: "länka till oläst/nytt och visa ett antal nya objekt" change: "ändra" featured_topic: "Utvalt ämne" moderator: "%{user} är en moderator" @@ -1904,6 +1905,8 @@ sv: success: "Ditt konto har skapats och du är nu inloggad." name_label: "Namn" password_label: "Lösenord" + existing_user_can_redeem: "Lös in din inbjudan till ett ämne eller en grupp." + existing_user_cannot_redeem: "Denna inbjudan kan inte lösas in. Be personen som bjöd in dig att skicka en ny inbjudan till dig." password_reset: continue: "Fortsätt till %{site_name}" emoji_set: @@ -3867,12 +3870,20 @@ sv: unread_with_count: "Oläst (%{count})" archive: "Arkiv" tags: + links: + add_tags: + content: "Lägg till taggar" + title: "Du har inte lagt till några taggar. Klicka för att komma igång." none: "Du har inte lagt till några taggar." click_to_get_started: "Klicka här för att komma igång." header_link_text: "Taggar" header_action_title: "redigera sidofältets taggar" configure_defaults: "Konfigurera standardvärden" categories: + links: + add_categories: + content: "Lägg till kategorier" + title: "Du har inte lagt till några kategorier. Klicka för att komma igång." none: "Du har inte lagt till några kategorier." click_to_get_started: "Klicka här för att komma igång." header_link_text: "Kategorier" @@ -3914,7 +3925,7 @@ sv: pending_count: "%{count} väntande" welcome_topic_banner: title: "Skapa ditt välkomstämne" - description: 'Ditt välkomstämne är det första som nya medlemmar kommer att läsa. Tänk på det som din "snabbpresentation" eller "målbeskrivning". Låt alla veta vem den här gruppen är till för, vad de kan förvänta sig att hitta här och vad du vill att de ska göra först.' + description: "Ditt välkomstämne är det första som nya medlemmar kommer att läsa. Tänk på det som din \"snabbpresentation\" eller \"målbeskrivning\". Låt alla veta vem den här gruppen är till för, vad de kan förvänta sig att hitta här och vad du vill att de ska göra först." button_title: "Börja redigera" until: "T.o.m.:" admin_js: @@ -4189,7 +4200,6 @@ sv: go_back: "Tillbaka till listan" payload_url: "Försändelse-URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Det verkar som du försöker upprätta en webhook till en lokal URL. Händelser levererade till en lokal adress kan orsaka bieffekter eller oväntad funktion. Vill du fortsätta?" secret_invalid: "Hemligheten får inte ha några blanka tecken." secret_too_short: "Hemligheten bör vara minst 12 tecken." secret_placeholder: "En alternativ sträng som används för att generera signatur" @@ -4288,6 +4298,7 @@ sv: change_settings_short: "Inställningar" howto: "Hur installerar jag tillägg?" official: "Officiellt tillägg" + broken_route: "Det går inte att konfigurera länken till '%{name}'. Se till att annonsblockerare är inaktiverade och försök ladda om sidan." backups: title: "Säkerhetskopior" menu: @@ -4478,7 +4489,6 @@ sv: last_attempt: "Installationsprocessen slutfördes inte, det senaste försöket:" remote_branch: "Filialnamn (valfritt)" public_key: "Ge följande offentliga nyckel tillträde till lagringsplatsen:" - public_key_note: "När du har angett en giltig privat lagringsplats URL ovan genereras det en SSH-nyckel som visas här." install: "Installera" installed: "Installerad" install_popular: "Populära" @@ -5157,7 +5167,7 @@ sv: trust_level_2_users: "Användare med förtroendenivå 2" trust_level_3_requirements: "Krav för förtroendenivå 3" trust_level_locked_tip: "förtroendenivå är låst, systemet kommer ej att befordra eller degradera användare" - trust_level_unlocked_tip: "förtroendenivå är olåst, systemet kan komma att befordra eller degradera användare" + trust_level_unlocked_tip: "förtroendenivån är olåst, systemet kan befordra eller degradera användare" lock_trust_level: "Lås förtroendenivå" unlock_trust_level: "Lås upp förtroendenivå" silenced_count: "Tystad" @@ -5217,23 +5227,23 @@ sv: delete_confirm: "Är du säker på att du vill ta bort det här användarfältet?" options: "Alternativ" required: - title: "Krävs vid registrering?" + title: "Krävs vid registrering" enabled: "krävs" disabled: "krävs ej" editable: - title: "Redigerbar efter registrering?" + title: "Redigerbar efter registrering" enabled: "redigerbar" disabled: "ej redigerbar" show_on_profile: - title: "Visa på offentlig profil?" + title: "Visa på offentlig profil" enabled: "visas på profil" disabled: "visas ej på profil" show_on_user_card: - title: "Ska visas på användarkort?" + title: "Ska visas på användarkort" enabled: "visas på användarkort" disabled: "visas inte på användarkort" searchable: - title: "Sökbart?" + title: "Sökbart" enabled: "sökbart" disabled: "inte sökbart" field_types: diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index 05ad3bd505..9f748dc407 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -645,7 +645,6 @@ sw: experimental_sidebar: options: "Chaguo" navigation_section: "Abiri" - list_destination_default: "Halisi" change: "badilisha" moderator: "%{mtumiaji} ni msimamizi" admin: "%{mtumiaji} ni kiongozi" @@ -3079,7 +3078,6 @@ sw: trust_level_2_users: "Watumiaji wenye Kiwango cha 2 cha Uaminifu" trust_level_3_requirements: "Mahitaji ya Kiwango cha 3 cha uaminifu" trust_level_locked_tip: "kiwango cha uaminifu kimefungwa, mfumo hauta mvusha au kumshusha mtu daraja" - trust_level_unlocked_tip: "kiwango cha uaminifu kimefunguliwa, mfumo unaweza kumvusha au kumshusha mtu daraja" lock_trust_level: "Funga Kiwango cha Uaminifu" unlock_trust_level: "Fungua Kiwango cha Uaminifu" silenced_count: "Amenyamazishwa" @@ -3129,19 +3127,19 @@ sw: delete_confirm: "Una uhakika unataka kufuta sehemu ya taarifa ya mtumiaji?" options: "Machaguo" required: - title: "Inahitajika wakati wa kujiunga?" + title: "Inahitajika wakati wa kujiunga" enabled: "muhimu" disabled: "sio muhimu" editable: - title: "Inaweza kufanyiwa uhariri baada ya kujiunga?" + title: "Inaweza kufanyiwa uhariri baada ya kujiunga" enabled: "inaweza kufanyiwa uhariri" disabled: "haiwezi kufanyiwa uhariri" show_on_profile: - title: "Imeonyeshwa kwenye maelezo mafupi ya mtumiaji yanayo onwa na umma?" + title: "Imeonyeshwa kwenye maelezo mafupi ya mtumiaji yanayo onwa na umma" enabled: "imeonyeshwa kwenye maelezo mafupi ya mtumiaji" disabled: "haijaonyeshwa kwenye maelezo mafupi ya mtumiaji" show_on_user_card: - title: "Onyesha kwenye kadi ya mtumiaji?" + title: "Onyesha kwenye kadi ya mtumiaji" enabled: "onyesha kwenye kadi ya mtumiaji" disabled: "usioneshe kwenye kadi ya mtumiaji" field_types: diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index 2221a9dd15..e8b5ff8ed1 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -415,7 +415,6 @@ te: experimental_sidebar: options: "ఎంపికలు" navigation_section: "నావిగేషను" - list_destination_default: "అప్రమేయ" change: "మార్చు" moderator: "%{user} ఒక నిర్వాహకుడు" admin: "%{user} ఒక అధికారి" @@ -1872,7 +1871,6 @@ te: trust_level_2_users: "నమ్మకం స్థాయి 2 సభ్యులు" trust_level_3_requirements: "నమ్మకపు స్థాయి 3 అవసరాలు" trust_level_locked_tip: "నమ్మకపు స్థాయి బంధింపబడిఉంది, వ్యవస్థ వినియోగదారుని ప్రోత్సాహించలేదు లేదా స్థాయి తగ్గించలేదు" - trust_level_unlocked_tip: "నమ్మకపు స్థాయి బంధింపబడలేదు, వ్యవస్థ వినియోగదారుని ప్రోత్సాహించవచ్చు లేదా స్థాయి తగ్గించవచ్చు" lock_trust_level: "నమ్మకపు స్థాయి ని బంధించు" unlock_trust_level: "నమ్మకపు స్థాయిని వదిలేయి" suspended_count: "సస్పెడయ్యాడు" @@ -1921,15 +1919,12 @@ te: delete_confirm: "మీరు నిజంగా ఈ సభ్య క్షేత్రం తొలగించాలనుకుంటున్నారా?" options: "ఎంపికలు" required: - title: "సైన్అప్ అవసరమా?" enabled: "కావాలి" disabled: "అవసరంలేదు" editable: - title: "సైన్అప్ తరువాత సవరించగలమా?" enabled: "సవరించదగిన" disabled: "సవరించలేని" show_on_profile: - title: "ప్రజా ప్రవరపై చూపు?" enabled: "ప్రవరపై చూపు" disabled: "ప్రవరపై చూపబడలేదు" field_types: diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index dfa311e31b..bff6036afc 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -782,7 +782,6 @@ th: experimental_sidebar: options: "ตัวเลือก" navigation_section: "การนำทาง" - list_destination_default: "ค่าเริ่มต้น" change: "เปลี่ยนแปลง" featured_topic: "กระทู้เด่น" moderator: "%{user} เป็นผู้ดูแลระบบ" @@ -2994,7 +2993,6 @@ th: trust_level_2_users: "ระดับความไว้ใจ 2 ผู้ใช้" trust_level_3_requirements: "ระดับความไว้ใจ 3 ความต้องการ" trust_level_locked_tip: "ระดับความไว้ใจถูกล็อกไว้ระบบจะไม่ปรับระดับความไว้ใจของผู้ใช้ขึ้นหรือลง" - trust_level_unlocked_tip: "ระดับความไว้ใจถูกปลดล็อก ระบบจะปรับระดับความไว้ใจขึ้นหรือลงของผู้ใช้ตามปกติ" lock_trust_level: "ล็อกระดับความไว้ใจ" unlock_trust_level: "ปลดล็อกระดับความไว้ใจ" suspended_count: "ระงับการใช้งาน" @@ -3038,19 +3036,17 @@ th: delete_confirm: "คุณแน่ใจหรือว่าจะลบฟิวส์ของผู้ใช้นี้?" options: "ตัวเลือก" required: - title: "ต้องการเมื่อลงทะเบียน?" + title: "ต้องการเมื่อลงทะเบียน" enabled: "ต้องการ" disabled: "ไม่ต้องการ" editable: - title: "แก้ไขหลังลงทะเบียนได้ใช่ไหม" enabled: "แก้ไขได้" disabled: "แก้ไขไม่ได้" show_on_profile: - title: "แสดงในข้อมูลสาธารณะ?" + title: "แสดงในข้อมูลสาธารณะ" enabled: "แสดงแสดงในข้อมูลส่วนตัว" disabled: "ไม่แสดงในข้อมูลส่วนตัว" show_on_user_card: - title: "แสดงบนการ์ดผู้ใช้?" enabled: "แสดงบนการ์ดผู้ใช้" disabled: "ไม่แสดงบนการ์ดผู้ใช้" field_types: diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index b9e8fb78a4..8979ce87f2 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -1089,9 +1089,6 @@ tr_TR: enable: "Kenar çubuğunu etkinleştir" options: "Seçenekler" navigation_section: "Navigasyon" - list_destination_instruction: "Kenar çubuğunda yeni veya okunmamış konuların bulunduğu bir konu listesi bağlantısını tıkladığımda beni şuraya götür:" - list_destination_default: "Varsayılan" - list_destination_unread_new: "Yeni/Okunmamış" change: "değiştir" featured_topic: "Öne Çıkan Konu" moderator: "%{user} moderatördür" @@ -3796,7 +3793,7 @@ tr_TR: title: "gözden geçirmeler" welcome_topic_banner: title: "Hoş Geldiniz konulu gönderinizi oluşturun" - description: 'Hoş geldiniz başlığınız yeni üyelerin okuyacağı ilk şeydir. Bunu "asansör konuşmanız" veya "misyon beyanınız" olarak düşünün. Onlara bu topluluğun kimler için olduğunu, burada neler bulabileceklerini ve ilk olarak ne yapmalarını istediğinizi söyleyin.' + description: "Hoş geldiniz başlığınız yeni üyelerin okuyacağı ilk şeydir. Bunu \"asansör konuşmanız\" veya \"misyon beyanınız\" olarak düşünün. Onlara bu topluluğun kimler için olduğunu, burada neler bulabileceklerini ve ilk olarak ne yapmalarını istediğinizi söyleyin." button_title: "Düzenlemeye Başla" until: "Şuna kadar:" admin_js: @@ -4062,7 +4059,6 @@ tr_TR: go_back: "Listeye geri dön" payload_url: "URL Veri yükü" payload_url_placeholder: "https://ornek.com/gonderial" - warn_local_payload_url: "Öyle görünüyor ki, web kancasını yerel bir URL'ye ayarlamaya çalışıyorsunuz. Yerel bir adrese iletilen olay bilgileri beklenmedik durumlara neden olabilir. Sürdürmek istiyor musunuz?" secret_invalid: "Gizli alanda boş karakter olamaz." secret_too_short: "Gizli alan en az 12 karakter olmalı." secret_placeholder: "İmza oluşturmak için isteğe bağlı metin" @@ -4345,7 +4341,6 @@ tr_TR: is_private: "Tema özel bir git veri havuzunda" remote_branch: "Şube adı (isteğe bağlı)" public_key: "Repo'ya aşağıdaki genel anahtar erişimini ver:" - public_key_note: "Yukarıya geçerli bir özel depo URL'i girildiğinde, bir SSH anahtarı oluşturulacak ve burada görüntülenecektir." install: "Yükle" installed: "Yüklendi" install_popular: "Gözde" @@ -5020,7 +5015,6 @@ tr_TR: trust_level_2_users: "Güven Düzeyi 2 Olan Kullanıcılar" trust_level_3_requirements: "Güven Düzeyi 3 Gereksinimleri" trust_level_locked_tip: "güven düzeyi kilitlendi, sistem kullanıcının düzeyini yükseltmeyecek veya düşürmeyecek" - trust_level_unlocked_tip: "güven düzeyi kilitli değil, sistem kullanıcının düzeyini yükseltebilir veya düşürebilir" lock_trust_level: "Güven Düzeyini Kilitle" unlock_trust_level: "Güven Düzeyi Kilidini Aç" silenced_count: "Susturuldu" @@ -5080,23 +5074,18 @@ tr_TR: delete_confirm: "Bu kullanıcı alanını silmek istediğine emin misin?" options: "Seçenekler" required: - title: "Kayıt olurken zorunlu mu?" enabled: "zorunlu" disabled: "zorunlu değil" editable: - title: "Kayıt sonrası düzenlenebilir mi?" enabled: "düzenlenebilir" disabled: "düzenlenemez" show_on_profile: - title: "Herkese açık profilde gösterilsin mi?" enabled: "profilde gösteriliyor" disabled: "profilde gösterilmiyor" show_on_user_card: - title: "Kullanıcı profilinde gösterilsin mi?" enabled: "kullanıcı profilinde gösterildi" disabled: "kullanıcı profilinde gösterilmiyor" searchable: - title: "Aranabilir mi?" enabled: "aranabilir" disabled: "aranamaz" field_types: diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index 6a9e87ef01..05c5d2a601 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -1217,7 +1217,6 @@ uk: tags_section: "Розділ Теги" tags_section_instruction: "Вибрані теги відображатимуться в розділі тегів бічної панелі." navigation_section: "Навігація" - list_destination_default: "За замовчуванням" change: "змінити" featured_topic: "Закріплені теми" moderator: "%{user} є модератором" @@ -4512,7 +4511,6 @@ uk: go_back: "Повернутися до списку" payload_url: "Посилання для відправки" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Здається, ви намагаєтеся налаштувати web-хук для локального посилання. Подія, доставлена на локальну адресу, може призвести до побічних ефектів або непередбачуваних дій. Продовжити?" secret_invalid: "Ключ не повинен містити порожніх символів." secret_too_short: "Ключ повинен бути не менше 12 символів." secret_placeholder: "Додатковий рядок, використовується для створення підпису" @@ -4805,7 +4803,6 @@ uk: last_attempt: "Процес встановлення не завершено, остання спроба:" remote_branch: "Назва гілки (не обов’язково)" public_key: "Надайте доступ до сховища з наступним відкритим ключем:" - public_key_note: "Після введення коректної адреси приватного сховища вище, тут буде згенеровано і показано SSH ключ." install: "Install" installed: "Встановлена" install_popular: "Популярні" @@ -5493,7 +5490,6 @@ uk: trust_level_2_users: "Користувачі з Рівнем довіри 2" trust_level_3_requirements: "Вимоги для Рівня довіри 3" trust_level_locked_tip: "рівень довіри заморожений, система не зможе за потреби розжалувати або просунути користувача" - trust_level_unlocked_tip: "рівень довіри розморожений, система зможе за потреби розжалувати або просунути користувача" lock_trust_level: "Заморозити рівень довіри" unlock_trust_level: "Розморозити рівень довіри" silenced_count: "Відключений" @@ -5555,23 +5551,23 @@ uk: delete_confirm: "Ви впевнені, що хочете видалити це поле?" options: "Налаштування" required: - title: "Обов’язкове під час реєстрації?" + title: "Обов’язкове під час реєстрації" enabled: "Обов’язкове" disabled: "Необов’язкове" editable: - title: "Редаговане після реєстрації?" + title: "Редаговане після реєстрації" enabled: "Редаговане" disabled: "Нередаговане" show_on_profile: - title: "Показувати в публічному профілі?" + title: "Показувати в публічному профілі" enabled: "Показувати в профілі" disabled: "Не показувати в профілі" show_on_user_card: - title: "Показувати в картці користувача?" + title: "Показувати в картці користувача" enabled: "показується в картці користувача" disabled: "Не показувати в картці користувача" searchable: - title: "Включати при пошуку по сайту?" + title: "Включати при пошуку по сайту" enabled: "для пошуку" disabled: "не для пошуку" field_types: diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index bfe2ddb846..6cd2eec50a 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -1071,7 +1071,6 @@ ur: experimental_sidebar: options: "اختیارات" navigation_section: "نیویگیشن" - list_destination_default: "ڈِیفالٹ" change: "بدلیں" featured_topic: "نمایاں موضوع" moderator: "%{user} ایک ماڈریٹر ہے" @@ -3951,7 +3950,6 @@ ur: go_back: "واپس فہرست پر" payload_url: "پَیلوڈ یو.آر.ایل." payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "ایسا لگتا ہے کہ آپ ویب ہک کو مقامی یو آر ایل پر سیٹ کرنے کی کوشش کر رہے ہیں۔ مقامی پتے پر پہنچایا گیا واقعہ ضمنی اثرات یا غیر متوقع طرز عمل کا سبب بن سکتا ہے۔ جاری رہے؟" secret_invalid: "سیکرٹ میں کوئی بھی خالی حروف نہ ہونا ضروری ہے۔" secret_too_short: "سیکرٹس کا کم از کم 12 حروف پر مشتمل ہونا ضروری ہے۔" secret_placeholder: "ایک اختیاری سٹرنگ، سِگنَیچر تخلیق کرنے کیلئے استعمال کیا جاتا ہے" @@ -4238,7 +4236,6 @@ ur: is_private: "تھِیم ایک ذاتی گِٹ رِیپَوزِٹَری میں ہے" remote_branch: "برانچ کا نام (اختیاری)" public_key: "رِیپَوزِٹَری کو درج ذیل پبلک کلید ایکسَیس فراہم کریں:" - public_key_note: "اوپر ایک درست پرائیویٹ ریپوزٹری یو آر ایل داخل کرنے کے بعد، ایک SSH کی یہاں تیار اور ڈسپلے کی جائے گی۔" install: "انسٹال" installed: "انسٹال کیا ہوا" install_popular: "مقبول" @@ -4905,7 +4902,6 @@ ur: trust_level_2_users: "ٹرسٹ لَیول 2 کے صارفین" trust_level_3_requirements: "ٹرسٹ لَیول 3 کے تقاضے" trust_level_locked_tip: "ٹرسٹ لَیول لاک ہے، سِسٹم صارف کا لَیول زیادہ یا کم نہیں کرے گا" - trust_level_unlocked_tip: "ٹرسٹ لَیول لاک نہیں ہے، سِسٹم صارف کا لَیول زیادہ یا کم کرسکتا ہے" lock_trust_level: "ٹرسٹ لَیول لاک کریں" unlock_trust_level: "ٹرسٹ لَیول کا لاک ختم کریں" silenced_count: "خاموش کیو ہوئے" @@ -4965,23 +4961,18 @@ ur: delete_confirm: "کیا آپ واقعی یہ صارف سے بھرے جانے والا خانا حذف کرنا چاہتے ہیں؟" options: "اختیارات" required: - title: "سائن اَپ کے وقت درکار ہے؟" enabled: "درکار ہے" disabled: "درکار نہیں ہے" editable: - title: "سائن اَپ کے بعد قابل ترمیم؟" enabled: "قابلِ ترمیم" disabled: "ناقابلِ ترمیم" show_on_profile: - title: "پبلک پروفائل پر دکھائیں؟" enabled: "پروفائل پر دکھائیں؟" disabled: "پروفائل پر نہیں دکھایا؟" show_on_user_card: - title: "صارف کارڈ پر دکھائیں؟" enabled: "صارف کارڈ پر دکھایا گیا" disabled: "صارف کارڈ پر نہیں دکھایا گیا" searchable: - title: "قابل تلاش؟" enabled: "قابل تلاش؟" disabled: "قابل تلاش نہیں" field_types: diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index 8ace446bba..cfb1824c8d 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -1041,7 +1041,6 @@ vi: tags_section: "Phần thẻ" tags_section_instruction: "Các thẻ đã chọn sẽ được hiển thị trong phần thẻ của Thanh bên." navigation_section: "Điều hướng" - list_destination_default: "Mặc định" change: "thay đổi" featured_topic: "Chủ đề nổi bật" moderator: "%{user} trong ban quản trị" @@ -3922,7 +3921,6 @@ vi: go_back: "Quay lại danh sách" payload_url: "Payload URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Có vẻ như bạn đang cố gắng thiết lập webhook thành một url cục bộ. Sự kiện được gửi đến một địa chỉ cục bộ có thể gây ra tác dụng phụ hoặc các hành vi không mong muốn. Tiếp tục?" secret_invalid: "Bí mật không được có bất kỳ ký tự trống nào." secret_too_short: "Bí mật phải có ít nhất 12 ký tự." secret_placeholder: "Một chuỗi tùy chọn, được sử dụng để tạo chữ ký" @@ -4207,7 +4205,6 @@ vi: is_private: "Theme nằm trong kho git riêng" remote_branch: "Tên chi nhánh (không bắt buộc)" public_key: "Cấp quyền truy cập khóa công khai sau vào repo:" - public_key_note: "Sau khi nhập URL kho lưu trữ riêng hợp lệ ở trên, khóa SSH sẽ được tạo và hiển thị ở đây." install: "Cài đặt" installed: "Đã cài đặt" install_popular: "Phổ biến" @@ -4827,7 +4824,6 @@ vi: trust_level_2_users: "Độ tin cậy tài khoản mức 2" trust_level_3_requirements: "Độ tin cậy bắt buộc mức 3" trust_level_locked_tip: "mức độ tin cậy đang khóa, hệ thống sẽ không thể thăng hoặc giáng chức người dùng" - trust_level_unlocked_tip: "độ tin cậy đang được mở, hệ thống có thể thăng hoặc giáng chức người dùng" lock_trust_level: "Khóa Cấp độ Tin tưởng" unlock_trust_level: "Mở khóa độ tin cậy" silenced_count: "Im lặng" @@ -4883,19 +4879,18 @@ vi: delete_confirm: "Bạn muốn xóa trường thành viên?" options: "Lựa chọn" required: - title: "Bắt buộc lúc đăng ký?" + title: "Bắt buộc lúc đăng ký" enabled: "bắt buộc" disabled: "không bắt buộc" editable: - title: "Có thể chỉnh sửa sau khi đăng ký?" + title: "Có thể chỉnh sửa sau khi đăng ký" enabled: "có thể chỉnh sửa" disabled: "không thể chỉnh sửa" show_on_profile: - title: "Hiển thị trong hồ sơ công khai" enabled: "hiển thị trong hồ sơ" disabled: "không hiển thị trong hồ sơ" show_on_user_card: - title: "Hiện trên thẻ người dùng?" + title: "Hiện trên thẻ người dùng" enabled: "hiển trên thẻ người dùng" disabled: "không hiện trên thẻ người dùng" searchable: diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index 29699e65d8..eef7f7a310 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -915,7 +915,7 @@ zh_CN: user_fields: none: "(选择一个选项)" required: '请为“%{name}”输入一个值。' - same_as_password: '您的密码不应重复出现在其他字段中。' + same_as_password: "您的密码不应重复出现在其他字段中。" user: said: "%{username}:" profile: "个人资料" @@ -927,7 +927,7 @@ zh_CN: success: "下载已开始,完成后将通过消息通知您。" rate_limit_error: "帖子每天只能下载一次,请明天再试。" new_private_message: "新消息" - private_message: "消息" + private_message: "私信" private_messages: "消息" user_notifications: filters: @@ -3712,7 +3712,7 @@ zh_CN: pending_count: "%{count} 待处理" welcome_topic_banner: title: "创建您的欢迎话题" - description: '您的欢迎话题是新成员首先会阅读的内容。把它想象成您的“电梯推销”或“使命宣言”。让每个人都知道这个社区是为谁服务的,他们可以在这里找到什么,以及您希望他们首先做什么。' + description: "您的欢迎话题是新成员首先会阅读的内容。把它想象成您的“电梯推销”或“使命宣言”。让每个人都知道这个社区是为谁服务的,他们可以在这里找到什么,以及您希望他们首先做什么。" button_title: "开始编辑" until: "直到:" admin_js: @@ -3984,7 +3984,6 @@ zh_CN: go_back: "返回列表" payload_url: "有效负载 URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "您似乎正在尝试将网络钩子设置为本地 URL。传递到本地地址的事件可能会导致副作用或意外行为。继续吗?" secret_invalid: "密钥不得包含任何空白字符。" secret_too_short: "密钥应至少为 12 个字符。" secret_placeholder: "可选字符串,用于生成签名" @@ -4271,7 +4270,6 @@ zh_CN: last_attempt: "安装过程未完成,最后一次尝试:" remote_branch: "分支名称(可选)" public_key: "授予以下公钥访问仓库的权限:" - public_key_note: "在上面输入有效的私有仓库 URL 后,将生成 SSH 密钥并在此处显示。" install: "安装" installed: "已安装" install_popular: "热门" @@ -4945,7 +4943,6 @@ zh_CN: trust_level_2_users: "信任级别 2 用户" trust_level_3_requirements: "信任级别 3 要求" trust_level_locked_tip: "信任级别已被锁定,系统将不会升降用户的信任级别" - trust_level_unlocked_tip: "信任级别已被解锁,系统可能会升降用户的信任级别" lock_trust_level: "锁定信任级别" unlock_trust_level: "解锁信任级别" silenced_count: "被禁言" @@ -5004,23 +5001,18 @@ zh_CN: delete_confirm: "确定要删除该用户字段吗?" options: "选项" required: - title: "注册时需要?" enabled: "必选" disabled: "非必选" editable: - title: "注册后可以编辑?" enabled: "可编辑" disabled: "不可编辑" show_on_profile: - title: "在公开个人资料中显示?" enabled: "在个人资料中显示" disabled: "不在个人资料中显示" show_on_user_card: - title: "在用户卡片上显示?" enabled: "在用户卡片上显示" disabled: "不在用户卡片上显示" searchable: - title: "可搜索?" enabled: "可搜索" disabled: "不可搜索" field_types: diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index 28d94f690d..24fe06ac6c 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -202,6 +202,7 @@ zh_TW: delete: "刪除" generic_error: "抱歉,發生錯誤。" generic_error_with_reason: "發生錯誤: %{error}" + multiple_errors: "發生多個錯誤: %{errors}" sign_up: "註冊" log_in: "登入" age: "已建立" @@ -963,9 +964,9 @@ zh_TW: tags_section: "選擇標籤" tags_section_instruction: "選定的標籤將顯示在側選單的標籤段落中。" navigation_section: "導覽" - list_destination_instruction: "當我點擊側選單中包含新主題或未讀主題的列表連結時,帶我前往" - list_destination_default: "預設" - list_destination_unread_new: "新文章/未讀" + list_destination_instruction: "當側邊欄中有新內容時..." + list_destination_default: "使用預設連結並顯示新項目的徽章" + list_destination_unread_new: "連結至未讀/新的並顯示新項目的數量" change: "修改" featured_topic: "特色主題" moderator: "%{user} 是板主" @@ -1538,6 +1539,8 @@ zh_TW: success: "你的帳號已被建立,且您已經登入了。" name_label: "姓名" password_label: "密碼" + existing_user_can_redeem: "兌換您對某個主題或群組的邀請。" + existing_user_cannot_redeem: "此邀請無法兌換。請找當初邀請您的人來重發一個新的邀請。" password_reset: continue: "繼續連接至 %{site_name}" emoji_set: @@ -2575,6 +2578,7 @@ zh_TW: views_lowercase: other: "觀看" replies: "回覆" + sr_replies: "依回覆排序" views_long: other: "這個話題已經被檢視過 %{number} 次" activity: "活動" @@ -3869,7 +3873,6 @@ zh_TW: trust_level_2_users: "信任等級 2 使用者" trust_level_3_requirements: "信任等級 3 之條件" trust_level_locked_tip: "信任等級鎖定,系統將不會升級或降級使用者。" - trust_level_unlocked_tip: "信任等級解除鎖定,系統將會升級或降級使用者。" lock_trust_level: "鎖住信任等級" unlock_trust_level: "解鎖信任等級" silenced_count: "被靜音" @@ -3921,21 +3924,23 @@ zh_TW: delete_confirm: "你確定要刪除此使用者欄位 ?" options: "選項" required: - title: "在註冊時必填?" + title: "在註冊時必填" enabled: "必填" disabled: "非必填" editable: - title: "在註冊後可以修改?" + title: "註冊後可以修改" enabled: "可編輯" disabled: "不可編輯" show_on_profile: - title: "顯示在公開的基本資料裡?" + title: "顯示在公開的基本資料裡" enabled: "在基本資料裡顯示" disabled: "不在基本資料裡顯示" show_on_user_card: - title: "在使用者卡片上顯示?" + title: "在使用者卡片上顯示" enabled: "在使用者卡片上顯示" disabled: "在使用者卡片上隱藏" + searchable: + title: "可搜索" field_types: text: "文字區域" confirm: "確認" diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index 72605365aa..71521d6fe3 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -71,6 +71,7 @@ ar: file_too_big: "الملف غير المضغوط كبير جدًا." unknown_file_type: "يبدو أن الملف الذي حمَّلته ليس سمة Discourse صالحة." not_allowed_theme: "`%{repo}` غير مُدرَج في قائمة السمات المسموح بها (ضع علامة في مربع الإعداد العام `allowed_theme_repos`)." + ssh_key_gone: "لقد انتظرت طويلًا لتثبيت السمة وانتهت صلاحية مفتاح SSH. يُرجى إعادة المحاولة." errors: component_no_user_selectable: "لا يمكن أن تكون مكونات السمة قابلة للتحديد بواسطة المستخدم" component_no_default: "لا يمكن أن تكون مكونات السمة تابعة للسمة الافتراضية" @@ -131,6 +132,7 @@ ar: unsubscribe_not_allowed: "يحدث ذلك عندما يكون إلغاء الاشتراك عبر البريد الإلكتروني غير مسموح به لهذا المستخدم." email_not_allowed: "يحدث عندما لا يكون عنوان البريد الإلكتروني مُدرَجًا في قائمة السماح أو موجودًا في قائمة الحظر." unrecognized_error: "خطأ غير معروف" + secure_uploads_placeholder: "محجوبة: الوسائط الآمنة مفعَّلة في هذا الموقع، قم بزيارة الموضوع أو انقر على \"عرض الوسائط\" لعرض الوسائط المُرفَقة." view_redacted_media: "عرض الوسائط" errors: &errors format: ! "%{attribute} %{message}" @@ -224,6 +226,7 @@ ar: page_publishing_requirements: "لا يمكن تفعيل نشر الصفحة في حال تفعيل الوسائط الآمنة." s3_backup_requires_s3_settings: "لا يمكنك استخدام S3 كمكان للنسخ الاحتياطي ما لم تُدخِل \"%{setting_name}\"." s3_bucket_reused: "لا يمكنك استخدام الحاوية نفسها لـ \"s3_upload_bucket\" و\"s3_backup_bucket\" معًا. اختر حاويةً أخرى أو استخدم مسارًا مختلفًا لكل حاوية." + secure_uploads_requirements: "يجب تفعيل التحميل إلى S3 قبل تفعيل التحميلات الآمنة." share_quote_facebook_requirements: "يجب عليك ضبط معرِّف تطبيق Facebook لتفعيل مشاركة الاقتباسات على Facebook." second_factor_cannot_enforce_with_socials: "لا يمكنك فرض المصادقة الثنائية عند تفعيل عمليات تسجيل الدخول بحسابات التواصل الاجتماعي. يجب عليك أولًا إيقاف تسجيل الدخول عبر: %{auth_provider_names}" second_factor_cannot_be_enforced_with_disabled_local_login: "لا يمكنك فرض المصادقة الثنائية في حال إيقاف عمليات تسجيل الدخول المحلية." @@ -231,9 +234,10 @@ ar: local_login_cannot_be_disabled_if_second_factor_enforced: "لا يمكنك إيقاف تسجيل الدخول المحلي في حال فرض المصادقة الثنائية. أوقف المصادقة الثنائية المفروضة قبل إيقاف عمليات تسجيل الدخول المحلية." cannot_enable_s3_uploads_when_s3_enabled_globally: "لا يمكنك تفعيل تحميلات S3 لأن تحميلات S3 مفعَّلة بشكلٍ عام بالفعل، وقد يتسبب تفعيل مستوى الموقع هذا في حدوث مشكلات خطيرة في التحميلات" cors_origins_should_not_have_trailing_slash: "يجب عدم إضافة الشرطة المائلة اللاحقة (/) إلى مصادر CORS." - slow_down_crawler_user_agent_must_be_at_least_3_characters: "يجب أن يكون طول وكلاء المستخدم 3 أحرف على الأقل لتجنب تقييد المستخدمين البشريين بشكل غير صحيح." - slow_down_crawler_user_agent_cannot_be_popular_browsers: "لا يمكنك إضافة أي من القيم التالية إلى الإعداد: %{values}." - strip_image_metadata_cannot_be_disabled_if_composer_media_optimization_image_enabled: "لا يمكنك تعطيل البيانات الوصفية للصورة الشريطية إذا مُكّن \"تمكين صورة تحسين وسائط الملحن\". عطّل \"تمكين صورة تحسين وسائط الملحن\" قبل تعطيل البيانات الوصفية للصورة الشريطية." + slow_down_crawler_user_agent_must_be_at_least_3_characters: "يجب أن يكون طول وكلاء المستخدم 3 أحرف على الأقل لتجنُّب تقييد المستخدمين البشريين بشكلٍ غير صحيح." + slow_down_crawler_user_agent_cannot_be_popular_browsers: "لا يمكنك إضافة أيٍّ من القيم التالية إلى الإعداد: %{values}." + strip_image_metadata_cannot_be_disabled_if_composer_media_optimization_image_enabled: "لا يمكنك إيقاف إزالة البيانات الوصفية للصورة إذا كان 'composer media optimization image enabled' مفعلًا. أوقف 'composer media optimization image enabled' قبل إيقاف إزالة البيانات الوصفية للصورة." + twitter_summary_large_image_no_svg: "لا يمكن أن تكون صور ملخص Twitter المُستخدَمة في البيانات الوصفية لـ twitter:image بتنسيق svg." conflicting_google_user_id: 'تم تغيير معرِّف حساب Google لهذا الحساب؛ مطلوب تدخُّل فريق العمل لأسباب أمنية. يُرجى التواصل مع فريق العمل وتوجيهه إلى
    https://meta.discourse.org/t/76575' onebox: invalid_address: "عذرًا، لم نتمكن من إنشاء معاينة لصفحة الويب هذه لتعذُّر العثور على الخادم \"%{hostname}\". بدلًا من المعاينة، سيظهر رابط فقط في منشورك. :cry:" @@ -259,8 +263,8 @@ ar:

    بخلاف ذلك، يُرجى إعادة ضبط كلمة المرور.

    not_found_template_link: | -

    هذه الدعوة إلى %{site_name} لم يعد من الممكن استردادها. يرجى أن تطلب من الشخص الذي دعاك أن يرسل لك دعوة جديدة.

    - user_exists: "ليست هناك حاجة لدعوة %{email}، لديهم حساب فعلًا!" +

    لم يعُد من الممكن استرداد هذه الدعوة إلى %{site_name}. يُرجى أن تطلب من الشخص الذي دعاك أن يُرسل إليك دعوة جديدة.

    + user_exists: "ليست هناك حاجة لدعوة %{email}، فلديه حساب بالفعل!" invite_exists: "لقد أرسلت بالفعل دعوة إلى %{email}." invalid_email: "%{email} ليس عنوان بريد إلكتروني صالحًا." rate_limit: @@ -275,8 +279,11 @@ ar: disabled_errors: discourse_connect_enabled: "الدعوات متوقفة لأنه تم تفعيل DiscourseConnect." invalid_access: "غير مسموح لك بعرض المورد المطلوب." - requires_groups: "لم تُحفظ الدعوة لأن الموضوع المحدد لا يمكن الوصول إليه. أضف إحدى المجموعات التالية: %{groups}." + requires_groups: "لم يتم حفظ الدعوة لأن الموضوع المحدَّد لا يمكن الوصول إليه. أضِف إحدى المجموعات التالية: %{groups}." domain_not_allowed: "لا يمكن استخدام بريدك الإلكتروني لاسترداد هذه الدعوة." + max_redemptions_allowed_one: "يجب أن تكون 1 لدعوات البريد الإلكتروني." + redemption_count_less_than_max: "ينبغي أن يكون أقل من %{max_redemptions_allowed}." + email_xor_domain: "غير مسموح بحقول البريد الإلكتروني والنطاق في الوقت نفسه" bulk_invite: file_should_be_csv: "يجب أن يكون الملف الذي تم تحميله بتنسيق CSV." max_rows: "تم إرسال أول %{max_bulk_invites} دعوة. حاول تقسيم الملف إلى أجزاء أصغر." @@ -286,6 +293,7 @@ ar: max_redemptions_limit: "يجب أن يكون بين 2 و%{max_limit}." topic_invite: failed_to_invite: "لا يمكن دعوة المستخدم إلى هذا الموضوع دون عضوية في إحدى المجموعات التالية: %{group_names}." + not_pm: "يمكنك الدعوة إلى الرسائل الخاصة فقط." user_exists: "عذرًا، لقد تمت دعوة هذا المستخدم بالفعل. لا يمكنك دعوة مستخدام إلى موضوع أكثر من مرة." muted_topic: "عذرًا، لقد كتم هذا المستخدم ذلك الموضوع." receiver_does_not_allow_pm: "عذرًا، لا يسمح لك هذا المستخدم بإرسال رسائل خاصة إليه." @@ -302,7 +310,7 @@ ar: not_found: "تعذَّر العثور على عنوان URL أو المورد المطلوب." invalid_access: "غير مسموح لك بعرض المورد المطلوب." authenticator_not_found: "طريقة المصادقة غير موجودة، أو تم إيقافها." - authenticator_no_connect: "لا يسمح موفر المصادقة هذا بالاتصال بحساب المنتدى الموجود." + authenticator_no_connect: "لا يسمح موفِّر المصادقة هذا بالاتصال بحساب موجود على المنتدى." invalid_api_credentials: "غير مسموح لك بعرض الموارد المطلوبة. اسم مستخدم API أو المفتاح غير صالح." provider_not_enabled: "غير مسموح لك بعرض المورد المطلوب. موفِّر المصادقة غير مفعَّل." provider_not_found: "غير مسموح لك بعرض المورد المطلوب. موفِّر المصادقة غير موجود." @@ -318,7 +326,7 @@ ar: deleted_topic: "عذرًا! تم حذف هذا الموضوع ولم يعُد متاحًا." delete_topic_failed: "حدث خطأ في أثناء حذف هذا الموضوع. يُرجى التواصل مع مسؤول الموقع." reading_time: "وقت القراءة" - likes: "الإعجابات" + likes: "تسجيلات الإعجاب" too_many_replies: zero: "عذرًا، لا يمكن للمستخدمين الجُدد الرد أكثر من %{count} مرة في الموضوع نفسه، وذلك بشكلٍ مؤقت." one: "عذرًا، لا يمكن للمستخدمين الجُدد الرد أكثر من مرة واحدة (%{count}) في الموضوع نفسه، وذلك بشكلٍ مؤقت." @@ -412,8 +420,8 @@ ar: few: "عذرًا، لا يمكن للمستخدمين الجُدد وضع أكثر من %{count} روابط في المنشور." many: "عذرًا، لا يمكن للمستخدمين الجُدد وضع أكثر من %{count} رابطًا في المنشور." other: "عذرًا، لا يمكن للمستخدمين الجُدد وضع أكثر من %{count} رابط في المنشور." - contains_blocked_word: "معذرةً، لا يمكنك إرسال كلمة \"%{word}\"؛ هذا غير مسموح." - contains_blocked_words: "آسف، لا يمكنك نشر ذلك. غير مسموح به: %{words}." + contains_blocked_word: "عذرًا، لا يمكنك إرسال كلمة \"%{word}\"؛ فهذا غير مسموح." + contains_blocked_words: "عذرًا، لا يمكنك نشر ذلك. الكلمات غير المسموح بها: %{words}." spamming_host: "عذرًا، لا يمكنك نشر رابط إلى هذا المضيف." user_is_suspended: "غير مسموح للمستخدمين المعلَّقين بالنشر." topic_not_found: "حدث خطأ. ربما يكون هذا الموضوع قد تم إغلاقه أو حذفه بينما كنت تعرضه؟" @@ -476,14 +484,14 @@ ar: bookmarks: errors: already_bookmarked_post: "لا يمكنك وضع إشارة مرجعية نفس المنشور نفسه مرتَين." - already_bookmarked: "لا يمكنك وضع إشارة مرجعية على نفس %{type} مرتين." + already_bookmarked: "لا يمكنك وضع إشارة مرجعية على %{type} نفسه مرتين." too_many: "عذرًا، لا يمكنك إضافة أكثر من %{limit} إشارة مرجعية، انتقل إلى %{user_bookmarks_url} لإزالة بعضها." cannot_set_past_reminder: "لا يمكنك ضبط تذكير بالإشارة المرجعية في الماضي." cannot_set_reminder_in_distant_future: "لا يمكنك ضبط تذكير بالإشارة المرجعية بعد أكثر من 10 أيام في المستقبل." time_must_be_provided: "يجب إدخال الوقت لجميع التذكيرات" - for_topic_must_use_first_post: "يمكنك فقط استخدام المنشور الأول لوضع علامة مرجعية على الموضوع." - bookmarkable_id_type_required: "مطلوب اسم ونوع السجل للعلامة المرجعية." - invalid_bookmarkable: "لا يمكن وضع إشارة مرجعية على %{type} ." + for_topic_must_use_first_post: "يمكنك استخدام المنشور الأول فقط لوضع إشارة مرجعية على الموضوع." + bookmarkable_id_type_required: "مطلوب اسم السجل ونوعه للعلامة المرجعية." + invalid_bookmarkable: "لا يمكن وضع إشارة مرجعية على %{type}." reminders: at_desktop: "في المرة القادمة التي أستخدم فيها كمبيوتر سطح المكتب" later_today: "لاحقًا اليوم" @@ -587,8 +595,8 @@ ar: يمكنك التعديل على ردك السابق لإضافة اقتباس عن طريق تمييز النص والضغط على زر اقتباس الرد الذي يظهر. يسهُل على الجميع قراءة الموضوعات التي يوجد بها عدد قليل من الردود التفصيلية بدلًا من الكثير من الردود الفردية الصغيرة. - dominating_topic: "### دع الآخرين ينضمون إلى المحادثة\n\nمن الواضح أن هذا الموضوع مهم بالنسبة لك &ndash؛ لقد نشرت أكثر من %{percent}% من الردود هنا.\n\nقد يكون من الأفضل أيضًا أن تمنح الآخرين مساحة لمشاركة وجهات نظرهم أيضًا. هل يمكنك دعوتهم؟" - get_a_room: لقد رددت على @%{reply_username} %{count} مرة، هل تعلم أنه يمكنك إرسال رسالة شخصية إليه بدلاً من ذلك؟ + dominating_topic: لقد نشرت أكثر من %{percent}% من الردود هنا؛ هل يمكننا أن نقترح إعطاء أشخاص آخرين الفرصة للتحدث؟ + get_a_room: لقد رددت على @%{reply_username} %{count} من المرات، هل تعلم أنه يمكنك إرسال رسالة شخصية إليه بدلًا من ذلك؟ too_many_replies: | ### لقد بلغت حد الردود في هذا الموضوع @@ -684,14 +692,21 @@ ar: too_many: "الكثير من الكلمات لهذا الإجراء" base: invalid_url: "عنوان URL البديل غير صالح" + invalid_tag_list: "قائمة وسوم الاستبدال غير صالحة" + sidebar_section_link: + attributes: + linkable_type: + invalid: "غير صالح" <<: *errors uncategorized_category_name: "غير مصنَّف" general_category_name: "عام" + general_category_description: "أنشئ موضوعات هنا لا تتناسب مع أي فئة أخرى موجودة." meta_category_name: "التعليقات على الموقع" meta_category_description: "مناقشة بشأن هذا الموقع، ومؤسسته، وطريقة عمله، وكيف يمكننا تحسينه." staff_category_name: "فريق العمل" staff_category_description: "فئة خاصة لمناقشات فريق العمل. تكون الموضوعات مرئية للمسؤولين والمشرفين فقط." discourse_welcome_topic: + title: "مرحبًا بك في مجتمعنا!" body: |2 ستكون الفقرة الأولى من هذا الموضوع المثبَّت مرئية كرسالة ترحيب لجميع الزوار الجُدد على صفحتك الرئيسية. إنه مهم! @@ -723,7 +738,7 @@ ar: permission_conflict: "يجب أيضًا السماح لأي مجموعة مسموح لها بالوصول إلى فئة فرعية بالوصول إلى الفئة الرئيسية. المجموعات التالية لديها إذن بالوصول إلى إحدى الفئات الفرعية، ولكن ليس لديها إذن بالوصول إلى الفئة الرئيسية: %{group_names}." disallowed_topic_tags: "يتضمَّن هذا الموضوع وسومًا لا تسمح بها هذه الفئة: \"%{tags}\"" disallowed_tags_generic: "يتضمَّن الموضوع وسومًا غير مسموح بها." - slug_contains_non_ascii_chars: "يتضمَّن حروفًا غير صالحة" + slug_contains_non_ascii_chars: "يتضمَّن حروفًا لا تنتمي إلى ترميز ASCII" cannot_delete: uncategorized: "هذه الفئة خاصة. والغرض منها هو أن تكون منطقة للاحتفاظ بالموضوعات التي ليس لها فئة؛ لا يمكن حذفها." has_subcategories: "لا يمكن حذف هذه الفئة لأنها تتضمَّن فئات فرعية." @@ -743,7 +758,12 @@ ar: post: image_placeholder: broken: "هذه الصورة تالفة" - hidden_bidi_character: "يمكن للأحرف ثنائية الاتجاه تغيير ترتيب عرض النص. يمكن استخدام هذا لإخفاء التعليمات البرمجية الضارة." + blocked_hotlinked_title: "صورة مستضافة على موقع آخر. انقر للفتح في علامة تبويب جديدة." + blocked_hotlinked: "صورة خارجية" + media_placeholder: + blocked_hotlinked_title: "وسائط مستضافة على موقع آخر. انقر للفتح في علامة تبويب جديدة." + blocked_hotlinked: "وسائط خارجية" + hidden_bidi_character: "يمكن للأحرف ثنائية الاتجاه تغيير ترتيب عرض النص. ويمكن استخدام ذلك لإخفاء الرموز البرمجية الضارة." has_likes: zero: "%{count} إعجاب" one: "إعجاب واحد (%{count})" @@ -753,7 +773,7 @@ ar: other: "%{count} إعجاب" cannot_permanently_delete: many_posts: "لا يمكنك حذف هذا الموضوع نهائيًا نظرًا لوجود منشورات أخرى." - wait_or_different_admin: "يجب أن تنتظر %{time_left} قبل حذف هذا المنشور نهائيًا أو يجب على مسؤول آخر فعل ذلك." + wait_or_different_admin: "يجب عليك الانتظار لمدة %{time_left} قبل حذف هذا المنشور نهائيًا أو يجب على مسؤول آخر فعل ذلك." rate_limiter: slow_down: "لقد نفَّذت هذا الإجراء عدة مرات، يُرجى إعادة المحاولة في وقت لاحق." too_many_requests: "لقد نفَّذت هذا الإجراء عدة مرات. يُرجى الانتظار %{time_left} قبل إعادة المحاولة." @@ -766,6 +786,7 @@ ar: public_group_membership: "أنت تنضم إلى/تغادر المجموعات كثيرًا جدًا. يُرجى الانتظار %{time_left} قبل إعادة المحاولة." topics_per_day: "لقد وصلت إلى الحد الأقصى من الموضوعات الجديدة المسموح بها يوميًا. يمكنك إنشاء المزيد من الموضوعات الجديدة بعد %{time_left}." pms_per_day: "لقد وصلت إلى الحد الأقصى للرسائل المسموح بها يوميًا. يمكنك إنشاء المزيد من الرسائل الجديدة بعد %{time_left}." + create_like: "واو! لقد كنت تشارك الكثير من الحب! لقد بلغت العدد الأقصى من تسجيلات الإعجاب خلال فترة 24 ساعة، ولكن بينما تكتسب مستويات الثقة، فستحصل على المزيد من تسجيلات الإعجاب اليومية. ستتمكن من تسجيل إعجابك بالمنشورات مرة أخرى بعد %{time_left}." create_bookmark: "لقد وصلت إلى الحد الأقصى من عدد الإشارات المرجعية اليومية. يمكنك إنشاء المزيد من الإشارات المرجعية بعد %{time_left}." edit_post: "لقد وصلت إلى الحد الأقصى من عدد التعديلات اليومية. يمكنك إجراء المزيد من التعديلات بعد %{time_left}." live_post_counts: "أنت تطلب أعداد المنشورات المباشرة بسرعة كبيرة. يُرجى الانتظار %{time_left} قبل إعادة المحاولة." @@ -947,7 +968,6 @@ ar: many: "منذ حوالي %{count} عامًا تقريبًا" other: "منذ حوالي %{count} عام تقريبًا" password_reset: - no_token: "عذرًا، رابط تغيير كلمة المرور قديم جدًا. اضغط على زر \"تسجيل الدخول\" واستخدم \"نسيت كلمة المرور\" لنُرسل إليك رابطًا جديدًا." choose_new: "اختيار كلمة مرور جديدة" choose: "اختيار كلمة مرور" update: "تحديث كلمة المرور" @@ -1073,7 +1093,7 @@ ar: pm_title: "المسودات الاحتياطية من الموضوعات الجارية" pm_body: "موضوع يحتوي على مسودات احتياطية" user_activity: - no_log_search_queries: "استعلامات سجل البحث معطلة حاليًا (يمكن للمسؤول تمكينها في إعدادات الموقع)." + no_log_search_queries: "استعلامات سجل البحث متوقفة حاليًا (يمكن للمسؤول تفعيلها في إعدادات الموقع)." email_settings: pop3_authentication_error: "حدثت مشكلة في بيانات اعتماد POP3 المقدَّمة، تحقَّق من اسم المستخدم وكلمة المرور وحاول مرة أخرى." imap_authentication_error: "حدثت مشكلة في بيانات اعتماد IMAP المقدَّمة، تحقَّق من اسم المستخدم وكلمة المرور وحاول مرة أخرى." @@ -1134,7 +1154,7 @@ ar: remove: "لم يعُد هذا الموضوع بانرًا. ولن يظهر بعد الآن في أعلى كل صفحة." unsubscribed: title: "تم تحديث تفضيلات البريد الإلكتروني!" - description: "تُحدث تفضيلات البريد الإلكتروني للعنوان %{email}. لتغيير إعدادات بريدك الإلكتروني، انتقل إلى تفضيلات المستخدم." + description: "تم تحديث تفضيلات البريد الإلكتروني للعنوان %{email}. لتغيير إعدادات بريدك الإلكتروني، انتقل إلى تفضيلات المستخدم." topic_description: "لإعادة الاشتراك في %{link}، استخدم إدارة التنبيهات في أسفل أو يسار الموضوع." private_topic_description: "لإعادة الاشتراك، استخدم عنصر التحكُّم في الإشعارات في أسفل أو يسار الموضوع." uploads: @@ -1143,7 +1163,7 @@ ar: title: "إلغاء الاشتراك" stop_watching_topic: "إيقاف مراقبة هذا الموضوع، %{link}" mute_topic: "كتم جميع الإشعارات لهذا الموضوع، %{link}" - unwatch_category: "إيقاف مراقبة جميع الموضوعات في %{category}" + unwatch_category: "إيقاف مراقبة كل الموضوعات في %{category}" mailing_list_mode: "إيقاف تشغيل وضع القائمة البريدية" all: "عدم مراسلتي من %{sitename}" different_user_description: "لقد سجَّلت الدخول حاليًا كمستخدم مختلف عن الذي راسلناه. يُرجى تسجيل الخروج أو دخول وضع التخفي، ثم إعادة المحاولة." @@ -1514,26 +1534,26 @@ ar: mutes_count: عدد مرات الكتم description: "المستخدمون الذين تم كتمهم أو تجاهلهم بواسطة العديد من المستخدمين الآخرين" top_users_by_likes_received: - title: "أفضل المستخدمين حسب الإعجابات المستلمة" + title: "أبرز المستخدمين حسب تسجيلات الإعجاب المتلقاة" labels: user: المستخدم - qtt_like: الإعجابات المتلقاة - description: "أفضل 10 مستخدمين تلقوا الإعجابات." + qtt_like: تسجيلات الإعجاب المتلقاة + description: "أبرز 10 مستخدمين تلقوا تسجيلات الإعجاب." top_users_by_likes_received_from_inferior_trust_level: - title: "أفضل المستخدمين حسب الإعجابات المستلمة من مستخدم بمستوى ثقة أقل" + title: "أبرز المستخدمين حسب تسجيلات الإعجاب المتلقاة من مستخدم ذي مستوى ثقة أقل" labels: user: المستخدم trust_level: مستوى الثقة - qtt_like: الإعجابات المتلقاة - description: "أعلى 10 مستخدمين في مستوى الثقة أعلى يُعجب بهم أشخاص في مستوى ثقة أقل." + qtt_like: تسجيلات الإعجاب المتلقاة + description: "أبرز 10 مستخدمين في مستوى ثقة أعلى نالوا إعجاب أشخاص في مستوى ثقة أقل." top_users_by_likes_received_from_a_variety_of_people: - title: "أفضل المستخدمين حسب الإعجابات الواردة من مجموعة متنوعة من الأشخاص" + title: "أبرز المستخدمين حسب تسجيلات الإعجاب الواردة من مجموعة متنوعة من الأشخاص" labels: user: المستخدم - qtt_like: الإعجابات المتلقاة - description: "أفضل 10 مستخدمين حصلوا على إعجابات من مجموعة واسعة من الأشخاص." + qtt_like: تسجيلات الإعجاب المتلقاة + description: "أبرز 10 مستخدمين حصلوا على إعجابات من مجموعة واسعة من الأشخاص." dashboard: - group_email_credentials_warning: 'حدثت مشكلة في بيانات اعتماد البريد الإلكتروني للمجموعة %{group_full_name}. لن تُرسل أي رسائل بريد إلكتروني من صندوق الوارد الخاص بالمجموعة حتى تُعالج هذه المشكلة. %{error}' + group_email_credentials_warning: 'حدثت مشكلة في بيانات اعتماد البريد الإلكتروني للمجموعة %{group_full_name}. لن يتم إرسال أي رسائل بريد إلكتروني من صندوق الوارد الخاص بالمجموعة حتى تتم معالجة هذه المشكلة. %{error}' rails_env_warning: "الخادم يعمل في وضع %{env}." host_names_warning: "يستخدم ملف config/database.yml اسم المضيف الافتراضي localhost. حدِّثه لاستخدام اسم مضيف موقعك." sidekiq_warning: 'Sidekiq ليس قيد التشغيل! يتم تنفيذ العديد من المهام، مثل إرسال الرسائل الإلكترونية، بشكلٍ غير متزامن من خلال sidekiq. يُرجى التأكد من وجود عملية sidekiq واحدة على الأقل قيد التشغيل. معرفة المزيد عن Sidekiq من هنا.' @@ -1565,8 +1585,8 @@ ar: unreachable_themes: "لم نتمكن من التحقُّق من وجود تحديثات للسمات التالية:" watched_word_regexp_error: "التعبير العادي للكلمات المُراقَبة للإجراء \"%{action}\" غير صالح. يُرجى التحقُّق من إعدادات الكلمات المُراقَبة، أو إيقاف إعداد الموقع `watched words regular expressions`." site_settings: - allow_bulk_invite: "السماح بالدعوات المجمعة عن طريق تحميل مِلَفّ CSV" - disabled: "معطّل" + allow_bulk_invite: "السماح بالدعوات الجماعية عن طريق تحميل ملف CSV" + disabled: "متوقف" display_local_time_in_user_card: "عرض التوقيت المحلي بناءً على المنطقة الزمنية للمستخدم عند فتح بطاقة المستخدم الخاصة به." censored_words: "الكلمات التي سيتم استبدالها تلقائيًا بـ ■■■■" delete_old_hidden_posts: "حذف المنشورات المخفية تلقائيًا إذا زادت مدة الإخفاء عن 30 يومًا" @@ -1587,7 +1607,7 @@ ar: max_emojis_in_title: "الحد الأقصى المسموح به من الرموز التعبيرية في عنوان الموضوع" min_search_term_length: "الحد الأدنى الصالح لطول مصطلح البحث بالأحرف" search_tokenize_chinese: "فرض البحث لترميز اللغة الصينية حتى على المواقع غير الصينية" - search_tokenize_japanese: "فرض البحث لترميز اليابانية حتى على المواقع غير اليابانية" + search_tokenize_japanese: "فرض البحث لترميز اللغة اليابانية حتى على المواقع غير اليابانية" search_prefer_recent_posts: "سيحاول هذا الخيار البحث في فهرس أحدث المنشورات أولًا إذا كان البحث في منتداك الكبير بطيئًا" search_recent_posts_size: "عدد المنشورات الحديثة التي سيتم الإبقاء عليها في الفهرس" log_search_queries: "تسجيل استعلامات البحث التي يجريها المستخدمون" @@ -1607,8 +1627,11 @@ ar: contact_email: "عنوان البريد الإلكتروني لجهة الاتصال الرئيسية المسؤولة عن هذا الموقع. يتم استخدامه للإشعارات المهمة ويتم عرضه أيضًا على صفحة /about للمسائل العاجلة." contact_url: "عنوان URL للتواصل لهذا الموقع. يتم عرضه على صفحة /about للمسائل العاجلة." crawl_images: "استعادة الصور من عناوين URL البعيدة لإدراج الأبعاد الصحيحة للطول والعرض" + download_remote_images_to_local: "يمكنك تحويل الصور البعيدة (المرتبطة برابط ساخن) عن طريق تنزيلها؛ يحفظ ذلك المحتوى حتى إذا تمت إزالة الصور من الموقع البعيد في المستقبل." download_remote_images_threshold: "الحد الأدنى من مساحة القرص اللازمة لتنزيل الصور البعيدة محليًا (بالنسبة المئوية)" disabled_image_download_domains: "لن يتم تنزيل الصور البعيدة من هذه النطاقات أبدًا. قائمة مفصولة بشرائط عمودية." + block_hotlinked_media: "امنع المستخدمين من وضع وسائط بعيدة (مرتبطة برابط ساخن) في منشوراتهم. سيتم استبدال الوسائط البعيدة التي لا يتم تنزيلها عبر 'download_remote_images_to_local' برابط لعنصر نائب." + block_hotlinked_media_exceptions: "قائمة بعناوين URL الأساسية المستثناة من إعداد block_hotlinked_media. قم بتضمين البروتوكول (مثل https://example.com)." editing_grace_period: "لن يؤدي التعديل إلى إنشاء نسخة جديدة في سجل المنشورات لمدة (n) ثانية بعد النشر." editing_grace_period_max_diff: "الحد الأقصى لعدد تغييرات الأحرف المسموح بها في فترة السماح بالتعديل، وحفظ مراجعة أخرى للمنشور إذا تم تغيير المزيد (مستوى الثقة 0 و1)" editing_grace_period_max_diff_high_trust: "الحد الأقصى لعدد تغييرات الأحرف المسموح بها في فترة السماح بالتعديل، مع حفظ مراجعة أخرى للمنشور إذا تم تغيير المزيد (مستوى الثقة 2 وأعلى)" @@ -1617,7 +1640,7 @@ ar: tl2_post_edit_time_limit: "يمكن للمؤلف من مستوى الثقة 2 وأعلى تعديل منشوراته لمدة (n) دقيقة بعد النشر. اضبط القيمة على 0 لإتاحة التعديل للأبد." edit_history_visible_to_public: "السماح للجميع برؤية النسخ السابقة من المنشورات التي تم تعديلها. عند إيقاف هذا الإعداد، سيتمكن أعضاء الفريق فقط من رؤيتها." delete_removed_posts_after: "سيتم حذف المنشورات التي أزالها المؤلف بعد (n) ساعة. في حال الضبط على 0، سيتم حذف المنشورات على الفور." - notify_users_after_responses_deleted_on_flagged_post: "عندما تُوضع علامة على منشور ثم تُزال، يُخطر جميع المستخدمين الذين ردوا على المنشور وأُزيلت ردودهم." + notify_users_after_responses_deleted_on_flagged_post: "عند الإبلاغ عن منشور وإزالته، سيتلقى جميع المستخدمين الذين ردوا على المنشور وتمت إزالة ردودهم إشعارًا." max_image_width: "أقصى عرض للصور المصغَّرة في المنشور" max_image_height: "أقصى ارتفاع للصور المصغَّرة في المنشور" responsive_post_image_sizes: "تغيير حجم صور المعاينة المبسَّطة للسماح بالشاشات ذات كثافة النقاط العالية بنسب البكسل التالية. قم بإزالة جميع القيم لإيقاف الصور المتجاوبة." @@ -1630,6 +1653,8 @@ ar: show_pinned_excerpt_mobile: "إظهار المقتطف في الموضوعات المثبَّتة فى طريقة عرض الجوَّال." show_pinned_excerpt_desktop: "إظهار المقتطف في الموضوعات المثبَّتة فى طريقة عرض سطح المكتب" post_onebox_maxlength: "الحد الأقصى لطول منشور Discourse في لوحة المعاينة بالأحرف." + blocked_onebox_domains: "قائمة بالنطاقات التي لن يتم وضعها في لوحة معاينة مطلقًا؛ على سبيل المثال، wikipedia.org\n(رموز أحرف البدل * ؟ غير مدعومة)" + block_onebox_on_redirect: "حجب لوحة المعاينة لعناوين URL التي تعيد التوجيه." allowed_inline_onebox_domains: "قائمة بالنطاقات التي سيتم وضعها في لوحة المعاينة في شكلٍ مصغَّر إذا تم ربطها دون عنوان" enable_inline_onebox_on_all_domains: "تجاهل إعداد الموقع inline_onebox_domain_allowlist والسماح بلوحة المعاينة المضمَّنة على جميع النطاقات." force_custom_user_agent_hosts: "المضيفات التي سيتم استخدام وكيل المستخدم للوحة المعاينة المخصَّصة في جميع طلباتها. (هذا الإعداد مفيد بشكلٍ خاص للمضيفات الذين تقيِّد الوصول حسب وكيل المستخدم)." @@ -1648,6 +1673,7 @@ ar: favicon: "الرمز المفضَّل لموقعك، راجع https://en.wikipedia.org/wiki/Favicon للعمل بشكلٍ صحيح على شبكة توصيل المحتوى، يجب أن تكون الصورة بتنسيق png. سيتم تغيير حجمها إلى 32 × 32. وإذا تركتها فارغة، فسيتم استخدام large_icon." apple_touch_icon: "الصورة المُستخدَمة كشعار/صورة البداية على Android. سيتم تغيير حجمها تلقائيًا إلى 512 × 512. وإذا تركتها فارغة، فسيتم استخدام large_icon." opengraph_image: "صورة opengraph افتراضية، يتم استخدامها عندما لا تحتوي الصفحة على صورة أخرى مناسبة. وإذا تركتها فارغة، فسيتم استخدام large_icon." + twitter_summary_large_image: "بطاقة Twitter 'summary large image' (ينبغي ألا يقل عرضها عن 280، وارتفاعها عن 150، وألا تكون بتنسيق .svg). في حال تركها فارغة، يتم إنشاء البيانات الوصفية التقليدية باستخدام opengraph_image ما دامت هي أيضًا ليست بتنسيق .svg" notification_email: "عنوان البريد الإلكتروني المُستخدَم في حقل \"من:\" عند إرسال جميع رسائل النظام الأساسية. يجب ضبط سجلات SPF وDKIM وreverse PTR للنطاق المحدَّد هنا بشكلٍ صحيح لتصل الرسالة الإلكترونية." email_custom_headers: "قائمة مفصولة بشرائط عمودية لرؤوس البريد الإلكتروني المخصَّصة" email_subject: "تنسيق موضوع قابل للتخصيص للرسائل الإلكترونية القياسية. راجع https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" @@ -1657,9 +1683,12 @@ ar: same_site_cookies: "استخدام ملفات تعريف ارتباط الموقع نفسه، فهي تقضي على جميع مؤشرات CSRF على المتصفحات المدعومة (Lax أو Strict). تحذير: سيعمل Strict فقط على المواقع التي تفرض تسجيل الدخول وتستخدم طريقة مصادقة خارجية." summary_score_threshold: "الحد الأدنى من النقاط المطلوبة لتضمين منشور في \"تلخيص هذا الموضوع\"" summary_posts_required: "الحد الأدنى من عدد المنشورات في الموضوع قبل تفعيل \"تلخيص هذا الموضوع\". سيتم تطبيق التغييرات على هذا الإعداد بأثر رجعي في غضون أسبوع." - summary_likes_required: "الحد الأدنى من عدد الإعجابات في الموضوع قبل تفعيل \"تلخيص هذا الموضوع\". سيتم تطبيق التغييرات على هذا الإعداد بأثر رجعي في غضون أسبوع." + summary_likes_required: "الحد الأدنى من عدد تسجيلات الإعجاب في الموضوع قبل تفعيل \"تلخيص هذا الموضوع\". سيتم تطبيق التغييرات على هذا الإعداد بأثر رجعي في غضون أسبوع." summary_percent_filter: "يظهر أعلى % من المنشورات عندما يضغط المستخدم على \"تلخيص هذا الموضوع\"" summary_max_results: "الحد الأقصى لعدد المنشورات التي تم إرجاعها بواسطة \"تلخيص هذا الموضوع\"" + summary_timeline_button: "إظهار زر 'تلخيص' في الجدول الزمني" + enable_personal_messages: "تم إيقافه، استخدم إعداد 'personal message enabled groups' بدلًا من ذلك. اسمح للمستخدمين من مستوى الثقة 1 (قابل للإعداد عبر الحد الأدنى لمستوى الثقة لإرسال الرسائل) بإنشاء رسائل والرد على الرسائل. لاحظ أن فريق العمل يمكنه دائمًا إرسال الرسائل بغض النظر عن أي شيء." + personal_message_enabled_groups: "اسمح للمستخدمين داخل هذه المجموعات بإنشاء رسائل والرد على الرسائل. تتضمن مجموعات مستوى الثقة جميع مستويات الثقة التي تزيد عن هذا الرقم؛ على سبيل المثال، يتيح اختيار trust_level_1 أيضًا لمستخدمي trust_level_2 و3 و4 إرسال رسائل خاصة. لاحظ أن الموظفين يمكنهم دائمًا إرسال الرسائل بغض النظر عن السبب." enable_system_message_replies: "يسمح للمستخدمين بالرد على رسائل النظام، حتى إذا كانت الرسائل الشخصية متوقفة." enable_chunked_encoding: "تفعيل استجابات الترميز المقسَّمة بواسطة الخادم. تعمل هذه الميزة على معظم الإعدادات، ولكن قد يتم تخزين بعض الخوادم الوكيلة مؤقتًا، مما يتسبب في تأخير الاستجابات" long_polling_base_url: "عنوان URL الأساسي المُستخدَم في الاستقصاء الطويل (عندما تخدم شبكة توصيل المحتوى (CDN) محتوًى ديناميكيًا، احرص على ضبط هذا على سحب الموارد من المصدر) eg: http://origin.site.com" @@ -1672,12 +1701,15 @@ ar: cooldown_minutes_after_hiding_posts: "عدد الدقائق التي يجب على المستخدم انتظارها قبل أن يتمكن من تعديل منشور مخفي بسبب بلاغات المجتمع" max_topics_in_first_day: "أقصى عدد من الموضوعات المسموح للمستخدم إنشاؤها خلال 24 ساعة من إنشاء أول منشور." max_replies_in_first_day: "أقصى عدد من الردود المسموح للمستخدم إنشاؤها خلال 24 ساعة قبل إنشاء منشوره الأول" - tl2_additional_likes_per_day_multiplier: "زيادة حد الإعجابات اليومي لمستوى الثقة 2 (عضو) عن طريق مضاعفة هذا الرقم" - tl3_additional_likes_per_day_multiplier: "زيادة حد الإعجابات اليومي لمستوى الثقة 3 (منتظم) عن طريق مضاعفة هذا الرقم" - tl4_additional_likes_per_day_multiplier: "زيادة حد الإعجابات اليومي لمستوى الثقة 4 (قائد) عن طريق مضاعفة هذا الرقم" + tl2_additional_likes_per_day_multiplier: "زيادة حد تسجيلات الإعجاب اليومي لمستوى الثقة 2 (عضو) عن طريق مضاعفة هذا الرقم" + tl3_additional_likes_per_day_multiplier: "زيادة حد تسجيلات الإعجاب اليومي لمستوى الثقة 3 (منتظم) عن طريق مضاعفة هذا الرقم" + tl4_additional_likes_per_day_multiplier: "زيادة حد تسجيلات الإعجاب اليومي لمستوى الثقة 4 (قائد) عن طريق مضاعفة هذا الرقم" tl2_additional_edits_per_day_multiplier: "زيادة حد التعديلات اليومية لمستوى الثقة 2 (عضو) بالضرب في هذا الرقم" tl3_additional_edits_per_day_multiplier: "زيادة حد التعديلات اليومية لمستوى الثقة 3 (منتظم) بالضرب في هذا الرقم" tl4_additional_edits_per_day_multiplier: "زيادة حد التعديلات اليومية لمستوى الثقة 4 (قائد) بالضرب في هذا الرقم" + tl2_additional_flags_per_day_multiplier: "زيادة حد البلاغات اليومية لمستوى الثقة 2 (عضو) بالضرب في هذا الرقم" + tl3_additional_flags_per_day_multiplier: "زيادة حد البلاغات اليومية لمستوى الثقة 3 (منتظم) بالضرب في هذا الرقم" + tl4_additional_flags_per_day_multiplier: "زيادة حد البلاغات اليومية لمستوى الثقة 4 (قائد) بالضرب في هذا الرقم" num_users_to_silence_new_user: "إخفاء جميع منشورات المستخدم الجديد ومنع النشر في المستقبل إذا تلقَّت منشوراته spam_flags_to_silence_new_user بلاغ عن سلوك غير مرغوب فيه من هذا العدد من المستخدمين المختلفين. اضبط القيمة على 0 للإيقاف." num_tl3_flags_to_silence_new_user: "إخفاء جميع منشورات المستخدم الجديد ومنع النشر في المستقبل إذا تلقَّت منشوراته العديد من البلاغات من num_tl3_users_to_silence_new_user مستخدم مختلف من مستوى الثقة 3. اضبط القيمة على 0 للإيقاف." num_tl3_users_to_silence_new_user: "إخفاء جميع منشورات المستخدم الجديد ومنع النشر في المستقبل إذا تلقَّت منشوراته num_tl3_flags_to_silence_new_user من هذا العدد من المستخدمين المختلفين من مستوى الثقة 3. اضبط القيمة على 0 للإيقاف." @@ -1702,6 +1734,7 @@ ar: ga_universal_auto_link_domains: "تفعيل تتبُّع Google Universal Analytics عبر النطاقات. ستتم إضافة معرِّف العميل إلى الروابط الصادرة إلى هذه النطاقات. راجع دليل التتبُّع عبر النطاقات من Google." gtm_container_id: "معرِّف حاوية إدارة العلامات من Google. على سبيل المثال: GTM-ABCDEF.
    ملاحظة: قد يلزم إدراج البرامج النصية المضمَّنة لجعة خارجية والتي تم تحميلها بواسطة GTM في قائمة السماح في `content security policy script src`." enable_escaped_fragments: "ارجع إلى واجهة برمجة تطبيقات Ajax-Crawling من Google إذا لم يتم اكتشاف زاحف ويب. راجع https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" + moderators_manage_categories_and_groups: "السماح للمشرفين بإنشاء وإدارة الفئات والمجموعات" moderators_change_post_ownership: "السماح للمشرفين بتغيير ملكية المنشور" cors_origins: "الأصول المسموح بها لطلبات الموارد متعددة المصادر (CORS). بجب أن يتضمَّن كل مصدر http:// أو https://. ويجب ضبط متغير البيئة للقيمة DISCOURSE_ENABLE_CORS على True لتفعيل CORS." use_admin_ip_allowlist: "لا يمكن للمسؤولين تسجيل الدخول إلا إذا كانوا على عنوان IP محدَّد في قائمة عناوين IP الخاضعة للمراقبة (المسؤول > السجلات > عناوين IP الخاضعة للمراقبة)." @@ -1711,6 +1744,7 @@ ar: allowed_iframes: "قائمة بادئات نطاقات iframe src التي يمكن أن يسمح بها Discourse بأمان في المنشورات" allowed_crawler_user_agents: "وكلاء المستخدمين لزاحفات الويب التي يجب السماح لها بالوصول إلى الموقع. تحذير! سيؤدي ضبط هذا الإعداد إلى عدم السماح بجميع الزاحفات غير المُدرَجة هنا!" blocked_crawler_user_agents: "الكلمة الفريدة غير حساسة لحالة الأحرف في سلسلة وكيل المستخدم والتي تحدِّد زاحفات الويب التي لا ينبغي السماح لها بالوصول إلى الموقع. لا تنطبق إذا تم تعريف قائمة السماح." + slow_down_crawler_user_agents: 'وكلاء المستخدم لبرامج زحف الويب التي يجب أن يكون معدَّلها محدودًا كما تم إعداده في "slow down crawler rate". يجب أن تتكون كل قيمة من 3 أحرف على الأقل.' slow_down_crawler_rate: "إذا تم تحديد slow_down_crawler_user_agents، فسيتم تطبيق هذا المعدَّل على جميع الزاحفات (عدد ثواني التأخير بين الطلبات)" content_security_policy: "تفعيل Content-Security-Policy" content_security_policy_report_only: "تفعيل Content-Security-Policy-Report-Only" @@ -1722,6 +1756,7 @@ ar: post_menu: "تحديد العناصر التي تظهر في قائمة المنشور وترتيبها. مثال: تعديل|إبلاغ|حذف|مشاركة|إشارة مرجعية|رد" post_menu_hidden_items: "عناصر القائمة التي سيتم إخفاؤها افتراضيًا في قائمة المنشور ما لم يتم النقر على رمز الثلاث نقاط لتوسيع القائمة." share_links: "تحديد العناصر التي تظهر في مربع حوار المشاركة وترتيبها" + allow_username_in_share_links: "السماح بتضمين أسماء المستخدمين في روابط المشاركة. هذا مفيد لمنح الشارات بناءً على الزوار المميزين." site_contact_username: "اسم مستخدم صالح لفريق العمل لإرسال جميع الرسائل الآلية منه. سيتم استخدام حساب النظام الافتراضي في حال تركها خالية." site_contact_group_name: "اسم مجموعة صالح ليتم دعوتها إلى جميع الرسائل الآلية" send_welcome_message: "إرسال رسالة ترحيبية إلى جميع المستخدمين الجُدد مع دليل البدء السريع" @@ -1729,6 +1764,7 @@ ar: send_tl2_promotion_message: "إرسال رسالة ترحيبية بشأن الترقية إلى المستخدمين الجُدد في مستوى الثقة 2" suppress_reply_directly_below: "عدم عرض عدد الردود القابل للتوسيع على منشور عندما يكون هناك رد واحد فقط أسفل هذا المنشور مباشرةً." suppress_reply_directly_above: "عدم عرض عبارة \"ردًا على\" القابلة للتوسيع في منشور عندما يكون هناك رد واحد فقط فوق هذا المنشور مباشرةً." + remove_full_quote: "إزالة الاقتباس تلقائيًا إذا (أ) ظهر في بداية المنشور، (ب) وكان لمنشور بأكمله، (ج) وكان من المنشور السابق مباشرةً. للمزيد من التفاصيل، راجع إزالة الاقتباسات الكاملة من الردود المباشرة" suppress_reply_when_quoting: "عدم عرض عبارة \"ردًا على\" القابلة للتوسيع في منشور عند اقتباس المنشور للرد." max_reply_history: "الحد الأقصى لعدد الردود التي سيتم توسيعها عند توسيع عبارة \"ردًا على\"" topics_per_period_in_top_summary: "عدد الموضوعات الأكثر نشاطًا في الملخص الافتراضي للموضوعات الأكثر نشاطًا." @@ -1743,10 +1779,13 @@ ar: enable_badges: "تفعيل نظام الشارات" max_favorite_badges: "الحد الأقصى لعدد الشارات التي يمكن للمستخدم تحديدها" enable_whispers: "السماح بالاتصالات الخاصة بين فريق العمل داخل الموضوعات." + whispers_allowed_groups: "السماح بالاتصالات الخاصة داخل الموضوعات لأعضاء المجموعات المحدَّدة." allow_index_in_robots_txt: "حدِّد في ملف robots.txt أن هذا الموقع يسمح لمحركات بحث الويب بفهرسته. وفي حالات استثنائية، يمكنك تجاوز ملف robots.txt بشكلٍ دائم." blocked_email_domains: "قائمة مفصولة بشرائط عمودية لنطاقات البريد الإلكتروني التي لا يتم السماح للمستخدمين بتسجيل حسابات عليها. مثال: mailinator.com|trashmail.net" allowed_email_domains: "قائمة مفصولة بشرائط عمودية لنطاقات البريد الإلكتروني التي يجب على المستخدمين تسجيل حسابات عليها. تحذير: لن يتم السماح بالمستخدمين المسجَّلين على نطاقات بريد إلكتروني أخرى بخلاف المذكورة هنا!" + normalize_emails: "تحقَّق مما إذا كان البريد الإلكتروني الذي تم تطبيعه فريدًا. يزيل البريد الإلكتروني الذي تم تطبيعه جميع النقاط من اسم المستخدم وكل شيء بين الرمزين + و@." auto_approve_email_domains: "ستتم الموافقة تلقائيًا على المستخدمين الذين لديهم عناوين بريد إلكتروني من قائمة النطاقات هذه." + hide_email_address_taken: "عدم إعلام المستخدمين بوجود حساب بعنوان بريد إلكتروني معيَّن في أثناء التسجيل أو في أثناء عملية \"نسيت كلمة المرور\". طلب البريد الإلكتروني الكامل لطلبات \"نسيت كلمة المرور\"." log_out_strict: "تسجيل خروج المستخدم من جميع الجلسات على جميع الأجهزة عند تسجيل الخروج" version_checks: "فحص Discourse Hub للحصول على تحديثات الإصدار وإظهار رسائل الإصدار الجديد على /admin لوحة المعلومات" new_version_emails: "إرسال رسالة إلكترونية إلى عنوان contact_email عند توفُّر إصدار جديد من Discourse" @@ -1791,6 +1830,9 @@ ar: google_oauth2_client_secret: "الرمز السري للعميل لتطبيق Google" google_oauth2_prompt: "قائمة اختيارية مفصولة بمسافات لقيم السلسلة والتي تحدِّد ما إذا كان خادم التفويض يطالب المستخدم بإعادة المصادقة والموافقة. راجع https://developers.google.com/identity/protocols/OpenIDConnect#prompt لمعرفة القيم الممكنة." google_oauth2_hd: "نطاق Google Apps Hosted اختياري والذي سيقتصر تسجيل الدخول عليه. راجع https://developers.google.com/identity/protocols/OpenIDConnect#hd-param لمزيد من التفاصيل." + google_oauth2_hd_groups: "(تجريبي) استرداد مجموعات Google للمستخدمين على النطاق المستضاف عند المصادقة. يمكن استخدام مجموعات Google التي تم استردادها لمنح عضوية مجموعة Discourse تلقائيًا (انظر إعدادات المجموعة). للمزيد من المعلومات، راجع https://meta.discourse.org/t/226850" + google_oauth2_hd_groups_service_account_admin_email: "عنوان بريد إلكتروني ينتمي إلى حساب مسؤول Google Workspace. سيتم استخدامه مع بيانات اعتماد حساب الخدمة لإحضار معلومات المجموعة." + google_oauth2_hd_groups_service_account_json: "معلومات مفتاح بتنسيق JSON لحساب الخدمة. سيتم استخدامه لإحضار معلومات المجموعة." enable_twitter_logins: "تفعيل مصادقة Twitter، يتطلب twitter_consumer_key وtwitter_consumer_secret. راجع إعداد تسجيل الدخول عبر Twitter (والتضمينات الغنية) لمنصة Discourse." twitter_consumer_key: "مفتاح العميل لمصادقة Twitter، مسجَّل على https://developer.twitter.com/apps" twitter_consumer_secret: "الرمز السري للعميل لمصادقة Twitter، مسجَّل على https://developer.twitter.com/apps" @@ -1823,14 +1865,16 @@ ar: verbose_localization: "عرض نصائح موسَّعة بشأن الترجمة في واجهة المستخدم" previous_visit_timeout_hours: "مدة الزيارة قبل أن نعتبرها الزيارة \"السابقة\"، بالساعات" top_topics_formula_log_views_multiplier: "قيمة مُضاعِف (n) مرات عرض السجل في معادلة الموضوعات الأكثر نشاطًا: `log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_first_post_likes_multiplier: "قيمة مُضاعِف الإعجابات على أول منشور (n) في معادلة الموضوعات الأكثر نشاطًا: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_least_likes_per_post_multiplier: "قيمة أقل عدد من الإعجابات لكل مُضاعِف منشورات (n) في معادلة الموضوعات الأكثر نشاطًا: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" + top_topics_formula_first_post_likes_multiplier: "قيمة مُضاعِف تسجيلات الإعجاب على أول منشور (n) في معادلة الموضوعات الأكثر نشاطًا: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" + top_topics_formula_least_likes_per_post_multiplier: "قيمة أقل عدد من تسجيلات الإعجاب لكل مُضاعِف منشورات (n) في معادلة الموضوعات الأكثر نشاطًا: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" enable_safe_mode: "السماح للمستخدمين بالدخول إلى الوضع الآمن لتصحيح أخطاء المكوِّنات الإضافية" + enable_experimental_sidebar_hamburger: "يسمح بتفعيل الشريط الجانبي التجريبي وقائمة الثلاث شرط المنسدلة." + enable_sidebar: "يفعِّل الشريط الجانبي التجريبي." rate_limit_create_topic: "بعد إنشاء موضوع، يجب على المستخدمين الانتظار (n) ثانية قبل إنشاء موضوع آخر." rate_limit_create_post: "بعد النشر، يجب على المستخدمين الانتظار (n) ثانية قبل إنشاء منشور آخر." rate_limit_new_user_create_topic: "بعد إنشاء موضوع، يجب على المستخدمين الجُدد الانتظار (n) ثانية قبل إنشاء موضوع آخر." rate_limit_new_user_create_post: "بعد النشر، يجب على المستخدمين الجُدد الانتظار (n) ثانية قبل إنشاء منشور آخر." - max_likes_per_day: "أقصى عدد من الإعجابات لكل مستخدم" + max_likes_per_day: "أقصى عدد من تسجيلات الإعجاب لكل مستخدم" max_flags_per_day: "الحد الأقصى لعدد البلاغات لكل مستخدم في اليوم" max_bookmarks_per_day: "الحد الأقصى اليومي للإشارات المرجعية لكل مستخدم" max_edits_per_day: "الحد الأقصى اليومي للتعديلات لكل مستخدم" @@ -1838,6 +1882,7 @@ ar: max_personal_messages_per_day: "الحد الأقصى لعدد موضوعات الرسائل الشخصية الجديدة التي يمكن للمستخدم إنشاؤها يوميًا" max_invites_per_day: "الحد الأقصى اليومي للدعوات التي يمكن للمستخدم إرسالها" max_topic_invitations_per_day: "الحد الأقصى لعدد الدعوات إلى الموضوعات التي يمكن للمستخدم إرسالها يوميًا" + max_topic_invitations_per_minute: "الحد الأقصى لعدد الدعوات إلى الموضوعات التي يمكن للمستخدم إرسالها في الدقيقة" max_logins_per_ip_per_hour: "الحد الأقصى لعدد عمليات تسجيل الدخول المسموح بها لكل عنوان IP في الساعة" max_logins_per_ip_per_minute: "الحد الأقصى لعدد عمليات تسجيل الدخول المسموح بها لكل عنوان IP في الدقيقة" max_post_deletions_per_minute: "الحد الأقصى لعدد المنشورات التي يمكن للمستخدم حذفها في الدقيقة. اضبط القيمة على 0 لإيقاف عمليات حذف المنشورات." @@ -1868,6 +1913,8 @@ ar: external_emoji_url: "عنوان URL للخدمة الخارجية لصور الرموز التعبيرية. اتركه فارغًا للإيقاف." use_site_small_logo_as_system_avatar: "استخدام شعار الموقع الصغير بدلًا من الصورة الرمزية لمستخدم النظام. يتطلب وجود الشعار." restrict_letter_avatar_colors: "قائمة بقيم الألوان السداسية العشرية المكوَّنة من 6 أرقام لاستخدامها في خلفية الصورة الرمزية لحرف الاسم" + enable_listing_suspended_users_on_search: "اسمح للمستخدمين المنتظمين بالعثور على المستخدمين المعلَّقين." + selectable_avatars_mode: "اسمح للمستخدمين بتحديد صورة رمزية من قائمة selectable_avatars وقصر عمليات تحميل الصور الرمزية المخصَّصة على مستوى الثقة المحدَّد." selectable_avatars: "قائمة الصور الرمزية التي يمكن للمستخدمين الاختيار منها" allow_all_attachments_for_group_messages: "السماح بجميع مرفقات البريد الإلكتروني لرسائل المجموعات" png_to_jpg_quality: "جودة ملف JPG المحوَّل (1 هي أقل جودة، و99 هي أفضل جودة، و100 للإيقاف)" @@ -1891,8 +1938,8 @@ ar: tl2_requires_read_posts: "عدد المنشورات التي يجب على المستخدم قراءتها قبل الترقية إلى مستوى الثقة 2" tl2_requires_time_spent_mins: "عدد الدقائق التي يجب على المستخدم استغراقها في قراءة المنشورات قبل الترقية إلى مستوى الثقة 2" tl2_requires_days_visited: "عدد الأيام التي يجب على المستخدم زيارة الموقع فيها قبل الترقية إلى مستوى الثقة 2" - tl2_requires_likes_received: "عدد الإعجابات التي يجب أن يتلقاها المستخدم قبل الترقية إلى مستوى الثقة 2" - tl2_requires_likes_given: "عدد الإعجابات التي يجب على المستخدم تسجيلها قبل الترقية إلى مستوى الثقة 2" + tl2_requires_likes_received: "عدد تسجيلات الإعجاب التي يجب أن يتلقاها المستخدم قبل الترقية إلى مستوى الثقة 2" + tl2_requires_likes_given: "عدد تسجيلات الإعجاب التي يجب على المستخدم تسجيلها قبل الترقية إلى مستوى الثقة 2" tl2_requires_topic_reply_count: "عدد الموضوعات التي يجب على المستخدم الرد عليها قبل الترقية إلى مستوى الثقة 2" tl3_time_period: "الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3 (بالأيام)" tl3_requires_days_visited: "الحد الأدنى لعدد الأيام التي يحتاجها المستخدم لزيارة الموقع في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) يوم للتأهُّل للترقية إلى مستوى الثقة 3. اضبطها على فترة زمنية أعلى من الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3 لإيقاف الترقيات إلى مستوى الثقة 3. (0 أو أعلى)" @@ -1905,8 +1952,8 @@ ar: tl3_requires_posts_read_all_time: "الحد الأدنى لإجمالي عدد المنشورات التي يجب على المستخدم قراءتها للتأهل إلى مستوى الثقة 3" tl3_requires_max_flagged: "يجب ألا يكون المستخدم قد تلقى بلاغات من x مستخدم مختلف على أكثر من x منشور في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) يوم للتأهُّل للترقية إلى مستوى الثقة 3، حيث تكون x هي قيمة هذا الإعداد .(0 أو أعلى)" tl3_promotion_min_duration: "الحد الأدنى لعدد الأيام التي تستمر فيها الترقية إلى مستوى الثقة 3 قبل خفض رتبة المستخدم مرة أخرى إلى مستوى الثقة 2" - tl3_requires_likes_given: "الحد الأدنى لعدد الإعجابات التي يجب منحها في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) للتأهُّل للترقية إلى مستوى الثقة 3" - tl3_requires_likes_received: "الحد الأدنى لعدد الإعجابات التي يجب تلقيها في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) يوم للتأهُّل للترقية إلى مستوى الثقة 3" + tl3_requires_likes_given: "الحد الأدنى لعدد تسجيلات الإعجاب التي يجب منحها في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) للتأهُّل للترقية إلى مستوى الثقة 3" + tl3_requires_likes_received: "الحد الأدنى لعدد تسجيلات الإعجاب التي يجب تلقيها في آخر (الفترة الزمنية المطلوبة للوصول إلى مستوى الثقة 3) يوم للتأهُّل للترقية إلى مستوى الثقة 3" tl3_links_no_follow: "عدم إزالة rel=nofollow من الروابط المنشورة بواسطة مستخدمين من مستوى الثقة 3" trusted_users_can_edit_others: "السماح للمستخدمين من مستويات الثقة العالية بتعديل محتوى المستخدمين الآخرين" min_trust_to_create_topic: "الحد الأدنى لمستوى الثقة المطلوب لإنشاء موضوع جديد" @@ -1914,6 +1961,7 @@ ar: min_trust_to_edit_wiki_post: "الحد الأدنى لمستوى الثقة المطلوب لتعديل منشور من نوع Wiki." min_trust_to_edit_post: "الحد الأدنى لمستوى الثقة المطلوب لتعديل المنشورات." min_trust_to_allow_self_wiki: "الحد الأدنى لمستوى الثقة المطلوب لتحويل منشور المستخدم إلى Wiki." + min_trust_to_send_messages: "تم إيقافه، استخدم إعداد 'personal message enabled groups' بدلًا من ذلك. الحد الأدنى لمستوى الثقة المطلوب لإنشاء رسائل شخصية جديدة." min_trust_to_send_email_messages: "الحد الأدنى لمستوى الثقة المطلوب لإرسال رسائل خاصة عبر البريد الإلكتروني" min_trust_to_flag_posts: "الحد الأدنى لمستوى الثقة المطلوب للإبلاغ عن المنشورات" min_trust_to_post_links: "الحد الأدنى لمستوى الثقة المطلوب لتضمين الروابط في المنشورات" @@ -1931,6 +1979,9 @@ ar: max_mentions_per_post: "الحد الأقصى لعدد إشعارات @name التي يمكن لأي شخص استخدامها في المنشور" max_users_notified_per_group_mention: "الحد الأقصى لعدد المستخدمين الذين قد يتلقون إشعارًا إذا تمت الإشارة إلى المجموعة (لن يتم إرسال الإشعارات إذا تم استيفاء الحد الأقصى)" enable_mentions: "السماح للمستخدمين بالإشارة إلى مستخدمين آخرين" + here_mention: "الاسم المستخدم للإشارة باستخدام الرمز @ للسماح للمستخدمين المتميزين بإعلام ما يصل إلى 'max_here_mentioned' من الأشخاص المشاركين في الموضوع. يجب ألا يكون اسم مستخدم موجودًا." + max_here_mentioned: "الحد الأقصى لعدد الأشخاص المُشار إليهم بواسطة @here." + min_trust_level_for_here_mention: "الحد الأدنى لمستوى الثقة المطلوب للإشارة @here." create_thumbnails: "إنشاء صور مصغَّرة وصور مبسَّطة أكبر من أن تلائم المنشور" email_time_window_mins: "الانتظار (n) دقيقة قبل إرسال أي إشعارات عبر البريد الإلكتروني لمنح المستخدمين فرصة لتعديل منشوراتهم والانتهاء منها" personal_email_time_window_seconds: "الانتظار (n) ثانية قبل إرسال أي إشعارات عبر البريد الإلكتروني بالرسائل الخاصة لمنح المستخدمين فرصة لتعديل رسائلهم والانتهاء منها" @@ -1968,19 +2019,20 @@ ar: history_hours_low: "يتم تمييز مؤشر التعديل بشكلٍ طفيف عند تعديل منشور خلال هذا العدد من الساعات." history_hours_medium: "يتم تمييز مؤشر التعديل بشكلٍ متوسط عند تعديل منشور خلال هذا العدد من الساعات." history_hours_high: "يتم تمييز مؤشر التعديل بقوة عند تعديل منشور خلال هذا العدد من الساعات." - topic_post_like_heat_low: "يتم تمييز حقل عدد المنشورات بشكلٍ طفيف بعد تجاوز معدل الإعجابات:المنشور هذه النسبة." - topic_post_like_heat_medium: "يتم تمييز حقل عدد المنشورات بشكلٍ متوسط بعد تجاوز معدل الإعجابات:المنشور هذه النسبة." - topic_post_like_heat_high: "يتم تمييز حقل عدد المنشورات بقوة بعد تجاوز معدل الإعجابات:المنشور هذه النسبة." + topic_post_like_heat_low: "يتم تمييز حقل عدد المنشورات بشكلٍ طفيف بعد تجاوز معدل تسجيلات الإعجاب:المنشور هذه النسبة." + topic_post_like_heat_medium: "يتم تمييز حقل عدد المنشورات بشكلٍ متوسط بعد تجاوز معدل تسجيلات الإعجاب:المنشور هذه النسبة." + topic_post_like_heat_high: "يتم تمييز حقل عدد المنشورات بقوة بعد تجاوز معدل تسجيلات الإعجاب:المنشور هذه النسبة." faq_url: "إذا كان لديك صفحة أسئلة شائعة مستضافة في مكانٍ آخر وتريد استخدامها، فأدخِل عنوان URL لها كاملًا هنا." tos_url: "إذا كان لديك مستند شروط خدمة مستضاف في مكانٍ آخر وتريد استخدامه، فأدخِل عنوان URL له كاملًا هنا." privacy_policy_url: "إذا كان لديك مستند مستضاف في مكان آخر لسياسة الخصوصية وتريد استخدامه، فأدخِل عنوان URL الكامل هنا." log_anonymizer_details: " تحديد ما إذا كان سيتم الاحتفاظ بتفاصيل المستخدم في السجل بعد إخفاء هويته أم لا. ستحتاج إلى إيقاف تشغيل هذا الإعداد عند الامتثال للقانون العام لحماية البيانات (GDPR)." newuser_spam_host_threshold: "عدد المرات التي يمكن فيها لمستخدم جديد نشر رابط للمضيف نفسه ضمن منشورات `newuser_spam_host_threshold` قبل اعتبارها غير مرغوب فيها" allowed_spam_host_domains: "قائمة بالنطاقات المستبعدة من اختبار المضيف غير المرغوب فيه. لن يتم تقييد المستخدمين الجُدد أبدًا من إنشاء منشورات تحتوي على روابط إلى هذه النطاقات." - staff_like_weight: "الأهمية التي يجب منحها لإعجابات فريق العمل (تساوي أهمية الإعجابات من غير فريق العمل 1)" + staff_like_weight: "الأهمية التي يجب منحها لإعجابات فريق العمل (تساوي أهمية تسجيلات الإعجاب من غير فريق العمل 1)" topic_view_duration_hours: "عد مرة عرض الموضوع الجديد مرة واحدة لكل IP/مستخدم كل N ساعة" user_profile_view_duration_hours: "عد عرض الملف الشخصي للمستخدم الجديد مرة واحدة لكل IP/مستخدم كل N ساعة" levenshtein_distance_spammer_emails: "عدد الأحرف المختلفة الذي سيسمح بمطابقة جزئية عند مطابقة الرسائل الإلكترونية غير المرغوب فيها" + max_new_accounts_per_registration_ip: "توقَّف عن قبول عمليات الاشتراك الجديدة من عنوان IP هذا إذا كان هناك بالفعل (n) حساب من مستوى الثقة 0 منه (ولم يكن لفريق العمل أو من المستوى الثقة 2 أو أعلى). اضبط القيمة على 0 لإيقاف الحد." min_ban_entries_for_roll_up: "عند النقر على الزر \"تجميع\"، سيتم إنشاء إدخال حظر جديد في الشبكة الفرعية إذا كان هناك (N) من الإدخالات على الأقل." max_age_unmatched_emails: "حذف إدخالات البريد الإلكتروني الخاضعة للمراقبة غير المتطابقة بعد (N) يوم" max_age_unmatched_ips: "حذف إدخالات عناوين IP الخاضعة للمراقبة غير المتطابقة بعد (N) يوم" @@ -2014,6 +2066,7 @@ ar: max_emails_per_day_per_user: "الحد الأقصى لعدد الرسائل الإلكترونية التي سيتم إرسالها إلى المستخدمين يوميًا. 0 لإيقاف الحد" enable_staged_users: "إنشاء مستخدمين مؤقتين تلقائيًا عند معالجة الرسائل الإلكترونية الواردة" maximum_staged_users_per_email: "الحد الأقصى لعدد المستخدمين المؤقتين الذين تم إنشاؤهم في أثناء معالجة رسالة إلكترونية واردة" + maximum_recipients_per_new_group_email: "حجب الرسائل الواردة التي تتضمَّن عددًا كبيرًا من المستلمين." auto_generated_allowlist: "قائمة عناوين البريد الإلكتروني التي لن يتم التحقُّق منها للمحتوى الذي تم إنشاؤه تلقائيًا. مثال: foo@bar.com|discourse@bar.com" block_auto_generated_emails: "حظر الرسائل الإلكترونية الواردة التي تم تحديدها على أنها منشأة تلقائيًا." ignore_by_title: "تجاهل الرسائل الإلكترونية الواردة بناءً على عنوانها" @@ -2033,6 +2086,7 @@ ar: raw_email_max_length: "عدد الأحرف التي يجب تخزينها للرسائل الإلكترونية الواردة" raw_rejected_email_max_length: "عدد الأحرف التي يجب تخزينها للرسائل الإلكترونية الواردة التي تم رفضها" delete_rejected_email_after_days: "حذف الرسائل الإلكترونية المرفوضة التي مضى عليها أكثر من (n) يوم" + require_change_email_confirmation: "مطالبة المستخدمين من خارج طاقم العمل بتأكيد عنوان بريدهم الإلكتروني القديم قبل تغييره. لا ينطبق ذلك على المستخدمين من طاقم العمل؛ لذا فإنهم بحاجة دائمًا إلى تأكيد عنوان بريدهم الإلكتروني القديم." manual_polling_enabled: "إرسال رسائل إلكترونية فورية باستخدام API لردود البريد الإلكتروني" pop3_polling_enabled: "استقصاء تلقي ردود البريد الإلكتروني عبر POP3" pop3_polling_ssl: "استخدام SSL أثناء الاتصال بخادم POP3 (موصى به)" @@ -2111,11 +2165,14 @@ ar: enable_mobile_theme: "تستخدم الأجهزة الجوَّالة سمة متوافقة مع الجوَّال، مع إمكانية التبديل إلى الموقع الكامل. يمكنك إيقاف هذا إذا كنت تريد استخدام ورقة أنماط مخصَّصة تستجيب بشكلٍ كامل." dominating_topic_minimum_percent: "ما النسبة المئوية للمنشورات التي يجب على المستخدم إنشاؤها في الموضوع قبل أن يتم تذكيره بالسيطرة الزائدة على الموضوع." disable_avatar_education_message: "إيقاف الرسالة التعليمية لتغيير الصورة الرمزية" + pm_warn_user_last_seen_months_ago: "تحذير المستخدمين، عند إنشاء رسالة شخصية، عندما يكون المستلم المستهدف لم يظهر منذ أكثر من n من الأشهر." suppress_uncategorized_badge: "عدم إظهار الشارة للموضوعات غير المصنَّفة في قوائم الموضوعات" header_dropdown_category_count: "عدد الفئات التي يمكن عرضها في القائمة المنسدلة للرأس" permalink_normalizations: "تطبيق التعبير العادي التالي قبل مطابقة الروابط الثابتة، على سبيل المثال: سيؤدي استخدام /(topic.*)\\?.*/\\1 إلى إزالة سلاسل الاستعلام من مسارات الموضوع. التنسيق هو regex+string. استخدم \\1 وما إلى ذلك للوصول إلى الالتقاطات." - global_notice: "عرض بانر عام عاجل وطارئ وغير قابل للتجاهل لجميع الزوار. قم بالتغيير إلى قيمة فارغة لإخفائه (مسموح باستخدام HTML)." + global_notice: "عرض بانر عام عاجل وطارئ وغير قابل للتجاهل لجميع الزائرين. قم بالتغيير إلى قيمة فارغة لإخفائه (مسموح باستخدام HTML)." disable_system_edit_notifications: "إيقاف إشعارات التعديل بواسطة مستخدم النظام عندما يكون \"download_remote_images_to_local\" نشطًا." + disable_category_edit_notifications: "إيقاف إشعارات تعديل الفئة في الموضوعات." + disable_tags_edit_notifications: "إيقاف إشعارات تعديل الوسوم في الموضوعات." notification_consolidation_threshold: "عدد إشعارات الإعجاب أو طلبات العضوية المتلقاة قبل دمج الإشعارات في رسالة واحدة. اضبط القيمة على 0 للإيقاف." likes_notification_consolidation_window_mins: "المدة بالدقائق التي يتم فيها دمج إشعارات الإعجاب في إشعار واحد بمجرد الوصول إلى الحد الأقصى. يمكن إعداد الحد الأقصى عبر `SiteSetting.notification_consolidation_threshold`." automatically_unpin_topics: "إلغاء تثبيت الموضوعات تلقائيًا عندما يصل المستخدم إلى النهاية" @@ -2135,9 +2192,11 @@ ar: display_name_on_posts: "إظهار الاسم الكامل للمستخدم في منشوراته بالإضافة إلى اسم المستخدم الخاص به @username" show_time_gap_days: "عرض الفجوة الزمنية في الموضوع إذا تم إنشاء منشورين بفارق هذا العدد من الأيام" short_progress_text_threshold: "بعد أن يتجاوز عدد المنشورات في الموضوع هذا الرقم، سيعرض شريط التقدُّم رقم المنشور الحالي فقط. إذا غيَّرت عرض شريط التقدُّم، فقد تحتاج إلى تغيير هذه القيمة." + default_code_lang: "تمييز جملة لغة البرمجة الافتراضية المطبَّق على كتل الرموز البرمجية (auto، nohighlight، ruby، python، إلى آخره). يجب أن تكون القيمة حاضرة أيضًا في إعداد الموقع `highlighted languages`." warn_reviving_old_topic_age: "سيتم عرض تحذير عندما يبدأ شخص ما في الرد على موضوع يكون فيه الرد الأخير أقدم من هذا العدد من الأيام. يمكنك إيقاف هذا الإعداد عن طريق الضبط على 0." autohighlight_all_code: "فرض تمييز التعليمات البرمجية على جميع كتل التعليمات البرمجية مسبقة التنسيق حتى في حال عدم تحديد اللغة بشكلٍ صريح." highlighted_languages: "القواعد المضمَّنة لتمييز البنية. (تحذير: قد يؤثر تضمين عدد كبير جدًا من اللغات على الأداء) راجع: https://highlightjs.org/static/demo للحصول على عرض توضيحي" + show_copy_button_on_codeblocks: "إضافة زر إلى كتل الرموز البرمجية لنسخ محتويات الكتل إلى حافظة المستخدم." embed_any_origin: "السماح بالمحتوى القابل للتضمين بغض النظر عن المصدر. هذا الإعداد مطلوب لتطبيقات الأجهزة الجوَّالة ذات نموذج HTML ثابت." embed_topics_list: "دعم تضمين HTML لقوائم الموضوعات" embed_set_canonical_url: "ضبط عنوان URL الأساسي للموضوعات المضمَّنة على عنوان URL للمحتوى المضمَّن" @@ -2154,6 +2213,12 @@ ar: delete_merged_stub_topics_after_days: "عدد الأيام التي يجب انتظارها قبل حذف الموضوعات البديلة المدمجة بالكامل تلقائيًا. اضبط القيمة على 0 لعدم حذف الموضوعات البديلة أبدًا." bootstrap_mode_min_users: "الحد الأدنى لعدد المستخدمين المطلوب لإيقاف وضع التمهيد (اضبط القيمة على 0 للإيقاف)" prevent_anons_from_downloading_files: "منع المستخدمين المجهولين من تنزيل المرفقات" + secure_media: 'تم إيقافه: استخدم الإعداد secure_uploads بدلًا من ذلك، ستتم إزالته في Discourse 3.0.' + secure_uploads: 'يقيِّد الوصول إلى كل التحميلات (الصور ومقاطع الفيديو والمقاطع الصوتية والنصوص وملفات PDF وملفات ZIP وغير ذلك). بخلاف ذلك، سيكون الوصول مقيَّدًا فقط لتحميلات الوسائط في الرسائل والفئات الخاصة. تحذير: هذا الإعداد معقَّد ويتطلب فهمًا إداريًا عميقًا. انظر موضوع التحميلات الآمنة على Meta لمعرفة التفاصيل.' + secure_media_allow_embed_images_in_emails: "تم إيقافه: استخدام secure_uploads_allow_embed_images_in_emails، ستتم إزالته في Discourse 3.0." + secure_uploads_allow_embed_images_in_emails: "يسمح بتضمين الصور الآمنة التي عادةً ما يتم تنقيحها في الرسائل الإلكترونية، إذا كان حجمها أصغر من الإعداد `secure uploads max email embed image size kb`." + secure_media_max_email_embed_image_size_kb: "تم إيقافه: استخدام secure_uploads_max_email_embed_image_size_kb، ستتم إزالته في Discourse 3.0." + secure_uploads_max_email_embed_image_size_kb: "اقتطاع حجم الصور الآمنة التي سيتم تضمينها في الرسائل الإلكترونية إذا تم تفعيل الإعداد `secure uploads allow embed in emails`. سيكون هذا الإعداد بلا أي تأثير دون تفعيله." slug_generation_method: "اختيار طريقة إنشاء المسار. سيُنشئ الخيار \"ترميز\" سلسلة ترميز النسبة المئوية، بينما سيوقف الخيار \"لا يوجد\" المسار تمامًا." enable_emoji: "تفعيل الرموز التعبيرية" enable_emoji_shortcuts: "سيتم تحويل نصوص الوجوه المبتسمة الشائعة مثل :) p: :( إلى رموز تعبيرية" @@ -2172,6 +2237,7 @@ ar: max_allowed_message_recipients: "الحد الأقصى المسموح به من المستلمين في الرسالة" watched_words_regular_expressions: "الكلمات المُراقَبة هي تعبيرات عادية" enable_diffhtml_preview: "الميزة التجريبية التي تستخدم diffHTML لمزامنة المعاينة بدلًا من إعادة العرض بالكامل" + enable_fast_edit: "يفعِّل تحديد جزء صغير من نص المنشور ليتم تعديله مباشرةً." old_post_notice_days: "عدد الأيام قبل أن يصبح الإشعار بشأن المنشور قديمًا" new_user_notice_tl: "الحد الأدنى لمستوى الثقة المطلوب لرؤية الإشعارات بشأن منشور مستخدم جديد" returning_user_notice_tl: "الحد الأدنى من مستوى الثقة المطلوب لرؤية الإشعارات بشأن منشور مستخدم عائد" @@ -2180,7 +2246,7 @@ ar: blur_tl0_flagged_posts_media: "تمويه صور المنشورات التي تم الإبلاغ عنها لإخفاء المحتوى المحتمل أن يكون غير آمن للعمل" enable_page_publishing: "السماح لفريق العمل بنشر الموضوعات إلى عناوين URL الجديدة بنمطهم الخاص" show_published_pages_login_required: "يمكن للمستخدمين المجهولين رؤية الصفحات المنشورة، حتى عندما يكون تسجيل الدخول مطلوبًا." - skip_auto_delete_reply_likes: "تخطي حذف المنشورات التي تحتوي على هذا العدد من الإعجابات أو أكثر عند حذف الردود القديمة تلقائيًا" + skip_auto_delete_reply_likes: "تخطي حذف المنشورات التي تحتوي على هذا العدد من تسجيلات الإعجاب أو أكثر عند حذف الردود القديمة تلقائيًا" default_email_digest_frequency: "عدد المرات التي يتلقى فيها المستخدمون رسائل إلكترونية تلخيصية بشكلٍ افتراضي" default_include_tl0_in_digests: "تضمين المنشورات من المستخدمين الجُدد في الرسائل الإلكترونية التلخيصية بشكلٍ افتراضي. يمكن للمستخدمين تغيير ذلك في تفضيلاتهم." default_email_level: "ضبط المستوى الافتراضي لإرسال الإشعارات عبر البريد الإلكتروني للموضوعات العادية" @@ -2198,7 +2264,7 @@ ar: default_other_enable_defer: "تفعيل وظيفة تأجيل الموضوع بشكلٍ افتراضي" default_other_dynamic_favicon: "إظهار عدد الموضوعات الجديدة/المحدَّثة على أيقونة المتصفح بشكلٍ افتراضي" default_other_skip_new_user_tips: "تخطي نصائح وشارات إعداد المستخدم الجديد" - default_other_like_notification_frequency: "إرسال إشعار إلى المستخدمين بالإعجابات بشكلٍ افتراضي" + default_other_like_notification_frequency: "إرسال إشعار إلى المستخدمين بتسجيلات الإعجاب بشكلٍ افتراضي" default_topics_automatic_unpin: "إلغاء تثبيت الموضوعات تلقائيًا عندما يصل المستخدم إلى النهاية بشكلٍ افتراضي" default_categories_watching: "قائمة الفئات المُراقَبة بشكلٍ افتراضي" default_categories_tracking: "قائمة الفئات التي يتم تتبُّعها بشكلٍ افتراضي" @@ -2230,6 +2296,7 @@ ar: tags_sort_alphabetically: "عرض الوسوم بترتيب أبجدي. الإعداد الافتراضي هو العرض بترتيب الأكثر رواجًا." tags_listed_by_group: "إدراج الوسوم حسب مجموعة الوسوم الموجودة في صفحة الوسوم" tag_style: "النمط المرئي لشارات الوسوم" + pm_tags_allowed_for_groups: "السماح لأعضاء المجموعة (المجموعات) المضمَّنة بوسم أي رسالة شخصية" min_trust_level_to_tag_topics: "الحد الأدنى لمستوى الثقة المطلوب لوضع وسوم على الموضوعات" suppress_overlapping_tags_in_list: "عدم عرض الوسم إذا كانت الوسوم تتطابق تمامًا مع الكلمات الموجودة في عناوين الموضوعات" remove_muted_tags_from_latest: "عدم عرض الموضوعات التي تم وضع وسم الكتم فقط عليها في قائمة الموضوعات الحديثة" @@ -2243,6 +2310,10 @@ ar: push_notifications_icon: "رمز الشارة الذي يظهر في ركن الإشعارات. يوصى باستخدام صورة PNG أحادية اللون بمقاس 96 × 96 وخلفية شفافة." base_font: "الخط الأساسي الذي سيتم استخدامه لمعظم النصوص على الموقع. يمكن تجاوز السمات عبر خاصية CSS المخصَّصة `--font-family`." heading_font: "الخط الذي سيتم استخدامه للعناوين على الموقع. يمكن تجاوز السمات عبر خاصية CSS المخصَّصة `--heading-font-family`." + enable_sitemap: "إنشاء خريطة موقع لموقعك وتضمينها في ملف robots.txt." + sitemap_page_size: "عدد عناوين URL المراد تضمينها في كل صفحة من خريطة الموقع. الحد الأقصى: 50.000" + enable_user_status: "(تجريبي) السماح للمستخدمين بتحديد رسالة حالة مخصَّصة (رمز تعبيري + وصف)." + enable_onboarding_popups: "(تجريبي) تفعيل النوافذ المنبثقة التعليمية التي تصف الميزات الرئيسية للمستخدمين" short_title: "سيتم استخدام العنوان القصير على الشاشة الرئيسية للمستخدم، أو المشغِّل، أو الأماكن الأخرى التي قد تكون المساحة فيها محدودة. يجب أن يقتصر على 12 حرفًا." dashboard_hidden_reports: "السماح بإخفاء التقارير المحدَّدة من لوحة المعلومات" dashboard_visible_tabs: "اختيار علامات التبويب المرئية في لوحة المعلومات" @@ -2254,10 +2325,18 @@ ar: share_quote_visibility: "تحديد وقت إظهار أزرار مشاركة الاقتباسات: \"أبدًا\" للمستخدمين المجهولين فقط أو لجميع المستخدمين " create_revision_on_bulk_topic_moves: "إنشاء مراجعة للمنشورات الأولى عند نقل الموضوعات إلى فئة جديدة بشكلٍ مجمَّع" allow_changing_staged_user_tracking: "السماح بتغيير تفضيلات إشعارات الفئة والوسم لمستخدم مؤقت بواسطة مستخدم مسؤول." + use_email_for_username_and_name_suggestions: "استخدام الجزء الأول من عناوين البريد الإلكتروني للحصول على اقتراحات لاسم المستخدم والاسم. لاحظ أن هذا يسهِّل على الجمهور تخمين عناوين البريد الإلكتروني الكاملة للمستخدمين (لأن نسبة كبيرة من الأشخاص يشاركون خدمات مشتركة مثل `gmail.com`)." + use_name_for_username_suggestions: "استخدام الاسم الكامل للمستخدم عند اقتراح أسماء المستخدمين." + suggest_weekends_in_date_pickers: "تضمين عطلات نهاية الأسبوع (السبت والأحد) في اقتراحات منتقي التاريخ (يمكنك إيقاف هذا الإعداد إذا كنت تستخدم Discouse في أيام الأسبوع فقط؛ أي من الاثنين إلى الجمعة)." + splash_screen: "يعرض شاشة تحميل مؤقتة أثناء تحميل أصول الموقع" + default_sidebar_categories: "سيتم عرض الفئات المحدَّدة ضمن قسم فئات الشريط الجانبي بشكلٍ افتراضي." + default_sidebar_tags: "سيتم عرض الوسوم المحدَّدة ضمن قسم فئات الشريط الجانبي بشكلٍ افتراضي." + enable_new_user_profile_nav_groups: "تجريبي: سيتم عرض قائمة التنقل الخاصة بملف تعريف المستخدم الجديد لمستخدمي المجموعات المحدَّدة" errors: invalid_css_color: "لون غير صالح. أدخِل اسم لون أو قيمة سداسية عشرية." invalid_email: "عنوان البريد الإلكتروني غير صالح." invalid_username: "لا يوجد مستخدم باسم المستخدم هذا." + valid_username: "يوجد مستخدم باسم المستخدم هذا." invalid_group: "لا توجد مجموعة بهذا الاسم." invalid_integer_min_max: "يجب أن تكون القيمة بين %{min} و%{max}." invalid_integer_min: "يجب أن تكون القيمة %{min} أو أكبر." @@ -2272,6 +2351,7 @@ ar: invalid_json: "ملف JSON غير صالح." invalid_reply_by_email_address: "يجب أن تحتوي القيمة على \"%{reply_key}\" وأن تكون مختلفة عن الرسالة الإلكترونية للإشعار." invalid_alternative_reply_by_email_addresses: "يجب أن تحتوي جميع القيم على \"%{reply_key}\" وأن تكون مختلفة عن الرسالة الإلكترونية للإشعار." + invalid_domain_hostname: "يجب ألا يتضمَّن الرمز * أو ؟." pop3_polling_host_is_empty: "يجب عليك ضبط `pop3 polling host` قبل تفعيل استقصاء POP3." pop3_polling_username_is_empty: "يجب عليك ضبط `pop3 polling username` قبل تفعيل استقصاء POP3." pop3_polling_password_is_empty: "يجب عليك ضبط `pop3 polling password` قبل تفعيل استقصاء POP3." @@ -2279,7 +2359,9 @@ ar: reply_by_email_address_is_empty: "يجب عليك ضبط `reply by email address` قبل تفعيل الرد عن طريق البريد الإلكتروني." email_polling_disabled: "يجب عليك ضبط الاستقصاء اليدوي أو من خلال POP3 قبل تفعيل الرد عن طريق البريد الإلكتروني." user_locale_not_enabled: "يجب عليك تفعيل `allow user locale` أولًا قبل تفعيل هذا الإعداد." + personal_message_enabled_groups_invalid: "يجب عليك تحديد مجموعة واحدة على الأقل لهذا الإعداد. إذا كنت لا تريد أن يقوم أي شخص باستثناء فريق العمل بإرسال الرسائل الشخصية، فاختر مجموعة فريق العمل." invalid_regex: "التعبير العادي غير صالح أو غير مسموح به." + invalid_regex_with_message: "يحتوي التعبير العادي '%{regex}' على خطأ: %{message}" email_editable_enabled: "يجب إيقاف `email editable` قبل تفعيل هذا الإعداد." staged_users_disabled: "يجب عليك تفعيل `staged users` أولًا قبل تفعيل هذا الإعداد." reply_by_email_disabled: "يجب عليك تفعيل `reply by email` أولًا قبل تفعيل هذا الإعداد." @@ -2300,6 +2382,12 @@ ar: leading_trailing_slash: "يجب ألا يبدأ التعبير العادي بشرطة مائلة وينتهي بها." unicode_usernames_avatars: "لا تدعم الصور الرمزية الداخلية للنظام أسماء المستخدمين بترميز Unicode." list_value_count: "يجب أن تحتوي القائمة على قيم %{count} بالضبط." + markdown_linkify_tlds: "لا يمكنك تضمين قيمة '*'." + google_oauth2_hd_groups: "يجب عليك ضبط جميع إعدادات 'google oauth2 hd' قبل تفعيل هذا الإعداد." + search_tokenize_chinese_enabled: "يجب عليك إيقاف `email editable` قبل تفعيل هذا الإعداد." + search_tokenize_japanese_enabled: "يجب عليك إيقاف 'search_tokenize_japanese' قبل تفعيل هذا الإعداد." + discourse_connect_cannot_be_enabled_if_second_factor_enforced: "لا يمكنك تفعيل DiscourseConnect في حال فرض المصادقة الثنائية." + delete_rejected_email_after_days: "لا يمكن ضبط هذا الإعداد على أقل من الإعداد delete_email_logs_after_days أو أكثر من %{max}" placeholder: discourse_connect_provider_secrets: key: "www.example.com" @@ -2537,6 +2625,15 @@ ar: second_factor_toggle: totp: "استخدام تطبيق المصادقة أو مفتاح الأمان بدلًا من ذلك" backup_code: "استخدام رمز احتياطي بدلًا من ذلك" + second_factor_auth: + challenge_not_found: "تعذَّر العثور على اختبار المصادقة الثنائية في جلستك الحالية." + challenge_expired: "مرَّ وقت طويل منذ تقديم اختبار المصادقة الثنائية ولم يعُد صالحًا. حاول مرة أخرى." + challenge_not_completed: "لم تكمل اختبار المصادقة الثنائية لاتخاذ هذا الإجراء. يُرجى إكمال اختبار المصادقة الثنائية وإعادة المحاولة." + actions: + grant_admin: + description: "لتدابير الأمان الإضافية، تحتاج إلى تأكيد المصادقة الثنائية قبل منح %{username} وصول المسؤول." + discourse_connect_provider: + description: "لقد طلب %{hostname} تأكيد المصادقة الثنائية. ستتم إعادة توجيهك مرة أخرى إلى الموقع بمجرد تأكيد المصادقة الثنائية." admin: email: sent_test: "تم الإرسال!" @@ -2544,7 +2641,7 @@ ar: merge_user: updating_username: "جارٍ تحديث اسم المستخدم..." changing_post_ownership: "جارٍ تغيير ملكية المنشور..." - merging_given_daily_likes: "جارٍ دمج الإعجابات اليومية الممنوحة..." + merging_given_daily_likes: "جارٍ دمج تسجيلات الإعجاب اليومية الممنوحة..." merging_post_timings: "جارٍ دمج توقيتات النشر..." merging_user_visits: "جارٍ دمج زيارات المستخدمين..." updating_site_settings: "جارٍ تحديث إعدادات الموقع..." @@ -2706,6 +2803,21 @@ ar: test_mailer: title: "رسالة الاختبار" subject_template: "[%{email_prefix}] اختبار تسليم الرسالة الإلكترونية" + text_body_template: | + هذه رسالة بريد إلكتروني تجريبية من + + [**%{base_url}**][0] + + نأمل أن تكون قد تلقيت اختبار تسليم البريد الإلكتروني هذا! + + فيما يلي [قائمة تحقق مفيدة للتحقق من تكوين تسليم البريد الإلكتروني][1]. + + حظًا سعيدًا، + + أصدقاؤك في [Discourse](https://www.discourse.org) + + [0]: %{base_url} + [1]: https://meta.discourse.org/t/email-delivery-configuration-checklist/209839 new_version_mailer: title: "رسالة الإصدار الجديد" subject_template: "[%{email_prefix}] إصدار جديد من Discourse، يتوفَّر تحديث" @@ -2719,6 +2831,11 @@ ar: inappropriate: "تم الإبلاغ عن منشورك على أنه **غير لائق**: يشعر المجتمع بأنه مسيئ أو ينتهك [إرشادات مجتمعنا](%{base_path}/guidelines)." spam: "تم الإبلاغ عن منشورك على أنه **غير مرغوب فيه**: يشعر المجتمع بأنه إعلان، أو ذو طبيعة ترويجية بشكلٍ زائد بدلًا من أن يكون مفيدًا أو ذا صلة بالموضوع كما هو متوقَّع." notify_moderators: "تم الإبلاغ عن منشورك على أنه **يستدعي انتباه المشرفين**: يشعر المجتمع بأن المنشور يتطلب تدخلًا يدويًا بواسطة أحد أعضاء فريق العمل." + responder: + off_topic: "تم الإبلاغ عن المنشور على أنه **خارج الموضوع**: يشعر المجتمع بأنه غير مناسب للموضوع، كما هو محدَّد حاليًا في العنوان وأول منشور." + inappropriate: "تم الإبلاغ عن المنشور على أنه **غير لائق**: يشعر المجتمع بأنه مسيئ أو ينتهك [إرشادات مجتمعنا](%{base_path}/guidelines)." + spam: "تم الإبلاغ عن المنشور على أنه **غير مرغوب فيه**: يشعر المجتمع بأنه إعلان، أو ذو طبيعة ترويجية بشكلٍ زائد بدلًا من أن يكون مفيدًا أو ذا صلة بالموضوع كما هو متوقَّع." + notify_moderators: "تم الإبلاغ عن المنشور على أنه **يستدعي انتباه المشرفين**: يشعر المجتمع بأن المنشور يتطلب تدخلًا يدويًا بواسطة أحد أعضاء فريق العمل." flags_dispositions: agreed: "نشكرك على إعلامنا. نتفق على وجود مشكلة ونبحث في الأمر." agreed_and_deleted: "نشكرك على إعلامنا. نتفق على وجود مشكلة وقد أزلنا المنشور." @@ -2733,6 +2850,15 @@ ar: many: "تم إغلاق هذا الموضوع مؤقتًا لمدة %{count} ساعة على الأقل بسبب وجود عدد كبير من بلاغات المجتمع." other: "تم إغلاق هذا الموضوع مؤقتًا لمدة %{count} ساعة على الأقل بسبب وجود عدد كبير من بلاغات المجتمع." system_messages: + reviewables_reminder: + subject_template: "هناك عناصر في قائمة انتظار المراجعة بحاجة إلى المراجعة" + text_body_template: + zero: "%{mentions} من العناصر تم إرسالها منذ أكثر من %{count} ساعة. [يُرجى مراجعتها](%{base_url}/review)." + one: "%{mentions} من العناصر تم إرسالها منذ أكثر من ساعة واحدة (%{count}). [يُرجى مراجعتها](%{base_url}/review)." + two: "%{mentions} من العناصر تم إرسالها منذ أكثر من ساعتين (%{count}). [يُرجى مراجعتها](%{base_url}/review)." + few: "%{mentions} من العناصر تم إرسالها منذ أكثر من %{count} ساعات. [يُرجى مراجعتها](%{base_url}/review)." + many: "%{mentions} من العناصر تم إرسالها منذ أكثر من %{count} ساعة. [يُرجى مراجعتها](%{base_url}/review)." + other: "%{mentions} من العناصر تم إرسالها منذ أكثر من %{count} ساعة. [يُرجى مراجعتها](%{base_url}/review)." private_topic_title: "الموضوع #%{id}" contents_hidden: "يُرجى زيارة هذا المنشور لرؤية محتوياته." post_hidden: @@ -2812,6 +2938,28 @@ ar: ``` يُرجى مراجعة [إرشادات المجتمع](%{base_url}/guidelines) لمعرفة التفاصيل. + flags_agreed_and_post_deleted_for_responders: + title: "تمت إزالة الرد من المنشور الذي تم الإبلاغ عنه من قِبل فريق العمل" + subject_template: "تمت إزالة الرد من المنشور الذي تم الإبلاغ عنه من قِبل فريق العمل" + text_body_template: | + مرحبًا، + + هذه رسالة تلقائية من %{site_name} لإعلامك بأنه قد تمت إزالة [المنشور](%{base_url}%{url}) الذي رددت عليه. + + %{flag_reason} + + تم الإبلاغ عن هذا المنشور من قِبل المجتمع وقرَّر أحد أعضاء فريق العمل إزالته. + + ``` markdown + %{flagged_post_raw_content} + ``` + الذي رددت عليه + + ``` markdown + %{flagged_post_response_raw_content} + ``` + + يُرجى مراجعة [إرشادات المجتمع](%{base_url}/guidelines) للمزيد من التفاصيل بشأن سبب الإزالة. usage_tips: text_body_template: | للحصول على بعض النصائح السريعة بشأن البدء كمستخدم جديد، [اطَّلع على هذه منشور المدونة](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). @@ -2863,24 +3011,72 @@ ar: [التفضيلات]: %{user_preferences_url} tl2_promotion_message: subject_template: "تهانينا على ترقية مستوى الثقة!" + text_body_template: | + تمت ترقيتك بمقدار [مستوى ثقة واحد!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)! + + يشير حصولك على مستوى الثقة 2 إلى أنك قد قرأت وشاركت بالدرجة الكافية ليتم اعتبارك عضوًا في هذا المجتمع. + + بصفتك مستخدمًا متمرسًا، فقد تنال إعجابك [هذه القائمة من النصائح المفيدة](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). + + ندعوك إلى الاستمرار في المشاركة - نحن نستمتع بوجودك. backup_succeeded: title: "نجح النسخ الاحتياطي" subject_template: "اكتمل النسخ الاحتياطي بنجاح" + text_body_template: | + تم عمل النسخ الاحتياطي بنجاح. + + انتقل إلى [المسؤول > قسم النسخ الاحتياطي](%{base_url}/admin/backups) لتنزيل النسخة الاحتياطية الجديدة. + + إليك السجل: + + %{logs} backup_failed: title: "فشل النسخ الاحتياطي" subject_template: "فشل النسخ الاحتياطي" + text_body_template: | + فشل النسخ الاحتياطي. + + إليك السجل: + + %{logs} restore_succeeded: title: "نجحت الاستعادة" subject_template: "اكتملت الاستعادة بنجاح" + text_body_template: | + تمت الاستعادة بنجاح. + + إليك السجل: + + %{logs} restore_failed: title: "فشلت الاستعادة" subject_template: "فشلت الاستعادة" + text_body_template: | + فشلت الاستعادة. + + إليك السجل: + + %{logs} bulk_invite_succeeded: title: "نجحت الدعوة الجماعية" subject_template: "تمت معالجة الدعوة الجماعية للمستخدمين بنجاح" + text_body_template: | + تمت معالجة ملف الدعوة الجماعية للمستخدمين، وتم إرسال %{sent} من الدعوات بالبريد، وتخطي %{skipped}، وظهر %{warnings} من التحذيرات. + + ``` text + %{logs} + ``` bulk_invite_failed: title: "فشلت الدعوة الجماعية" subject_template: "تمت معالجة الدعوة الجماعية للمستخدمين مع وجود أخطاء" + text_body_template: | + تمت معالجة ملف الدعوة الجماعية للمستخدمين، وإرسال %{sent} من الدعوات بالبريد، وتخطي %{skipped}، وظهر %{warnings} من التحذيرات و%{failed} من الأخطاء. + + إليك السجل: + + ``` text + %{logs} + ``` user_added_to_group_as_owner: title: "تمت إضافتك إلى المجموعة كمالك" subject_template: "تمت إضافتك كمالك إلى المجموعة %{group_name}" @@ -3093,6 +3289,15 @@ ar: عذرًا، لكن رسالتك الإلكترونية إلى %{destination} (بعنوان %{former_title}) لم تنجح. لقد رددت على رسالة إلكترونية تلخيصية، إذا كنت تعتقد أن هذا خطأ، [فتواصل مع أحد أعضاء فريق العمل](%{base_url}/about). + email_reject_too_many_recipients: + title: "تم رفض الرسالة الإلكترونية بسبب كثيرة عدد المستلمين" + subject_template: "[%{email_prefix}] مشكلة في الرسالة الإلكترونية -- عدد المستلمين كبير جدًا" + text_body_template: | + عذرًا، لكن رسالتك الإلكترونية إلى %{destination} (بعنوان %{former_title}) لم تنجح. + + لقد حاولت مراسلة أكثر من %{max_recipients_count} من الأشخاص عبر البريد الإلكتروني، ووضع نظامنا علامة على رسالتك الإلكترونية على أنها بريد غير مرغوب فيه. + + إذا كنت تعتقد أن هذا خطأ، [فتواصل مع عضو في فريق العمل](%{base_url}/about). email_error_notification: title: "إشعار بخطأ في الرسالة الإلكترونية" subject_template: "مشكلة في الرسالة الإلكترونية [%{email_prefix}] - خطأ في مصادقة POP" @@ -3251,6 +3456,7 @@ ar: subject_re: "بخصوص: " subject_pm: "[PM] " email_from: "%{user_name} عبر %{site_name}" + email_from_without_site: "%{group_name}" user_notifications: previous_discussion: "الردود السابقة" reached_limit: @@ -3273,6 +3479,13 @@ ar: reply_above_line: "## يُرجى كتابة ردك فوق هذا الخط. ##" posted_by: "تم النشر بواسطة %{username} في %{post_date}" pm_participants: "المشاركون: %{participants}" + more_pm_participants: + zero: "%{participants} و%{count} آخر" + one: "%{participants} وواحد (%{count}) آخر" + two: "%{participants} واثنان (%{count}) آخران" + few: "%{participants} و%{count} آخرين" + many: "%{participants} و%{count} آخر" + other: "%{participants} و%{count} آخر" invited_group_to_private_message_body: | دعا المستخدم %{username} المجموعة @%{group_name} إلى رسالة @@ -3429,7 +3642,8 @@ ar: %{respond_instructions} user_group_mentioned_pm: - subject_template: "[%{email_prefix}] [PM] %{topic_title}" + title: "تمت الإشارة إلى مجموعة المستخدم في رسالة خاصة" + subject_template: "[%{email_prefix}] [رسالة شخصية] %{topic_title}" text_body_template: | %{header_instructions} @@ -3439,7 +3653,8 @@ ar: %{respond_instructions} user_group_mentioned_pm_group: - subject_template: "[%{email_prefix}] [PM] %{topic_title}" + title: "تمت الإشارة إلى مجموعة المستخدم في رسالة خاصة" + subject_template: "[%{email_prefix}] [رسالة شخصية] %{topic_title}" text_body_template: | %{header_instructions} @@ -3541,7 +3756,7 @@ ar: new_topics: "الموضوعات الجديدة" unread_notifications: "الإشعارات غير المقروءة" unread_high_priority: "الإشعارات عالية الأولوية غير المقروءة" - liked_received: "الإعجابات المتلقاة" + liked_received: "تسجيلات الإعجاب المتلقاة" new_users: "المستخدمون الجُدد" popular_topics: "الموضوعات الرائجة" follow_topic: "تابع هذا الموضوع" @@ -3707,6 +3922,7 @@ ar: suspicious_login: title: "تنبيه بعملية تسجيل دخول جديدة" subject_template: "[%{site_name}] عملية تسجيل دخول جديدة من %{location}" + text_body_template: "مرحبًا،\n\nلاحظنا عملية تسجيل دخول من جهاز أو موقع لا تستخدمه عادةً. هل كان هذا أنت؟\n\n - الموقع: %{location} (%{client_ip}) \n - المتصفح: %{browser}\n - الجهاز: %{device} - %{os}\n\nإذا كان هذا أنت، رائع! لا يوجد شيء آخر عليك القيام به.\n\nإذا لم يكن هذا أنت، يُرجى [مراجعة جلساتك الحالية](%{base_url}/my/preferences/security) والتفكير في تغيير كلمة مرورك.\n" post_approved: title: "تمت الموافقة على منشورك" subject_template: "[%{site_name}] تمت الموافقة على منشورك" @@ -3748,16 +3964,25 @@ ar: png_to_jpg_conversion_failure_message: "حدث خطأ عند التحويل من PNG إلى JPG." optimize_failure_message: "حدث خطأ في أثناء تحسين الصورة التي تم تحميلها." download_failure: "فشل تنزيل الملف من الموفِّر الخارجي." + size_mismatch_failure: "لم يتطابق حجم الملف الذي تم تحميله على S3 مع الحجم المخصَّص للتحميل الخارجي. %{additional_detail}" + create_multipart_failure: "فشل إنشاء تحميل متعدد الأجزاء في المتجر الخارجي." + abort_multipart_failure: "فشل إنهاء تحميل متعدد الأجزاء في المتجر الخارجي." + complete_multipart_failure: "فشل إكمال تحميل متعدد الأجزاء في المتجر الخارجي." + external_upload_not_found: "لم يتم العثور على التحميل في المتجر الخارجي. %{additional_detail}" checksum_mismatch_failure: "المجموع الاختباري للملف الذي حمَّلته غير متطابق. ربما تغيَّرت محتويات الملف عند التحميل. حاول مرة أخرى." cannot_promote_failure: "يتعذَّر إكمال التحميل، ربما يكون قد اكتمل بالفعل أو فشل مسبقًا." + size_zero_failure: "عذرًا، يبدو أنه حدث خطأ ما؛ فحجم الملف الذي تحاول تحميله هو 0 بايت. حاول مرة أخرى." attachments: too_large: "عذرًا، الملف الذي تحاول تحميله كبير جدًا (الحجم الأقصى هو %{max_size_kb} ك.ب)." + too_large_humanized: "عذرًا، الملف الذي تحاول تحميله كبير جدًا (الحجم الأقصى هو %{max_size})." images: too_large: "عذرًا، الصورة التي تحاول تحميلها كبيرة جدًا (الحجم الأقصى هو %{max_size_kb} ك.ب)، يُرجى تغيير حجمها وإعادة المحاولة." + too_large_humanized: "عذرًا، الصورة التي تحاول تحميلها كبيرة جدًا (الحجم الأقصى هو %{max_size})، يُرجى تغيير حجمها وإعادة المحاولة." larger_than_x_megapixels: "عذرًا، الصورة التي تحاول تحميلها كبيرة جدًا (الحد الأقصى للأبعاد هو %{max_image_megapixels} ميغابكسل)، يُرجى تغيير حجمها وإعادة المحاولة." size_not_found: "عذرًا، لكننا لم نتمكَّن من تحديد حجم الصورة. قد تكون صورتك تالفة؟" placeholders: too_large: "(الصورة أكبر من %{max_size_kb} كيلوبايت)" + too_large_humanized: "(الصورة أكبر من %{max_size})" avatar: missing: "عذرًا، لا يمكننا العثور على أي صورة رمزية مرتبطة بعنوان البريد الإلكتروني هذا. هل يمكنك محاولة تحميلها مرة أخرى؟" flag_reason: @@ -3802,6 +4027,12 @@ ar: dark_rose: "الوردي الداكن" wcag: "WCAG فاتح" wcag_theme_name: "WCAG فاتح" + dracula: "دراكولا" + dracula_theme_name: "دراكولا" + solarized_light: "فاتح شمسي" + solarized_light_theme_name: "فاتح شمسي" + solarized_dark: "داكن شمسي" + solarized_dark_theme_name: "داكن شمسي" wcag_dark: "WCAG داكن" wcag_dark_theme_name: "WCAG داكن" default_theme_name: "افتراضي" @@ -3822,7 +4053,7 @@ ar: عدِّل أول منشور في هذا الموضوع لتغيير محتويات الصفحة %{page_name}. guidelines_topic: title: "الأسئلة الشائعة/الإرشادات" - body: " ## [هذا مكان متحضر للنقاش العام](#civilized)\n\nيُرجى التعامل مع منتدى المناقشة هذا بالاحترام نفسه الذي تتعامل به في الحديقة العامة. نحن أيضًا مورد مجتمعي مشترك — مكان لتبادل المهارات والمعارف والاهتمامات من خلال المحادثة المستمرة.\n\nهذه ليست قواعد صارمة وسريعة. إنها مبادئ توجيهية لمساعدة الحكم الإنساني لمجتمعنا والحفاظ على هذا المكان لطيفًا وودودًا للخطاب العام المتحضر.\n\n\n\n## [تحسين المناقشة](#improve)\n\nساعدنا في جعل هذا مكانًا رائعًا للمناقشة من خلال إضافة شيء إيجابي دائمًا إلى المناقشة، مهما كان صغيرًا. إذا لم تكن متأكدًا من أن منشورك يضيف إلى المحادثة، ففكر فيما تريد قوله وحاول مرة أخرى لاحقًا.\n\nتتمثل إحدى طرق تحسين المناقشة في استكشاف المحادثات الجارية بالفعل. استغرق بعض الوقت في تصفُّح الموضوعات هنا قبل الرد أو البدء في موضوع خاص بك، وستكون لديك فرصة أفضل لمقابلة الآخرين الذين يشاركونك اهتماماتك.\n\nالموضوعات التي تتم مناقشتها هنا تهمنا، ونريدك أن تتصرف كما لو كانت تهمك أيضًا. احترم الموضوعات والأشخاص الذين يناقشونها، حتى لو كنت لا توافق على بعضها.\n\n\n\n## [كُن لطيفًا، حتى عندما لا توافق](#agreeable)\n\nقد ترغب في الرد بمخالفة الرأي. هذا جيد. لكن تذكر أن \"تنتقد الأفكار وليس الأشخاص\". يُرجى تجنُّب:\n\n* التنابز بالألقاب\n* الهجوم الشخصي\n* الرد على نبرة المنشور بدلًا من محتواه الفعلي\n* مخالفة الرأي المتوقعة\n\nبدلًا من ذلك، قدِّم رؤًى عميقة الفكر تعمل على تحسين المحادثة.\n\n\n\n## [مشاركاتك مهمة](#participate)\n\nتحدِّد المحادثات التي لدينا هنا المناخ العام لكل وافد جديد. ساعدنا في التأثير على مستقبل هذا المجتمع من خلال اختيار المشاركة في المناقشات التي تجعل هذا المنتدى مكانًا مثيرًا للاهتمام — وتجنَّب أولئك الذين لا يفعلون ذلك.\n\nيوفِّر Discourse الأدوات التي تتيح للمجتمع تحديد أفضل (وأسوأ) المساهمات بشكلٍ جماعي: الإشارات المرجعية والإعجابات والبلاغات والردود والتعديلات والمراقبة والكتم وما إلى ذلك. استخدم هذه الأدوات لتحسين تجربتك الخاصة وتجربة الآخرين أيضًا.\n\nلنترك مجتمعنا أفضل مما وجدناه.\n\n\n\n## [إذا رأيت مشكلة، فأبلغ عنها](#flag-problems)\n\nيتمتَّع المشرفون بسلطة خاصة؛ فهم مسؤولون عن هذا المنتدى. وكذلك أنت. فبمساعدتك، يمكن أن يصبح المشرفون ميسرين للمجتمع، وليس مجرد عمال نظافة أو شرطة.\n\nعندما ترى سلوكًا سيئًا، لا ترد. يشجع الرد على السلوك السيئ من خلال الاعتراف به ويستهلك طاقتك ويضيع وقت الجميع. _أبلغ عنه فحسب_. إذا تراكم عدد كافٍ من البلاغات، فسيتم اتخاذ الإجراء، إما تلقائيًا أو عن طريق تدخل المشرف.\n\nللحفاظ على مجتمعنا، يحتفظ المشرفون بالحق في إزالة أي محتوى وأي حساب مستخدم لأي سبب في أي وقت. لا يستعرض المشرفون المنشورات الجديدة؛ ولا يتحمَّل المشرفون ومشغِّلو الموقع أي مسؤولية عن أي محتوى ينشره المجتمع.\n\n\n\n## [كُن متحضرًا دائمًا](#be-civil)\n\nلا شيء يفسد المحادثة الصحية مثل الوقاحة:\n\n* كُن متحضرًا. لا تنشر أي شيء يعتبره أي شخص عاقل مسيئًا أو يحض على الكراهية.\n* حافظ على الأدب. لا تنشر أي شيء فاحش أو جنسي صريح.\n* احترم الآخرين. لا تضايق أو تحزن أي شخص، أو تنتحل صفة الأشخاص، أو تكشف عن معلوماتهم الخاصة.\n* احترم منتدانا. لا تنشر محتوى غير مرغوب فيه أو تفسد المنتدى بأي طريقة أخرى.\n\nهذه ليست مصطلحات محدَّدة ذات تعريفات دقيقة — تجنَّب حتى _ظهور_ أي من هذه الأشياء. إذا لم تكن متأكدًا، فاسأل نفسك كيف ستشعر إذا ظهر منشورك في الصفحة الأولى لموقع إخباري رئيسي.\n\nهذا منتدى عام، وتقوم محركات البحث بفهرسة هذه المناقشات. حافظ على اللغة والروابط والصور آمنة للعائلة والأصدقاء.\n\n\n\n## [حافظ على النظام](#keep-tidy)\n\nابذل جهدًا لوضع الأشياء في مكانها الصحيح؛ حتى نتمكن من قضاء المزيد من الوقت في المناقشة وليس التنظيم. وبالتالي:\n\n* لا تبدأ موضوعًا في فئة خاطئة. يُرجى قراءة تعريفات الفئات.\n* لا تنشر الشيء نفسه في موضوعات متعددة.\n* لا تنشر ردودًا دون وجود محتوى.\n* لا تحوِّل موضوعًا عن طريق تغييره في منتصف الطريق.\n* لا توقِّع منشوراتك — معلومات ملفك الشخصي مرفقة بكل منشور. \n\nبدلًا من نشر \"+1\" أو \"أتفق\"، استخدم الزر \"أعجبني\". بدلًا من أخذ موضوع موجود في اتجاه مختلف جذريًا، استخدم \"الرد في موضوع متربط\".\n\n\n\n## [انشر المحتوى الذي أنشأته بنفسك فقط](#stealing)\n\nلا يجوز لك نشر أي محتوى رقمي يخص شخص آخر دون إذن. لا يجوز لك نشر أوصاف أو روابط أو طرق لسرقة الملكية الفكرية لشخص ما (البرامج أو الفيديوهات أو الملفات الصوتية أو الصور) أو لخرق أي قانون آخر.\n\n\n\n## [بدعمٍ منك](#power)\n\nيتم تشغيل هذا الموقع بواسطة [فريق العمل المحلي](%{base_path}/about) والمستخدمين *أمثالك*؛ أي المجتمع. إذا كانت لديك أي أسئلة أخرى بشأن كيفية عمل الأشياء هنا، فافتح موضوعًا جديدًا في [فئة ملاحظات الموقع](%{base_path}/c/site-feedback) ولنناقشها! إذا كانت هناك مشكلة حرجة أو عاجلة لا يمكن معالجتها من خلال موضوع أو علامة وصفية، فتواصل معنا من خلال [صفحة فريق العمل](%{base_path}/about).\n\n\n\n## [شروط الخدمة](#tos)\n\nنعم، الحديث القانوني ممل، ولكن يجب علينا حماية أنفسنا – وبالتبعية حمايتك أنت وبياناتك – ضد الأشخاص غير الودودين. لدينا [شروط الخدمة](%{base_path}/tos) التي تصف سلوكك (وسلوكنا) والحقوق المتعلقة بالمحتوى والخصوصية والقوانين. لاستخدام هذه الخدمة، يجب أن توافق على الالتزام بشروط الخدمة [TOS](%{base_path}/tos).\n" + body: " ## [هذا مكان متحضر للنقاش العام](#civilized)\n\nيُرجى التعامل مع منتدى المناقشة هذا بالاحترام نفسه الذي تتعامل به في الحديقة العامة. نحن أيضًا مورد مجتمعي مشترك — مكان لتبادل المهارات والمعارف والاهتمامات من خلال المحادثة المستمرة.\n\nهذه ليست قواعد صارمة وسريعة. إنها مبادئ توجيهية لمساعدة الحكم الإنساني لمجتمعنا والحفاظ على هذا المكان لطيفًا وودودًا للخطاب العام المتحضر.\n\n\n\n## [تحسين المناقشة](#improve)\n\nساعدنا في جعل هذا مكانًا رائعًا للمناقشة من خلال إضافة شيء إيجابي دائمًا إلى المناقشة، مهما كان صغيرًا. إذا لم تكن متأكدًا من أن منشورك يضيف إلى المحادثة، ففكر فيما تريد قوله وحاول مرة أخرى لاحقًا.\n\nتتمثل إحدى طرق تحسين المناقشة في استكشاف المحادثات الجارية بالفعل. استغرق بعض الوقت في تصفُّح الموضوعات هنا قبل الرد أو البدء في موضوع خاص بك، وستكون لديك فرصة أفضل لمقابلة الآخرين الذين يشاركونك اهتماماتك.\n\nالموضوعات التي تتم مناقشتها هنا تهمنا، ونريدك أن تتصرف كما لو كانت تهمك أيضًا. احترم الموضوعات والأشخاص الذين يناقشونها، حتى لو كنت لا توافق على بعضها.\n\n\n\n## [كُن لطيفًا، حتى عندما لا توافق](#agreeable)\n\nقد ترغب في الرد بمخالفة الرأي. هذا جيد. لكن تذكر أن \"تنتقد الأفكار وليس الأشخاص\". يُرجى تجنُّب:\n\n* التنابز بالألقاب\n* الهجوم الشخصي\n* الرد على نبرة المنشور بدلًا من محتواه الفعلي\n* مخالفة الرأي المتوقعة\n\nبدلًا من ذلك، قدِّم رؤًى عميقة الفكر تعمل على تحسين المحادثة.\n\n\n\n## [مشاركاتك مهمة](#participate)\n\nتحدِّد المحادثات التي لدينا هنا المناخ العام لكل وافد جديد. ساعدنا في التأثير على مستقبل هذا المجتمع من خلال اختيار المشاركة في المناقشات التي تجعل هذا المنتدى مكانًا مثيرًا للاهتمام — وتجنَّب أولئك الذين لا يفعلون ذلك.\n\nيوفِّر Discourse الأدوات التي تتيح للمجتمع تحديد أفضل (وأسوأ) المساهمات بشكلٍ جماعي: الإشارات المرجعية وتسجيلات الإعجاب والبلاغات والردود والتعديلات والمراقبة والكتم وما إلى ذلك. استخدم هذه الأدوات لتحسين تجربتك الخاصة وتجربة الآخرين أيضًا.\n\nلنترك مجتمعنا أفضل مما وجدناه.\n\n\n\n## [إذا رأيت مشكلة، فأبلغ عنها](#flag-problems)\n\nيتمتَّع المشرفون بسلطة خاصة؛ فهم مسؤولون عن هذا المنتدى. وكذلك أنت. فبمساعدتك، يمكن أن يصبح المشرفون ميسرين للمجتمع، وليس مجرد عمال نظافة أو شرطة.\n\nعندما ترى سلوكًا سيئًا، لا ترد. يشجع الرد على السلوك السيئ من خلال الاعتراف به ويستهلك طاقتك ويضيع وقت الجميع. _أبلغ عنه فحسب_. إذا تراكم عدد كافٍ من البلاغات، فسيتم اتخاذ الإجراء، إما تلقائيًا أو عن طريق تدخل المشرف.\n\nللحفاظ على مجتمعنا، يحتفظ المشرفون بالحق في إزالة أي محتوى وأي حساب مستخدم لأي سبب في أي وقت. لا يستعرض المشرفون المنشورات الجديدة؛ ولا يتحمَّل المشرفون ومشغِّلو الموقع أي مسؤولية عن أي محتوى ينشره المجتمع.\n\n\n\n## [كُن متحضرًا دائمًا](#be-civil)\n\nلا شيء يفسد المحادثة الصحية مثل الوقاحة:\n\n* كُن متحضرًا. لا تنشر أي شيء يعتبره أي شخص عاقل مسيئًا أو يحض على الكراهية.\n* حافظ على الأدب. لا تنشر أي شيء فاحش أو جنسي صريح.\n* احترم الآخرين. لا تضايق أو تحزن أي شخص، أو تنتحل صفة الأشخاص، أو تكشف عن معلوماتهم الخاصة.\n* احترم منتدانا. لا تنشر محتوى غير مرغوب فيه أو تفسد المنتدى بأي طريقة أخرى.\n\nهذه ليست مصطلحات محدَّدة ذات تعريفات دقيقة — تجنَّب حتى _ظهور_ أي من هذه الأشياء. إذا لم تكن متأكدًا، فاسأل نفسك كيف ستشعر إذا ظهر منشورك في الصفحة الأولى لموقع إخباري رئيسي.\n\nهذا منتدى عام، وتقوم محركات البحث بفهرسة هذه المناقشات. حافظ على اللغة والروابط والصور آمنة للعائلة والأصدقاء.\n\n\n\n## [حافظ على النظام](#keep-tidy)\n\nابذل جهدًا لوضع الأشياء في مكانها الصحيح؛ حتى نتمكن من قضاء المزيد من الوقت في المناقشة وليس التنظيم. وبالتالي:\n\n* لا تبدأ موضوعًا في فئة خاطئة. يُرجى قراءة تعريفات الفئات.\n* لا تنشر الشيء نفسه في موضوعات متعددة.\n* لا تنشر ردودًا دون وجود محتوى.\n* لا تحوِّل موضوعًا عن طريق تغييره في منتصف الطريق.\n* لا توقِّع منشوراتك — معلومات ملفك الشخصي مرفقة بكل منشور. \n\nبدلًا من نشر \"+1\" أو \"أتفق\"، استخدم الزر \"أعجبني\". بدلًا من أخذ موضوع موجود في اتجاه مختلف جذريًا، استخدم \"الرد في موضوع متربط\".\n\n\n\n## [انشر المحتوى الذي أنشأته بنفسك فقط](#stealing)\n\nلا يجوز لك نشر أي محتوى رقمي يخص شخص آخر دون إذن. لا يجوز لك نشر أوصاف أو روابط أو طرق لسرقة الملكية الفكرية لشخص ما (البرامج أو الفيديوهات أو الملفات الصوتية أو الصور) أو لخرق أي قانون آخر.\n\n\n\n## [بدعمٍ منك](#power)\n\nيتم تشغيل هذا الموقع بواسطة [فريق العمل المحلي](%{base_path}/about) والمستخدمين *أمثالك*؛ أي المجتمع. إذا كانت لديك أي أسئلة أخرى بشأن كيفية عمل الأشياء هنا، فافتح موضوعًا جديدًا في [فئة ملاحظات الموقع](%{base_path}/c/site-feedback) ولنناقشها! إذا كانت هناك مشكلة حرجة أو عاجلة لا يمكن معالجتها من خلال موضوع أو علامة وصفية، فتواصل معنا من خلال [صفحة فريق العمل](%{base_path}/about).\n\n\n\n## [شروط الخدمة](#tos)\n\nنعم، الحديث القانوني ممل، ولكن يجب علينا حماية أنفسنا – وبالتبعية حمايتك أنت وبياناتك – ضد الأشخاص غير الودودين. لدينا [شروط الخدمة](%{base_path}/tos) التي تصف سلوكك (وسلوكنا) والحقوق المتعلقة بالمحتوى والخصوصية والقوانين. لاستخدام هذه الخدمة، يجب أن توافق على الالتزام بشروط الخدمة [TOS](%{base_path}/tos).\n" tos_topic: title: "شروط الخدمة" body: | @@ -4024,17 +4255,17 @@ ar: يتم منحك هذه الشارة عندما تصل إلى مستوى الثقة 1. نشكرك على متابعة بعض الموضوعات وقراءتها لمعرفة ما يدور حوله مجتمعنا. تم رفع قيود المستخدم الجديد؛ لقد تم منحك جميع الوظائف الأساسية في المجتمع، مثل الرسائل الخاصة، والبلاغات، وتعديل Wiki، والقدرة على نشر صور وروابط متعددة. member: name: عضو - description: تم منحك الدعوات والمراسلات الجماعية والمزيد من الإعجابات + description: تم منحك الدعوات والمراسلات الجماعية والمزيد من تسجيلات الإعجاب long_description: | - يتم منح هذه الشارة عندما تصل إلى مستوى الثقة 2. نشكرك على المشاركة على مدار أسابيع للانضمام حقًا إلى مجتمعنا. يمكنك الآن إرسال دعوات من صفحة المستخدم الخاصة بك أو من الموضوعات الفردية، وإنشاء رسائل خاصة جماعية، والحصول على المزيد من الإعجابات كل يوم. + يتم منح هذه الشارة عندما تصل إلى مستوى الثقة 2. نشكرك على المشاركة على مدار أسابيع للانضمام حقًا إلى مجتمعنا. يمكنك الآن إرسال دعوات من صفحة المستخدم الخاصة بك أو من الموضوعات الفردية، وإنشاء رسائل خاصة جماعية، والحصول على المزيد من تسجيلات الإعجاب كل يوم. regular: name: منتظم - description: تم منحك إعادة التصنيف وإعادة التسمية والروابط التي تتم متابعتها، وWiki، والمزيد من الإعجابات + description: تم منحك إعادة التصنيف وإعادة التسمية والروابط التي تتم متابعتها، وWiki، والمزيد من تسجيلات الإعجاب long_description: | - يتم منح هذه الشارة عندما تصل إلى مستوى الثقة 3. نشكرك على كونك جزءًا منتظمًا من مجتمعنا على مدار أشهر. أنت الآن أحد القراء الأكثر نشاطًا ومساهمًا موثوقًا يجعل مجتمعنا رائعًا. يمكنك الآن إعادة تصنيف الموضوعات وإعادة تسميتها، والاستفادة ببلاغات أكثر قوة عن السلوك غير المرغوب فيه، والوصول إلى منطقة استراحة خاصة، وستحصل أيضًا على المزيد من الإعجابات كل يوم. + يتم منح هذه الشارة عندما تصل إلى مستوى الثقة 3. نشكرك على كونك جزءًا منتظمًا من مجتمعنا على مدار أشهر. أنت الآن أحد القراء الأكثر نشاطًا ومساهمًا موثوقًا يجعل مجتمعنا رائعًا. يمكنك الآن إعادة تصنيف الموضوعات وإعادة تسميتها، والاستفادة ببلاغات أكثر قوة عن السلوك غير المرغوب فيه، والوصول إلى منطقة استراحة خاصة، وستحصل أيضًا على المزيد من تسجيلات الإعجاب كل يوم. leader: name: قائد - description: تم منحك التعديل الشامل والتثبيت والإغلاق والأرشفة والتقسيم والدمج والمزيد من الإعجابات + description: تم منحك التعديل الشامل والتثبيت والإغلاق والأرشفة والتقسيم والدمج والمزيد من تسجيلات الإعجاب long_description: | يتم منح هذه الشارة عندما تصل إلى مستوى الثقة 4. أنت قائد في هذا المجتمع كما حدَّد فريق العمل، وتقدِّم مثالًا إيجابيًا لبقية المجتمع في أفعالك وأقوالك هنا. لديك القدرة على تعديل جميع المنشورات، واتخاذ الإجراءات الشائعة التي يتخذها مشرف الموضوع، مثل التثبيت والإغلاق وإلغاء الإدراج والأرشفة والتقسيم والدمج. welcome: @@ -4231,7 +4462,7 @@ ar: name: "المستخدم الجديد لهذا الشهر" description: مساهمات متميزة في الشهر الأول long_description: | - يتم منح هذه الشارة لتهنئة مستخدمَين جديدين كل شهر على إجمالي مساهماتهما الممتازة، وفقًا لعدد الإعجابات على منشوراتهما ومن سجَّلوها. + يتم منح هذه الشارة لتهنئة مستخدمَين جديدين كل شهر على إجمالي مساهماتهما الممتازة، وفقًا لعدد تسجيلات الإعجاب على منشوراتهما ومن سجَّلوها. enthusiast: name: متحمس description: الزيارة لمدة 10 أيام متتالية @@ -4255,6 +4486,7 @@ ar: invalid_token: "الرمز غير صالح." email_input: "البريد الإلكتروني للمسؤول" submit_button: "إرسال رسالة إلكترونية" + safe_mode: "الوضع الآمن: إيقاف كل السمات المكوِّنات الإضافية عند تسجيل الدخول" performance_report: initial_post_raw: يتضمَّن هذا الموضوع تقارير الأداء اليومية لموقعك. initial_topic_title: تقارير أداء الموقع @@ -4288,6 +4520,20 @@ ar: other: '"%{tag_name}" مقصور على الفئات التالية: %{category_names}' synonym: 'غير مسموح باستخدام المرادفات. استخدم "%{tag_name}" بدلًا منها.' has_synonyms: 'لا يمكن استخدام "%{tag_name}" لأنه يحتوي على مرادفات.' + restricted_tags_cannot_be_used_in_category: + zero: 'لا يمكن استخدام الوسم "%{tags}" في فئة "%{category}". يُرجى إزالته.' + one: 'لا يمكن استخدام الوسم "%{tags}" في فئة "%{category}". يُرجى إزالته.' + two: 'لا يمكن استخدام الوسمين "%{tags}" في فئة "%{category}". يُرجى إزالتهما.' + few: 'لا يمكن استخدام الوسوم "%{tags}" في الفئة "%{category}". يُرجى إزالتها.' + many: 'لا يمكن استخدام الوسوم "%{tags}" في الفئة "%{category}". يُرجى إزالتها.' + other: 'لا يمكن استخدام الوسوم "%{tags}" في الفئة "%{category}". يُرجى إزالتها.' + category_does_not_allow_tags: + zero: 'لا تسمح الفئة "%{category}" بالوسم "%{tags}". يُرجى إزالته.' + one: 'لا تسمح الفئة "%{category}" بالوسم "%{tags}". يُرجى إزالته.' + two: 'لا تسمح الفئة "%{category}" بالوسمين "%{tags}". يُرجى إزالتهما.' + few: 'لا تسمح الفئة "%{category}" بالوسوم "%{tags}". يُرجى إزالتها.' + many: 'لا تسمح الفئة "%{category}" بالوسوم "%{tags}". يُرجى إزالتها.' + other: 'لا تسمح الفئة "%{category}" بالوسوم "%{tags}". يُرجى إزالتها.' required_tags_from_group: zero: "يجب عليك تضمين %{count}% وسم من %{tag_group_name} على الأقل. الوسوم في هذه المجموعة هي: %{tags}." one: "يجب عليك تضمين وسم واحد (%{count}%) من %{tag_group_name} على الأقل. الوسوم في هذه المجموعة هي: %{tags}." @@ -4303,6 +4549,7 @@ ar: register: button: "التسجيل" title: "تسجيل حساب المسؤول" + help: "أنشئ حسابًا جديدًا للبدء." no_emails: "للأسف، لم يتم تحديد أي رسائل إلكترونية للمسؤول في أثناء الإعداد؛ لذا قد يكون إنهاء الإعداد أمرًا صعبًا. يُرجى إضافة عنوان بريد إلكتروني للمطوِّر في ملف الإعداد أو إنشاء حساب مسؤول من وحدة التحكم ." confirm_email: title: "تأكيد عنوان بريدك الإلكتروني" @@ -4312,6 +4559,8 @@ ar: message: "

    لقد أعدنا إرسال الرسالة الإلكترونية للتنشيط إلى %{email}" safe_mode: title: "الدخول إلى الوضع الآمن" + description: "يسمح لك الوضع الآمن باختبار موقعك دون تحميل المكوِّنات الإضافية أو السمات." + no_themes: "إيقاف السمات ومكوِّنات السمات" no_unofficial_plugins: "إيقاف المكوِّنات الإضافية غير الرسمية" no_plugins: "إيقاف كل المكوِّنات الإضافية" enter: "الدخول إلى الوضع الآمن" @@ -4320,24 +4569,40 @@ ar: title: "إعداد Discourse" step: introduction: + title: "أخبرنا عن مجتمعك" fields: title: + label: "اسم المجتمع" placeholder: "استراحة جين" site_description: + label: "صِف مجتمعك في جملة واحدة" placeholder: "مكان لجين وأصدقائها لمناقشة أشياء رائعة" + contact_email: + label: "نقطة الاتصال" + placeholder: "example@user.com" + description: "الشخص أو المجموعة المسؤولة عن هذا المجتمع. تُستخدَم للتحديثات المهمة، ومُدرَج في صفحة نبذة عنك للتواصل العاجل." default_locale: label: "اللغة" privacy: + title: "تجربة العضو" fields: login_required: placeholder: "خاصة" extra_description: "يمكن للمستخدمين المسجَّلين فقط الوصول إلى هذا المجتمع" + invite_only: + placeholder: "الدعوة فقط" + extra_description: "يجب دعوة المستخدمين من قِبل المستخدمين الموثوقين أو فريق العمل، وإلا فسيتمكن المستخدمون من التسجيل بأنفسهم." must_approve_users: placeholder: "طلب الموافقة" + extra_description: "يجب الموافقة على المستخدمين من قِبل فريق العمل" ready: title: "تم إعداد Discourse!" + description: "هذا كل شيء! لقد انتهيت من الخطوات الأساسية لإعداد مجتمعك. يمكنك البدء الآن وإلقاء نظرة، وكتابة موضوع ترحيبي، وإرسال الدعوات!

    استمتع بوقتك!" styling: + title: "الشكل والمظهر" fields: + color_scheme: + label: "نظام الألوان" body_font: label: "خط النص الأساسي" heading_font: @@ -4345,6 +4610,7 @@ ar: styling_preview: label: "معاينة" homepage_style: + label: "نمط الصفحة الرئيسية" choices: latest: label: "أحدث الموضوعات" @@ -4354,26 +4620,42 @@ ar: label: "الفئات ذات الموضوعات المميزة" categories_and_latest_topics: label: "الفئات وأحدث الموضوعات" + categories_and_latest_topics_created_date: + label: "الفئات وأحدث الموضوعات (الفرز حسب تاريخ إنشاء الموضوع)" categories_and_top_topics: label: "الفئات والموضوعات الأكثر نشاطًا" + categories_boxes: + label: "مربعات الفئات" + categories_boxes_with_topics: + label: "مربعات الفئات ذات الموضوعات" + subcategories_with_featured_topics: + label: "الفئات الفرعية ذات الموضوعات المميزة" branding: + title: "تخصيص الشعارات" fields: logo: label: "الشعار الأساسي" + description: "الشعار في الجزء العلوي الأيمن من موقعك. استخدم صورة مستطيلة عريضة بارتفاع 120 ونسبة عرض إلى ارتفاع أكبر من 3:1" logo_small: label: "الشعار المربع" + description: "نسخة مربعة من شعارك. تظهر في الجزء العلوي الأيمن عند التمرير لأسفل، وعند المشاركة على المنصات الاجتماعية. يجب أن يكون الحجم المثالي 512×512 على الأقل." favicon: label: "أيقونة المتصفح" + description: "الأيقونة المستخدمة لتمثيل موقعك في متصفحات الويب وتبدو جيدة في الأحجام الصغيرة. يوصى بتنسيق PNG أو JPG. يُستخدَم الشعار المربع بشكلٍ افتراضي." large_icon: - label: "الأيقونة الكبيرة" + label: "أيقونة كبيرة" + description: "الأيقونة المستخدمة لتمثيل موقعك على أجهزة الجوَّال وتبدو جيدة في الأحجام الأكبر. يجب أن يكون الحجم المثالي أكبر من 512×512. سنستخدم الشعار المربع بشكلٍ افتراضي." corporate: + title: "مؤسستك" fields: company_name: label: "اسم الشركة" placeholder: "مثال لمؤسسة" + description: "يتم إدخاله في صفحة شروط الخدمة. لا تتردَّد في التخطي إذا لم تكن هناك شركة." governing_law: label: "القانون المعمول به" placeholder: "قانون ولاية كاليفورنيا" + description: "يتم إدخاله في صفحة شروط الخدمة. لا تتردَّد في التخطي إذا لم تكن هناك شركة." contact_url: label: "صفحة الويب" placeholder: "https://www.example.com/contact-us" @@ -4381,6 +4663,7 @@ ar: city_for_disputes: label: "مدينة النزاعات" placeholder: "سان فرانسيسكو، كاليفورنيا" + description: "يتم إدخاله في صفحة شروط الخدمة. لا تتردَّد في التخطي إذا لم تكن هناك شركة." site_contact: label: "الرسائل التلقائية" description: "سيتم إرسال جميع رسائل Discourse التلقائية الخاصة من هذا المستخدم، مثل التحذيرات بشأن البلاغات وإشعارات إكمال النسخ الاحتياطي." @@ -4443,8 +4726,24 @@ ar: missing_version: "يجب عليك توفير معلمة للإصدار" conflict: "حدث تعارض في التحديث منعك من إجراء ذلك." reasons: + post_count: "يجب الموافقة على أول بضعة منشورات من كل مستخدم من قِبل فريق العمل. راجع %{link}." + trust_level: "يجب أن تتم الموافقة على ردود المستخدمين من مستويات الثقة المنخفضة من قِبل فريق العمل. راجع %{link}." + new_topics_unless_trust_level: "يجب أن تتم الموافقة على موضوعات المستخدمين من مستويات الثقة المنخفضة من قِبل فريق العمل. راجع %{link}." + fast_typer: "كتب مستخدم جديد أول منشور له بسرعة كبيرة بشكلٍ مريب، ويُشتبَه في أن يكون روبوتًا أو صاحب سلوك غير مرغوب فيه. راجع %{link}." + auto_silence_regex: "مستخدم جديد يتطابق منشوره الأول مع الإعداد %{link}." + watched_word: "تضمَّن هذا المنشور كلمة مراقبة. راجع %{link}." + staged: "يجب الموافقة على الموضوعات والمنشورات الجديدة من المستخدمين المؤقتين من قِبل فريق العمل. راجع %{link}." + category: "تتطلب المنشورات في هذه الفئة موافقة يدوية بواسطة فريق العمل. راجع %{link}." + must_approve_users: "يجب الموافقة على جميع المستخدمين الجُدد من قِبل فريق العمل. راجع %{link}." + invite_only: "يجب دعوة جميع المستخدمين الجُدد. راجع %{link}." email_auth_res_enqueue: "فشلت هذه الرسالة الإلكترونية في اجتياز تحقُّق DMARC، وعلى الأرجح أنه ليس من الشخص الذي يبدو أنه منه. تحقَّق من رؤوس الرسائل الإلكترونية البسيطة لمزيد من المعلومات." + email_spam: "تم الإبلاغ عن هذه الرسالة الإلكترونية كرسالة غير مرغوب فيها من خلال الرأس المحدَّد في %{link}." + suspect_user: "أدخل هذا المستخدم الجديد معلومات الملف الشخصي دون قراءة أي موضوعات أو منشورات، مما يشير بشدة إلى أنه قد يكون من أصحاب السلوك غير المرغوب فيه. راجع %{link}." + contains_media: "يحتوي هذا المنشور على وسائط مضمَّنة. راجع %{link}." queued_by_staff: "يعتقد أحد أعضاء فريق العمل أن هذا المنشور يحتاج إلى مراجعة. سيظل مخفيًا حتى تتم مراجعته." + links: + watched_word: قائمة الكلمات المراقبة + category: إعدادات الفئة actions: agree: title: "الموافقة..." @@ -4528,6 +4827,8 @@ ar: notification_level: ignore_error: "عذرًا، لا يمكنك تجاهل هذا المستخدم." mute_error: "عذرًا، لا يمكنك كتم هذا المستخدم." + error: "عذرًا، لا يمكنك تغيير مستوى الإشعارات لهذا المستخدم." + invalid_value: 'القيمة "%{value}" ليست مستوى إشعارات صالحًا.' discord: not_in_allowed_guild: "فشلت المصادقة. أنت لست عضوًا في خادم Discord مسموح به." old_keys_reminder: @@ -4550,7 +4851,7 @@ ar: other: "%{topic_title} (الجزء %{count})" post_raw: "متابعة المناقشة من %{parent_url}.\n\nالمناقشات السابقة:\n\n%{previous_topics}" small_action_post_raw: "تابع المناقشة في %{new_title}." - fallback_username: "مستخدم واحد" + fallback_username: "مستخدم" user_status: errors: ends_at_should_be_greater_than_set_at: "يجب أن تكون end_at أكبر من set_at" diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml index 9205e62714..57e331ee66 100644 --- a/config/locales/server.ca.yml +++ b/config/locales/server.ca.yml @@ -585,7 +585,6 @@ ca: one: "fa gairebé %{count} any" other: "fa gairebé %{count} any" password_reset: - no_token: "Aquest enllaç de canvi de contrasenya és massa antic. Trieu el botó d'inici de sessió i feu servir \"He oblidat la contrasenya\" per a obtenir un enllaç nou." choose_new: "Trieu una contrasenya nova" choose: "Trieu una contrasenya" update: "Actualitza la contrasenya" diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml index 91e920f6bb..64de6f1f4a 100644 --- a/config/locales/server.da.yml +++ b/config/locales/server.da.yml @@ -466,7 +466,6 @@ da: Du kan redigere dit forrige svar for at tilføje et citat ved at markere teksten og vælge citér svar-knappen som dukker op. Det er nemmere for alle at læse emner som har færre detaljerede svar i stedet for mange mindre, individuelle svar. - dominating_topic: Du har skrevet mere end %{percent}% af svarene her, er der andre, som du gerne vil høre fra? get_a_room: Du har svaret @%{reply_username} %{count} gange, vidste du, at du kunne sende dem en personlig besked i stedet? too_many_replies: | ### Du kan ikke skrive flere indlæg i dette emne @@ -699,7 +698,6 @@ da: one: "næsten %{count} år siden" other: "næsten %{count} år siden" password_reset: - no_token: "Beklager, dit skift-kodeords link er udløbet. Vælg 'Log ind' knappen og brug 'Jeg har glemt min adgangskode' for at få et nyt link." choose_new: "Vælg en ny adgangskode" choose: "Vælg en adgangskode" update: "Opdatér Adgangskode" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 0192e02b8f..551584b6d5 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -495,7 +495,6 @@ de: Du kannst deine letzte Antwort bearbeiten, um ein Zitat hinzuzufügen, indem du den Text auswählst und auf die erscheinende Schaltfläche Zitat klickst. Es ist für alle einfacher, Themen zu lesen, die wenige umfassende Antworten statt viele kleine und einzelne Antworten haben. - dominating_topic: Du hast mehr als %{percent} % der Antworten hier gepostet. Gib anderen doch auch die Möglichkeit, etwas beizutragen. get_a_room: Du hast @%{reply_username} %{count} Mal geantwortet. Wusstest du, dass du der Person stattdessen eine persönliche Nachricht schicken kannst? too_many_replies: | ### Du hast das Antwort-Limit für dieses Thema erreicht @@ -764,7 +763,6 @@ de: one: "vor fast %{count} Jahr" other: "vor fast %{count} Jahren" password_reset: - no_token: "Entschuldige, aber der Link zum Ändern des Passworts ist zu alt. Wähle die Anmelden-Schaltfläche und nutze „Ich habe mein Passwort vergessen“, um einen neuen Link zu erhalten." choose_new: "Wähle ein neues Passwort" choose: "Wähle ein Passwort" update: "Passwort aktualisieren" @@ -3698,7 +3696,6 @@ de: png_to_jpg_conversion_failure_message: "Beim Konvertieren von PNG in JPG ist ein Fehler aufgetreten." optimize_failure_message: "Beim Optimieren des hochgeladenen Bildes ist ein Fehler aufgetreten." download_failure: "Herunterladen der Datei vom externen Anbieter fehlgeschlagen." - size_mismatch_failure: "Die Größe der auf S3 hochgeladenen Datei stimmte nicht mit der beabsichtigten Größe des externen Upload-Stubs überein. %{additional_detail}" create_multipart_failure: "Fehler beim Erstellen eines mehrteiligen Uploads im externen Speicher." abort_multipart_failure: "Fehler beim Abbrechen des mehrteiligen Uploads im externen Speicher." complete_multipart_failure: "Der mehrteilige Upload im externen Speicher konnte nicht abgeschlossen werden." diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 30fbff39f3..0ba48c1b79 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -543,7 +543,6 @@ el: one: "σχεδόν %{count} χρόνο πριν" other: "σχεδόν %{count} χρόνια πριν" password_reset: - no_token: "Συγνώμη, ο σύνδεσμος αλλαγής κωδικού είναι πολύ παλιός. Πατήστε ξανά το κουμπί 'Συνδεθείτε' και επιλέξτε 'Ξέχασα τον κωδικό πρόσβασής μου' για να λάβετε νέο σύνδεσμο. " choose_new: "Επιλέξτε νέο κωδικό πρόσβασης" choose: "Επιλέξτε έναν κωδικό πρόσβασης" update: "Ενημέρωση κωδικού πρόσβασης" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e853452a01..67254e6218 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -697,6 +697,7 @@ en: disallowed_topic_tags: "This topic has tags not allowed by this category: '%{tags}'" disallowed_tags_generic: "This topic has disallowed tags." slug_contains_non_ascii_chars: "contains non-ascii characters" + is_already_in_use: "is already in use" cannot_delete: uncategorized: "This category is special. It is intended as a holding area for topics that have no category; it cannot be deleted." has_subcategories: "Can't delete this category because it has sub-categories." @@ -1437,7 +1438,7 @@ en: group_email_credentials_warning: 'There was an issue with the email credentials for the group %{group_full_name}. No emails will send from the group inbox until this problem is addressed. %{error}' rails_env_warning: "Your server is running in %{env} mode." host_names_warning: "Your config/database.yml file is using the default localhost hostname. Update it to use your site's hostname." - sidekiq_warning: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by sidekiq. Please ensure at least one sidekiq process is running. Learn about Sidekiq here.' + sidekiq_warning: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by Sidekiq. Please ensure at least one Sidekiq process is running. Learn about Sidekiq here.' queue_size_warning: "The number of queued jobs is %{queue_size}, which is high. This could indicate a problem with the Sidekiq process(es), or you may need to add more Sidekiq workers." memory_warning: "Your server is running with less than 1 GB of total memory. At least 1 GB of memory is recommended." google_oauth2_config_warning: 'The server is configured to allow signup and login with Google OAuth2 (enable_google_oauth2_logins), but the client id and client secret values are not set. Go to the Site Settings and update the settings. See this guide to learn more.' @@ -2227,7 +2228,7 @@ en: bootstrap_mode_min_users: "Minimum number of users required to disable bootstrap mode (set to 0 to disable)" prevent_anons_from_downloading_files: "Prevent anonymous users from downloading attachments." - secure_media: 'DEPRECATED: Use the secure_uploads setting instead, will be removed in Discourse 3.0.' + secure_media: "DEPRECATED: Use the secure_uploads setting instead, will be removed in Discourse 3.0." secure_uploads: 'Limits access to ALL uploads (images, video, audio, text, pdfs, zips, and others). If "login required” is enabled, only logged-in users can access uploads. Otherwise, access will be limited only for media uploads in private messages and private categories. WARNING: This setting is complex and requires deep administrative understanding. See the secure uploads topic on Meta for details.' secure_media_allow_embed_images_in_emails: "DEPRECATED: Use secure_uploads_allow_embed_images_in_emails, will remove in Discourse 3.0." secure_uploads_allow_embed_images_in_emails: "Allows embedding secure images that would normally be redacted in emails, if their size is smaller than the 'secure uploads max email embed image size kb' setting." @@ -2279,6 +2280,8 @@ en: default_email_in_reply_to: "Include excerpt of replied to post in emails by default." + default_hide_profile_and_presence: "Hide user public profile and presence features by default." + default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new." default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked." default_other_notification_level_when_replying: "Global default notification level when the user replies to a topic." @@ -2351,7 +2354,7 @@ en: sitemap_page_size: "Number of URLs to include in each sitemap page. Max 50.000" enable_user_status: "(experimental) Allow users to set custom status message (emoji + description)." - enable_onboarding_popups: "(experimental) Enable educational popups that describe key features to users" + enable_user_tips: "(experimental) Enable new user tips that describe key features to users" short_title: "The short title will be used on the user's home screen, launcher, or other places where space may be limited. It should be limited to 12 characters." diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 9bb1e6e0f3..d00775e7e0 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -495,7 +495,6 @@ es: Puedes editar tu anterior respuesta para añadir una cita. Para ello, selecciona el texto que quieras citar y pulsa el botón citar respuesta que aparecerá. Es más fácil leer temas que tengan menos respuestas (aunque más profundas), que tener que leer muchas respuestas individuales. - dominating_topic: Has publicado más del %{percent} % de las respuestas, ¿hay alguien más de quien te gustaría saber? get_a_room: Has respondido a @%{reply_username} %{count} veces, ¿sabías que también puedes enviarle un mensaje personal directamente? too_many_replies: | ### Has llegado al límite de respuestas en este tema @@ -764,7 +763,6 @@ es: one: "hace casi %{count} año" other: "hace casi %{count} años" password_reset: - no_token: "Lo sentimos, ese enlace para cambiar la contraseña es demasiado antiguo. Haz clic en el botón Iniciar sesión y utiliza el «olvidé mi contraseña» para obtener un nuevo enlace." choose_new: "Escoge una nueva contraseña" choose: "Escoge una contraseña" update: "Actualizar contraseña" @@ -3702,7 +3700,6 @@ es: png_to_jpg_conversion_failure_message: "Ha ocurrido un error cuando se convertía desde PNG a JPG." optimize_failure_message: "Ha ocurrido un error al optimizar la imagen subida." download_failure: "Ha fallado la descarga del archivo del proveedor externo." - size_mismatch_failure: "El tamaño del archivo subido a S3 no coincide con el tamaño esperado del stub externo. %{additional_detail}" create_multipart_failure: "Fallo al crear una subida en varias partes en el almacenamiento externo." abort_multipart_failure: "Fallo al abortar una subida en varias partes en el almacenamiento externo." complete_multipart_failure: "Fallo al completar la subida en varias partes en el almacenamiento externo." diff --git a/config/locales/server.et.yml b/config/locales/server.et.yml index 5dd4ce33a9..65ad5c688c 100644 --- a/config/locales/server.et.yml +++ b/config/locales/server.et.yml @@ -399,7 +399,6 @@ et: one: "peaaegu %{count} aasta tagasi" other: "peaaegu %{count} aastat tagasi" password_reset: - no_token: "Vabandust, see parooli uuendamise link on liiga vana. Vajuta sisselogimise nuppu ja kasuta värske lingi saamiseks 'Unustasin parooli'." choose_new: "Vali uus parool" choose: "Vali parool" update: "Uuenda parooli" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index d912b7b0fb..3a73ebd215 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -498,7 +498,6 @@ fa_IR: one: "تقریبا %{count} سال قبل" other: "تقریبا %{count} سال قبل" password_reset: - no_token: "متآسفیم, پیوند تغییر رمز عبور بسیار قدیمی است. دکمه ورود را انتخاب کنید و از 'من رمز عبور خود را فراموش کرده ام' برای دریافت یک پیوند جدید استفاده کنید." choose_new: "رمز‌عبور جدید را وارد کنید" choose: "رمز‌عبور وارد کنید" update: "به‌روز کردن رمز‌عبور" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 860882692b..bb441a2e43 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -128,7 +128,7 @@ fi: unsubscribe_not_allowed: "Näin käy, kun tämä käyttäjä ei voi perua tilausta sähköpostitse." email_not_allowed: "Näin käy, kun sähköpostiosoite ei ole sallittujen listalla tai on kiellettyjen listalla." unrecognized_error: "Tuntematon virhe" - secure_uploads_placeholder: "Piilotettu: Tällä sivustolla on suojatut lataukset käytössä, vieraile ketjussa tai katso liitetyt lataukset napsauttamalla Näytä sisältö -painiketta." + secure_uploads_placeholder: "Piilotettu: Tällä sivustolla on suojatut lataukset käytössä, vieraile ketjussa tai katso liitetyt lataukset klikkaamalla Näytä sisältö -painiketta." view_redacted_media: "Näytä sisältö" errors: &errors format: ! "%{attribute} %{message}" @@ -495,7 +495,6 @@ fi: Voit muokata edellistä viestiäsi ja lisätä siihen lainauksen maalaamalla lainattavan viestin tekstiä ja klikkaamalla ilmestyvää lainaa-painiketta. On helpompaa lukea ketjua, jossa on vähemmän pidempiä vastauksia kuin sellaista, jossa on paljon lyhyitä yksittäisiä vastauksia. - dominating_topic: Olet lähettänyt yli %{percent} % vastauksista täällä, olisiko aika antaa muiden osallistua keskusteluun? get_a_room: Olet vastannut käyttäjälle @%{reply_username} %{count} kertaa, tiesitkö, että voit lähettää hänelle yksityisviestin? too_many_replies: | ### Olet kirjoittanut enimmäismäärän vastauksia tähän ketjuun @@ -654,10 +653,10 @@ fi: post: image_placeholder: broken: "Tämä kuva ei toimi" - blocked_hotlinked_title: "Kuva on isännöity toisella sivustolla. Avaa se uudessa välilehdessä napsauttamalla." + blocked_hotlinked_title: "Kuva on isännöity toisella sivustolla. Avaa se uudessa välilehdessä klikkaamalla." blocked_hotlinked: "Ulkoinen kuva" media_placeholder: - blocked_hotlinked_title: "Mediasisältö on isännöity toisella sivustolla. Avaa se uudessa välilehdessä napsauttamalla." + blocked_hotlinked_title: "Mediasisältö on isännöity toisella sivustolla. Avaa se uudessa välilehdessä klikkaamalla." blocked_hotlinked: "Ulkoinen mediasisältö" hidden_bidi_character: "Kaksisuuntaiset merkit voivat muuttaa tekstin esitysjärjestystä. Tätä voidaan käyttää haitallisen koodin peittämiseen." has_likes: @@ -764,7 +763,6 @@ fi: one: "lähes vuosi sitten" other: "lähes %{count} vuotta sitten" password_reset: - no_token: "Tämä salasanan vaihtolinkki on liian vanha. Paina 'Kirjaudu sisään' -painiketta ja valitse 'Unohdin salasanani' saadaksesi uuden linkin." choose_new: "Valitse uusi salasana" choose: "Valitse salasana" update: "Päivitä salasana" @@ -3545,7 +3543,7 @@ fi: title: "Vahvista uusi sähköpostiosoite" subject_template: "[%{email_prefix}] Vahvista uusi sähköpostiosoite" text_body_template: | - Vahvista uusi sähköpostiosoitteesi sivustolla %{site_name} napsauttamalla seuraavaa linkkiä: + Vahvista uusi sähköpostiosoitteesi sivustolla %{site_name} klikkaamalla seuraavaa linkkiä: %{base_url}/u/confirm-new-email/%{email_token} @@ -3698,7 +3696,6 @@ fi: png_to_jpg_conversion_failure_message: "PNG:n muuttamisessa JPG:ksi tapahtui virhe." optimize_failure_message: "Ladatun kuvan optimoinnissa tapahtui virhe." download_failure: "Tiedoston lataaminen ulkoiselta palveluntarjoajalta epäonnistui." - size_mismatch_failure: "S3:een ladatun tiedoston koko ei vastannut ulkoisen latauksen tyngän tarkoitettua kokoa. %{additional_detail}" create_multipart_failure: "Moniosaisen latauksen luominen ulkoisessa tallennustilassa epäonnistui." abort_multipart_failure: "Moniosaisen latauksen hylkääminen ulkoisessa tallennustilassa epäonnistui." complete_multipart_failure: "Moniosaisen latauksen viimeistely ulkoisessa tallennustilassa epäonnistui." diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 8b6358ca10..5f8b8e2e43 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -101,7 +101,7 @@ fr: incoming: default_subject: "Ce sujet a besoin d'un titre" show_trimmed_content: "Montrer le contenu raccourci" - maximum_staged_user_per_email_reached: "Vous avez atteint le nombre maximal d'utilisateurs distants qui peuvent être créés par courriel." + maximum_staged_user_per_email_reached: "Vous avez atteint le nombre maximal d'utilisateurs distants qui peuvent être créés par e-mail." no_subject: "(aucun objet)" no_body: "(aucun texte)" missing_attachment: "(La pièce jointe %{filename} est manquante)" @@ -109,24 +109,24 @@ fr: one: "Poursuite de la discussion à partir de [%{title}](%{url}), car elle a été créée il y a plus de %{count} jour." other: "Poursuite de la discussion à partir de [%{title}](%{url}), car elle a été créée il y a plus de %{count} jours." errors: - empty_email_error: "Se produit quand le courriel reçu est vide." - no_message_id_error: "Se produit quand le courriel n'a pas d'en-tête « Message-Id »." + empty_email_error: "Se produit quand l'e-mail reçu est vide." + no_message_id_error: "Se produit quand l'e-mail n'a pas d'en-tête « Message-Id »." auto_generated_email_error: "Se produit quand l'en-tête « precedence » est : list, junk, bulk ou auto-reply, ou lorsque n'importe quel autre en-tête contient : auto-submitted, auto-replied ou auto-generated." no_body_detected_error: "Se produit quand il est impossible d'extraire le corps du message et qu'il n'y a pas de pièces jointes." - no_sender_detected_error: "Se produit lorsque nous n'avons pas trouvé une adresse courriel valide dans l'en-tête From." - from_reply_by_address_error: "Survient quand l'en-tête « From » correspond à l'adresse courriel « Reply by »." + no_sender_detected_error: "Se produit lorsque nous n'avons pas trouvé une adresse e-mail valide dans l'en-tête From." + from_reply_by_address_error: "Survient quand l'en-tête « From » correspond à l'adresse e-mail « Reply by »." inactive_user_error: "Se produit quand l'expéditeur n'est pas actif." silenced_user_error: "Se produit lorsque l'expéditeur a été mis en sourdine." - bad_destination_address: "Se produit quand aucune des adresses courriel reprises dans les champs To/Cc ne correspond à une adresse courriel entrante configurée." + bad_destination_address: "Se produit quand aucune des adresses e-mail reprises dans les champs To/Cc ne correspond à une adresse e-mail entrante configurée." strangers_not_allowed_error: "Se produit quand un utilisateur essaie de créer un nouveau sujet dans une catégorie dans laquelle il n'est pas membre." insufficient_trust_level_error: "Se produit quand un utilisateur essaie de créer un nouveau sujet dans une catégorie pour laquelle il n'a pas le niveau de confiance nécessaire." - reply_user_not_matching_error: "Se produit quand une réponse est venue d'une adresse courriel différente de celle où a été envoyée la notification." + reply_user_not_matching_error: "Se produit quand une réponse est venue d'une adresse e-mail différente de celle où a été envoyée la notification." topic_not_found_error: "Se produit quand quelqu'un répond à un sujet qui a été supprimé." topic_closed_error: "Se produit quand quelqu'un répond alors que le sujet lié a été fermé." - bounced_email_error: "Le courriel est un rapport de courriel non délivré." - screened_email_error: "Se produit quand l'adresse courriel de l'expéditeur est déjà sous surveillance." - unsubscribe_not_allowed: "Se produit lorsque le désabonnement via courriel n'est pas autorisé pour cet utilisateur." - email_not_allowed: "Se produit quand l'adresse courriel n'est pas reprise dans la liste autorisée ou se trouve dans la liste des expéditeurs indésirables." + bounced_email_error: "L'e-mail est un rapport d'e-mail non délivré." + screened_email_error: "Se produit quand l'adresse e-mail de l'expéditeur est déjà sous surveillance." + unsubscribe_not_allowed: "Se produit lorsque le désabonnement par e-mail n'est pas autorisé pour cet utilisateur." + email_not_allowed: "Se produit quand l'adresse e-mail n'est pas reprise dans la liste autorisée ou se trouve dans la liste des expéditeurs indésirables." unrecognized_error: "Erreur non reconnue" secure_uploads_placeholder: "Masqué : ce site a activé la sécurisation des téléversements. Visitez le sujet ou cliquez sur « Afficher le contenu multimédia » pour les voir." view_redacted_media: "Afficher le média" @@ -223,7 +223,7 @@ fr: expired: "Votre jeton d'invitation a expiré. Veuillez contacter les responsables." not_found: "Votre jeton d'invitation est invalide. Veuillez contacter les responsables." not_found_json: "Votre jeton d'invitation est invalide. Veuillez contacter les responsables." - not_matching_email: "Votre adresse de courriel ne correspond pas à celle qui a été définie dans votre invitation. Nous vous invitons à contacter un responsable." + not_matching_email: "Votre adresse e-mail ne correspond pas à celle qui a été définie dans votre invitation. Nous vous invitons à contacter un responsable." not_found_template: |

    Votre invitation à %{site_name} a déjà été utilisée.

    @@ -234,11 +234,11 @@ fr:

    La date d'expiration de cette invitation à %{site_name} est dépassée. Nous vous invitons à demander à la personne qui vous a invité(e) de vous faire parvenir une nouvelle invitation.

    user_exists: "Inutile d'inviter %{email}, cette adresse est déjà associée à un compte !" invite_exists: "Vous avez déjà invité %{email}." - invalid_email: "%{email} n'est pas une adresse de courriel valable." + invalid_email: "%{email} n'est pas une adresse e-mail valable." rate_limit: one: "Vous avez déjà envoyé %{count} invitation dans la journée, veuillez patienter %{time_left} avant de réessayer." other: "Vous avez déjà envoyé %{count} invitations dans la journée, veuillez patienter %{time_left} avant de réessayer." - confirm_email: "

    Vous y êtes presque ! Nous avons envoyé un courriel d'activation à votre adresse. Merci de suivre les instructions figurant dans le courriel pour activer votre compte.

    Si vous ne le recevez pas, veuillez vérifier votre dossier de courrier indésirable.

    " + confirm_email: "

    Vous y êtes presque ! Nous avons envoyé un e-mail d'activation à votre adresse. Merci de suivre les instructions figurant dans l'e-mail pour activer votre compte.

    Si vous ne le recevez pas, veuillez vérifier votre dossier de courrier indésirable.

    " cant_invite_to_group: "Vous n'êtes pas autorisé(e) à inviter des utilisateurs dans le(s) groupe(s) spécifié(s). Assurez-vous que vous êtes le ou la propriétaire du ou des groupes dans lesquels vous essayez d'inviter." disabled_errors: discourse_connect_enabled: "Les invitations sont désactivées car DiscourseConnect est activé." @@ -253,7 +253,7 @@ fr: max_rows: "Les premières %{max_bulk_invites} invitations ont été envoyées. Essayez de diviser le fichier en parties plus petites." error: "Une erreur est survenue lors de l'envoi de ce fichier. Veuillez réessayer plus tard." invite_link: - email_taken: "Cette adresse courriel est déjà utilisée. Si vous possédez déjà un compte, veuillez vous connecter ou réinitialiser votre mot de passe." + email_taken: "Cette adresse e-mail est déjà utilisée. Si vous possédez déjà un compte, veuillez vous connecter ou réinitialiser votre mot de passe." max_redemptions_limit: "doit être compris entre 2 et %{max_limit}." topic_invite: failed_to_invite: "L'utilisateur ne peut pas être invité dans ce sujet sans être membre d'un des groupes suivants : %{group_names}." @@ -280,7 +280,7 @@ fr: provider_not_found: "Vous n'êtes pas autorisé(e) à voir cette ressource. Le fournisseur d'authentification n'existe pas." read_only_mode_enabled: "Le site est en mode lecture seule. Les interactions sont désactivées." invalid_grant_badge_reason_link: "Le lien Discourse externe ou invalide n'est pas autorisé dans le motif du badge" - email_template_cant_be_modified: "Ce modèle de courriel ne peut pas être modifié" + email_template_cant_be_modified: "Ce modèle d'e-mail ne peut pas être modifié" invalid_whisper_access: "Les murmures ne sont pas activés ou vous n'avez pas accès à la création des messages murmures." not_in_group: title_topic: "Vous devez demander l'adhésion au groupe « %{group} » pour voir ce sujet." @@ -428,7 +428,7 @@ fr: one: "%{username} est déjà membre de ce groupe." other: "Les utilisateurs suivants sont déjà membres de ce groupe : %{username}" invalid_domain: "« %{domain} » n'est pas un domaine valide." - invalid_incoming_email: "L'adresse « %{email} » n'est pas une adresse courriel valide." + invalid_incoming_email: "L'adresse « %{email} » n'est pas une adresse e-mail valide." email_already_used_in_group: "L'adresse « %{email} » est déjà utilisée par le groupe « %{group_name} »." email_already_used_in_category: "L'adresse « %{email} » est déjà utilisée par la catégorie « %{category_name} »." cant_allow_membership_requests: "Vous ne pouvez pas autoriser les requêtes d'adhésion pour un groupe sans propriétaire." @@ -436,7 +436,7 @@ fr: adding_too_many_users: one: "Un maximum de %{count} utilisateur peut être ajouté à la fois" other: "Un maximum de %{count} utilisateurs peut être ajouté à la fois" - usernames_or_emails_required: "Des noms d'utilisateur ou des adresses de courriel doivent être indiqués" + usernames_or_emails_required: "Des noms d'utilisateur ou des adresses e-mail doivent être indiqués" no_invites_with_discourse_connect: "Il vous est seulement possible d'inviter des utilisateurs enregistrés, car DiscourseConnect est activé." no_invites_without_local_logins: "Il est vous seulement possible d'inviter des utilisateurs enregistrés, car l'authentification locale est désactivée." default_names: @@ -495,7 +495,6 @@ fr: Vous pouvez modifier votre dernière réponse et ajouter une citation en sélectionnant le texte à citer et en cliquant sur le bouton Citer. Il est plus facile de lire des discussions comprenant des réponses approfondies plutôt que de nombreuses réponses individuelles brèves. - dominating_topic: Vous êtes l'auteur de %{percent} % des réponses actuelles de ce sujet. Pourriez-vous laisser l'occasion à d'autres personnes de s'exprimer ? get_a_room: Nous remarquons que vous avez déjà envoyé un certain nombre de réponses (%{count}) à @%{reply_username}. Saviez-vous qu'il est également possible d'échanger des messages directs avec cette personne sur notre site ? too_many_replies: | ### Vous avez atteint le nombre maximal de réponses dans ce sujet @@ -530,8 +529,8 @@ fr: too_many_users: "Vous ne pouvez envoyer des avertissements qu'à un utilisateur à la fois." cant_send_pm: "Nous sommes désolés, vous ne pouvez pas envoyer de message direct à cet utilisateur." no_user_selected: "Vous devez choisir un utilisateur valide." - reply_by_email_disabled: "Réponse par courriel désactivée." - send_to_email_disabled: "Nous sommes désolés, vous ne pouvez pas faire parvenir de message personnel par courriel." + reply_by_email_disabled: "Réponse par e-mail désactivée." + send_to_email_disabled: "Nous sommes désolés, vous ne pouvez pas faire parvenir de message personnel par e-mail." target_user_not_found: "Un des utilisateurs à qui vous envoyez ce message n'a pas été trouvé." unable_to_update: "Une erreur s'est produite lors de la mise à jour de ce sujet." unable_to_tag: "Une erreur s'est produite lors de l'ajout d'étiquette à ce sujet." @@ -542,7 +541,7 @@ fr: password: common: "fait partie des 10 000 mots de passe les plus utilisés. Veuillez utiliser un mot de passe plus sécurisé." same_as_username: "est identique à votre nom d'utilisateur. Veuillez utiliser un mot de passe plus sécurisé." - same_as_email: "est identique à votre adresse courriel. Veuillez utiliser un mot de passe plus sécurisé." + same_as_email: "est identique à votre adresse e-mail. Veuillez utiliser un mot de passe plus sécurisé." same_as_current: "est identique à votre mot de passe actuel." same_as_name: "est identique à votre nom." unique_characters: "contient trop de caractères répétés. Veuillez utiliser un mot de passe plus sécurisé." @@ -559,7 +558,7 @@ fr: user_email: attributes: user_id: - reassigning_primary_email: "Il n'est pas permis de réassigner une adresse courriel à un autre utilisateur." + reassigning_primary_email: "Il n'est pas permis de réassigner une adresse e-mail à un autre utilisateur." color_scheme_color: attributes: hex: @@ -631,7 +630,7 @@ fr: uncategorized_parent: "La catégorie « Sans catégorie » ne peut pas avoir de parent" self_parent: "Le parent d'une sous-catégorie ne peut pas être elle-même" depth: "Vous ne pouvez pas imbriquer une sous-catégorie sous une autre" - invalid_email_in: "L'adresse « %{email} » n'est pas une adresse courriel valide." + invalid_email_in: "L'adresse « %{email} » n'est pas une adresse e-mail valide." email_already_used_in_group: "L'adresse « %{email} » est déjà utilisée par le groupe « %{group_name} »." email_already_used_in_category: "L'adresse « %{email} » est déjà utilisée par la catégorie « %{category_name} »." description_incomplete: "Le message de description de la catégorie doit contenir au moins un paragraphe." @@ -682,7 +681,7 @@ fr: create_bookmark: "Vous avez atteint la limite quotidienne du nombre de signets qu'il vous est possible de créer. Vous pourrez créer de nouveaux signets d'ici %{time_left}." edit_post: "Vous avez atteint la limite quotidienne du nombre de modifications qu'il vous est possible d'apporter à des messages. Vous pourrez faire de nouvelles modifications d'ici %{time_left}." live_post_counts: "Vous demandez le nombre de messages en activité trop rapidement. Veuillez patienter %{time_left} avant de réessayer." - unsubscribe_via_email: "Vous avez atteint la limite quotidienne du nombre de désinscriptions par courriel. Veuillez patienter %{time_left} avant de réessayer." + unsubscribe_via_email: "Vous avez atteint la limite quotidienne du nombre de désinscriptions par e-mail. Veuillez patienter %{time_left} avant de réessayer." topic_invitations_per_day: "Vous avez atteint la limite quotidienne du nombre d'invitations qu'il vous est possible d'envoyer. Vous pourrez envoyer de nouvelles invitations d'ici %{time_left}." hours: one: "%{count} heure" @@ -764,7 +763,6 @@ fr: one: "il y a presque un an" other: "il y a presque %{count} ans" password_reset: - no_token: "Nous sommes désolés, le lien pour la modification de mot de passe est trop ancien. Cliquez sur le bouton « Se connecter » et utilisez « J'ai oublié mon mot de passe » pour obtenir un nouveau lien." choose_new: "Choisir un nouveau mot de passe" choose: "Choisir un mot de passe" update: "Mettre à jour le mot de passe" @@ -773,8 +771,8 @@ fr: success: "Vous avez modifié votre mot de passe avec succès et vous êtes maintenant connecté(e)." success_unapproved: "Vous avez modifié votre mot de passe avec succès." email_login: - invalid_token: "Nous sommes désolés, ce lien de connexion par courriel est trop ancien. Cliquez sur le bouton « Se connecter » et utilisez « J'ai oublié mon mot de passe » pour obtenir un nouveau lien." - title: "Connexion par courriel" + invalid_token: "Nous sommes désolés, ce lien de connexion par e-mail est trop ancien. Cliquez sur le bouton « Se connecter » et utilisez « J'ai oublié mon mot de passe » pour obtenir un nouveau lien." + title: "Connexion par e-mail" user_auth_tokens: browser: chrome: "Google Chrome" @@ -806,26 +804,26 @@ fr: unknown: "système d'exploitation inconnu" change_email: wrong_account_error: "Vous êtes connecté(e) avec le mauvais compte, veuillez vous déconnecter et réessayer." - confirmed: "Votre adresse courriel a été mise à jour." + confirmed: "Votre adresse e-mail a été mise à jour." please_continue: "Continuer vers %{site_name}" error: "Une erreur s'est produite lors de la modification de votre adresse e-mail. L'adresse est peut-être déjà utilisée ?" - doesnt_exist: "Cette adresse courriel n'est pas associée à votre compte." - error_staged: "Une erreur est survenue lors de la modification de votre adresse courriel. Cette adresse est déjà utilisée par un utilisateur distant." - already_done: "Nous sommes désolés, ce lien de confirmation n'est plus valide. Votre adresse courriel a peut-être déjà été modifiée ?" + doesnt_exist: "Cette adresse e-mail n'est pas associée à votre compte." + error_staged: "Une erreur est survenue lors de la modification de votre adresse e-mail. Cette adresse est déjà utilisée par un utilisateur distant." + already_done: "Nous sommes désolés, ce lien de confirmation n'est plus valide. Votre adresse e-mail a peut-être déjà été modifiée ?" confirm: "Confirmer" - max_secondary_emails_error: "Vous avez atteint la limite autorisée du nombre d'adresses de courriel secondaires." + max_secondary_emails_error: "Vous avez atteint la limite autorisée du nombre d'adresses e-mail secondaires." authorizing_new: - title: "Confirmer votre nouvelle adresse courriel" - description: "Veuillez confirmer que vous souhaitez remplacer votre adresse courriel par :" - description_add: "Veuillez confirmer que vous souhaitez ajouter une adresse courriel alternative :" + title: "Confirmer votre nouvelle adresse e-mail" + description: "Veuillez confirmer que vous souhaitez remplacer votre adresse e-mail par :" + description_add: "Veuillez confirmer que vous souhaitez ajouter une adresse e-mail alternative :" authorizing_old: - title: "Modifier votre adresse courriel" - description: "Veuillez confirmer votre changement d'adresse courriel" - description_add: "Veuillez confirmer que vous souhaitez ajouter une adresse courriel alternative :" - old_email: "Adresse courriel précédente : %{email}" - new_email: "Nouvelle adresse courriel : %{email}" - almost_done_title: "Confirmer une nouvelle adresse courriel" - almost_done_description: "Nous avons envoyé un courriel à votre nouvelle adresse pour confirmer le changement !" + title: "Modifier votre adresse e-mail" + description: "Veuillez confirmer votre changement d'adresse e-mail" + description_add: "Veuillez confirmer que vous souhaitez ajouter une adresse e-mail alternative :" + old_email: "Adresse e-mail précédente : %{email}" + new_email: "Nouvelle adresse e-mail : %{email}" + almost_done_title: "Confirmer une nouvelle adresse e-mail" + almost_done_description: "Nous avons envoyé un e-mail à votre nouvelle adresse pour confirmer le changement !" associated_accounts: revoke_failed: "Échec de la révocation de votre compte avec %{provider_name}." connected: "(connecté)" @@ -835,7 +833,7 @@ fr: please_continue: "Votre nouveau compte est confirmé ; vous allez être redirigé(e) vers la page d'accueil." continue_button: "Continuer vers %{site_name}" welcome_to: "Bienvenue sur %{site_name} !" - approval_required: "Un modérateur doit approuver manuellement votre nouveau compte avant que vous puissiez accéder au forum. Vous recevrez un courriel lorsque que votre compte sera approuvé !" + approval_required: "Un modérateur doit approuver manuellement votre nouveau compte avant que vous puissiez accéder au forum. Vous recevrez un e-mail lorsque que votre compte sera approuvé !" missing_session: "Nous ne pouvons pas détecter si votre compte a été créé, veuillez vérifier que les cookies de votre navigateur sont activés." activated: "Nous sommes désolés, ce compte a déjà été activé." admin_confirm: @@ -902,7 +900,7 @@ fr: imap_unhandled_error: "Une erreur non prise en charge est survenue lors de la communication avec le serveur IMAP. %{message}" connection_error: "Un problème de connexion au serveur s'est produit. Vérifiez le nom du serveur et le numéro de port avant de réessayer." timeout_error: "Le délai d'établissement de la connexion au serveur s'est écoulé. Vérifiez le nom du serveur et le numéro de port avant de réessayer." - unhandled_error: "Une erreur non prise en charge est survenue lors du test des paramètres de courriel. %{message}" + unhandled_error: "Une erreur non prise en charge est survenue lors du test des paramètres d'e-mail. %{message}" webauthn: validation: invalid_type_error: "Le type de webauthn est invalide. Les types valides sont webauthn.get et webauthn.create." @@ -950,7 +948,7 @@ fr: make: "Ce sujet est maintenant à la une. Il sera affiché en haut de chaque page jusqu'à ce qu'il soit ignoré par un utilisateur." remove: "Ce sujet n'est plus à la une. Il ne sera plus affiché en haut de chaque page." unsubscribed: - title: "Préférences de courriel enregistrées !" + title: "Préférences d'e-mail enregistrées !" description: "Les préférences de messagerie pour %{email} ont été mises à jour. Pour modifier vos paramètres de messagerie visitez vos préférences utilisateur." topic_description: "Pour vous réabonner à %{link}, utilisez le contrôle des notifications en bas ou à droite du sujet." private_topic_description: "Pour vous réabonner, utilisez le contrôle des notifications en bas ou à droite du sujet." @@ -962,15 +960,15 @@ fr: mute_topic: "Ignorer toutes les notifications pour ce sujet, %{link}" unwatch_category: "Arrêter de surveiller les sujets de la catégorie %{category}" mailing_list_mode: "Désactiver la liste de diffusion" - all: "Ne pas m'envoyer de courriel de %{sitename}" - different_user_description: "Vous êtes actuellement connecté(e) avec un utilisateur différent de celui à qui le courriel est adressé. Veuillez vous déconnecter ou utiliser le mode anonyme avant de réessayer." - not_found_description: "Nous sommes désolés, nous n'avons pas pu trouver ce désabonnement. Il est possible que le lien dans votre courriel soit trop ancien et qu'il ait expiré." + all: "Ne pas m'envoyer d'e-mail de %{sitename}" + different_user_description: "Vous êtes actuellement connecté(e) avec un utilisateur différent de celui à qui l'e-mail est adressé. Veuillez vous déconnecter ou utiliser le mode anonyme avant de réessayer." + not_found_description: "Nous sommes désolés, nous n'avons pas pu trouver ce désabonnement. Il est possible que le lien dans votre e-mail soit trop ancien et qu'il ait expiré." log_out: "Déconnexion" submit: "Enregistrer les préférences" digest_frequency: - title: "Vous recevez des courriels de résumé %{frequency}" - never_title: "Vous ne recevez pas de courriels de résumé" - select_title: "Régler la fréquence des courriels de résumé sur :" + title: "Vous recevez des e-mails de résumé %{frequency}" + never_title: "Vous ne recevez pas d'e-mails de résumé" + select_title: "Régler la fréquence des e-mails de résumé sur :" never: "jamais" every_30_minutes: "toutes les 30 minutes" every_hour: "toutes les heures" @@ -1161,10 +1159,10 @@ fr: click_through: CTR description: "Les critères de recherche les plus tendances avec leurs taux de clics." emails: - title: "Courriels envoyés" + title: "E-mails envoyés" xaxis: "Jour" - yaxis: "Nombre de courriels" - description: "Nombre de nouveaux courriels envoyés." + yaxis: "Nombre d'e-mails" + description: "Nombre de nouveaux e-mails envoyés." user_to_user_private_messages: title: "Utilisateur à utilisateur (sans les réponses)" xaxis: "Jour" @@ -1353,7 +1351,7 @@ fr: group_email_credentials_warning: 'L''authentification auprès du serveur de messagerie a échoué avec les identifiants spécifiés pour le groupe %{group_full_name}. Les e-mails liés à ce groupe ne pourront pas être expédiés tant que ce problème ne sera pas résolu. %{error}' rails_env_warning: "Votre serveur fonctionne dans l'environnement de %{env}." host_names_warning: "Votre fichier config/database.yml utilise le nom d'hôte par défaut. Veuillez renseigner votre nom d'hôte." - sidekiq_warning: 'Sidekiq n''est pas en cours d''exécution. De nombreuses tâches, comme l''envoi de courriels, sont exécutées de manière asynchrone par Sidekiq. Assurez-vous d''avoir au moins un processus Sidekiq en exécution. En savoir plus sur Sidekiq.' + sidekiq_warning: 'Sidekiq n''est pas en cours d''exécution. De nombreuses tâches, comme l''envoi d''e-mails, sont exécutées de manière asynchrone par Sidekiq. Assurez-vous d''avoir au moins un processus Sidekiq en exécution. En savoir plus sur Sidekiq.' queue_size_warning: "Il y a %{queue_size} tâches dans la file d'attente, ce qui est assez élevé. Cela peut indiquer un problème avec les processus Sidekiq ou que ces derniers ne sont pas assez nombreux pour traiter efficacement les tâches." memory_warning: "Votre serveur dispose de moins de 1 Go de mémoire vive. Au moins 1 Go de RAM est recommandé." google_oauth2_config_warning: 'Le serveur est configuré pour permettre l''authentification via Google Oauth2 (enable_google_oauth2_logins), mais les valeurs de l''identifiant client et le secret client ne sont pas renseignés. Allez dans les paramètres du site et configurez ces valeurs. Voir le guide pour en savoir plus.' @@ -1364,14 +1362,14 @@ fr: s3_backup_config_warning: 'Le serveur est configuré pour envoyer les sauvegardes sur S3, mais l''un des paramètres suivants n''est pas renseigné : s3_access_key_id, s3_secret_access_key, s3_use_iam_profile ou s3_backup_bucket. Allez dans les Paramètres et configurez ces valeurs. Consultez « Comment mettre en place une sauvegarde sur S3 ? » pour en savoir plus.' s3_cdn_warning: 'Le serveur est configuré pour envoyer les fichiers sur S3, mais aucun CDN S3 n''est configuré. Cela peut entraîner des coûts S3 élevés et réduire les performances du site. Consultez « Utiliser un stockage d''objet pour les envois » pour en savoir plus.' image_magick_warning: 'Le serveur est configuré pour créer des aperçus des grandes images, mais ImageMagick n''est pas installé. Installez ImageMagick en utilisant votre gestionnaire de paquets favori ou téléchargez directement la dernière version.' - failing_emails_warning: 'Il y a %{num_failed_jobs} tâches d''envoi de courriel échouées. Vérifiez votre fichier app.yml et assurez-vous de la conformité des paramètres du serveur de courriel. Voir aussi les processus en échec dans Sidekiq.' + failing_emails_warning: 'Il y a %{num_failed_jobs} tâches d''envoi d''e-mail échouées. Vérifiez votre fichier app.yml et assurez-vous de la conformité des paramètres du serveur d''e-mail. Voir aussi les processus en échec dans Sidekiq.' subfolder_ends_in_slash: "Votre configuration de sous-répertoire est erronée ; DISCOURSE_RELATIVE_URL_ROOT se termine avec une barre oblique ." email_polling_errored_recently: - one: "La vérification des courriels a généré une erreur au cours des 24 dernières heures. Vérifiez le journal pour plus de détails." - other: "La vérification des courriels a généré %{count} erreurs au cours des 24 dernières heures. Vérifiez le journal pour plus de détails." - missing_mailgun_api_key: "Le serveur est configuré pour envoyer des courriels via Mailgun, mais vous n'avez pas fourni la clé API nécessaire pour vérifier les messages Webhook." + one: "La vérification des e-mails a généré une erreur au cours des 24 dernières heures. Vérifiez le journal pour plus de détails." + other: "La vérification des e-mails a généré %{count} erreurs au cours des 24 dernières heures. Vérifiez le journal pour plus de détails." + missing_mailgun_api_key: "Le serveur est configuré pour envoyer des e-mails via Mailgun, mais vous n'avez pas fourni la clé API nécessaire pour vérifier les messages Webhook." bad_favicon_url: "Impossible de charger la favicon. Vérifiez le paramètre favicon dans les paramètres du site" - poll_pop3_timeout: "La connexion vers le serveur POP3 a expiré. Les courriels entrants n'ont pas pu être téléchargés. Veuillez vérifier les paramètres POP3 et votre fournisseur de service courriel." + poll_pop3_timeout: "La connexion vers le serveur POP3 a expiré. Les e-mails entrants n'ont pas pu être téléchargés. Veuillez vérifier les paramètres POP3 et votre fournisseur de service de messagerie." poll_pop3_auth_error: "La connexion vers le serveur POP3 échoue avec une erreur d'authentification. Veuillez vérifier les paramètres POP3." force_https_warning: "Votre site Web utilise SSL. Mais l'option « force_https » n'est pas encore activée dans les paramètres du site." out_of_date_themes: "Des mises à jour sont disponibles pour les thèmes suivants :" @@ -1392,7 +1390,7 @@ fr: min_personal_message_post_length: "Longueur minimale des messages en nombre de caractères" max_post_length: "Longueur maximale autorisée des messages en nombres de caractères" topic_featured_link_enabled: "Activer la publication de liens avec les sujets." - show_topic_featured_link_in_digest: "Afficher le lien du sujet à la une dans le résumé par courriel." + show_topic_featured_link_in_digest: "Afficher le lien du sujet à la une dans le résumé par e-mail." min_topic_views_for_delete_confirm: "Nombre minimum de vues qu'un sujet doit avoir pour qu'une fenêtre de confirmation apparaisse lors de sa suppression" min_topic_title_length: "Longueur minimale autorisée des titres de sujet en nombre de caractères" max_topic_title_length: "Longueur maximale autorisée des titres de sujet en nombre de caractères" @@ -1417,7 +1415,7 @@ fr: title: "Le nom du site utilisé dans la balise title." site_description: "Décrivez ce site en une seule phrase qui sera utilisée dans la métabalise de description." short_site_description: "Description courte utilisée dans la balise « title » de la page d'accueil." - contact_email: "Adresse courriel de la personne responsable de ce site. Elle est utilisée pour les notifications critiques et également affichée sur la page /about pour les questions urgentes." + contact_email: "Adresse e-mail de la personne responsable de ce site. Elle est utilisée pour les notifications critiques et également affichée sur la page /about pour les questions urgentes." contact_url: "URL de contact pour ce site. Elle est affichée sur la page À propos et les utilisateurs sont invités à l'utiliser en cas de problème urgent." crawl_images: "Récupérer les images provenant de sources tierces pour y insérer les dimensions correctes (hauteur et largeur)." download_remote_images_to_local: "Convertissez des images distantes (hotlink) en images locales en les téléchargeant. Cela préserve le contenu même si les images sont supprimées du site distant à l'avenir." @@ -1455,7 +1453,7 @@ fr: facebook_app_access_token: "Un jeton généré à partir de votre identifiant d'application Facebook et de votre secret. Utilisé pour générer des Onebox Instagram." logo: "L'image de votre logo située en haut à gauche de votre site. Utilisez une forme rectangulaire large avec une hauteur de 120 et une largeur de plus que 360. Si vous laissez ce champ vide, le nom de votre site apparaîtra." logo_small: "La petite image logo affichée en haut à gauche du site quand la page est défilée vers le bas. Utilisez une image carrée de 120 x 120. Si vous laissez ce champ vide, une icône de maison sera affichée." - digest_logo: "L'image alternative de votre logo utilisée en haut du résumé par courriel de votre site. Utilisez une image rectangulaire et suffisamment large. N'utilisez pas une image SVG. Si vous laissez ce champ vide, l'image du paramètre « logo » sera utilisée." + digest_logo: "L'image alternative de votre logo utilisée en haut du résumé par e-mail de votre site. Utilisez une image rectangulaire et suffisamment large. N'utilisez pas une image SVG. Si vous laissez ce champ vide, l'image du paramètre « logo » sera utilisée." mobile_logo: "L'image de votre logo utilisée pour la version mobile de votre site. Utilisez une image rectangulaire avec une hauteur de 120 et une largeur de plus que 360. Si vous laissez ce champ vide, l'image du paramètre « logo » sera utilisée." logo_dark: "Alternative au paramètre « logo » pour le mode sombre." logo_small_dark: "Alternative au paramètre « logo small » pour le mode sombre." @@ -1467,9 +1465,9 @@ fr: apple_touch_icon: "Icône utilisée pour les appareils Apple touch. Elle sera automatiquement redimensionnée en 180x180. Si vous laissez ce champ vide, « large_icon » sera utilisée." opengraph_image: "Image opengraph par défaut, utilisée lorsque la page n'a pas d'autre image appropriée. Si vous laissez ce champ vide, « large_icon » sera utilisée" twitter_summary_large_image: "Carte Twitter « Summary Card with Large Image » (sa taille doit être d'au moins 280 en largeur et d'au moins 150 en hauteur et ne peut pas être au format .svg). Si vous laissez ce champ vide, les métadonnées normales de carte sont générées à l'aide d'« opengraph_image » tant que celle-ci n'est pas au format .svg également." - notification_email: "L'adresse courriel utilisée pour envoyer les courriels systèmes essentiels et affichée dans le champ « De ». Le nom de domaine spécifié doit comprendre les informations SPF, DKIM et PTR inversé correctement renseignées pour que le courriel arrive à destination." - email_custom_headers: "Une liste délimitée par des barres verticales d'en-têtes de courriel" - email_subject: "Format du sujet personnalisable pour les courriels normaux. Voir https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + notification_email: "L'adresse e-mail utilisée pour envoyer les e-mails systèmes essentiels et affichée dans le champ « De ». Le nom de domaine spécifié doit comprendre les informations SPF, DKIM et PTR inversé correctement renseignées pour que l'e-mail arrive à destination." + email_custom_headers: "Une liste délimitée par des barres verticales d'en-têtes d'e-mail" + email_subject: "Format du sujet personnalisable pour les e-mails normaux. Voir https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "Donne plus d'informations aux utilisateurs concernant la raison pour laquelle ils ne peuvent pas accéder à ce sujet en particulier. Note : cela est moins sécurisé car les utilisateurs sauront si une URL pointe vers un sujet valide ou non." enforce_second_factor: "Force les utilisateurs à activer l'authentification à deux facteurs. Sélectionner « tous » pour l'appliquer à tous les utilisateurs. Sélectionner « responsables » pour l'appliquer aux responsables uniquement." force_https: "Forcer votre site en HTTPS uniquement. ATTENTION : n'activez PAS cette fonction tant que vous n'avez pas vérifié que le HTTPS est complètement configuré et fonctionne absolument partout ! Avez-vous vérifié que vos CDN, vos connexions de réseaux sociaux et tous les logos/dépendances tiers sont eux aussi compatibles avec HTTPS ?" @@ -1536,15 +1534,15 @@ fr: allowed_onebox_iframes: "Liste de domaines dans l'attribut « src » des iframes qui sont autorisés dans les prévisualisations Onebox. Le caractère « * » autorise tous les moteurs Onebox par défaut." allowed_iframes: "Une liste des préfixes du domaine src iframe que Discourse peut autoriser en toute sécurité dans les messages" allowed_crawler_user_agents: "Les agents utilisateurs des robots d'indexation qui sont autorisés à accéder au site. ATTENTION : CE PARAMÈTRE BLOQUERA TOUS LES ROBOTS D'INDEXATION NON LISTÉS ICI !" - blocked_crawler_user_agents: "Mot unique insensible à la casse dans la chaîne de caractères de l'agent utilisateur identifiant les robots d'exploration Web qui ne devraient pas être autorisés à accéder au site. Ne s'applique pas si la liste d'autorisations est définie." - slow_down_crawler_user_agents: 'Agents utilisateurs des robots d''exploration Web dont le débit doit être limité, comme configuré dans le paramètre « slow down crawler rate ». Chaque valeur doit comporter au moins 3 caractères.' + blocked_crawler_user_agents: "Mot unique insensible à la casse dans la chaîne de caractères de l'agent utilisateur identifiant les robots d'indexation Web qui ne devraient pas être autorisés à accéder au site. Ne s'applique pas si la liste d'autorisations est définie." + slow_down_crawler_user_agents: 'Agents utilisateurs des robots d''indexation Web dont le débit doit être limité, comme configuré dans le paramètre « slow down crawler rate ». Chaque valeur doit comporter au moins 3 caractères.' slow_down_crawler_rate: "Si slow_down_crawler_user_agents est défini, ce taux s'appliquera à tous les robots d'indexation (nombre de secondes d'attente entre requêtes)" content_security_policy: "Activer Content-Security-Policy" content_security_policy_report_only: "Activer Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Activer la collecte des rapports de violation CSP sur la page /csp_reports" content_security_policy_frame_ancestors: "Limiter les parties autorisées à intégrer ce site dans une iframe par le biais de l'attribut « csp ». Appliquer les restrictions d'hôtes définies dans la page Intégration externe." content_security_policy_script_src: "Sources supplémentaires de scripts acceptées. L'hôte actuel et le CDN sont inclus par défaut. Voir Empêcher les attaques XSS avec Content Security Policy." - invalidate_inactive_admin_email_after_days: "Les comptes administrateurs qui n'ont pas visité le site depuis ce nombre de jours devront revalider leur adresse courriel avant de se connecter. Mettre à 0 pour désactiver." + invalidate_inactive_admin_email_after_days: "Les comptes administrateurs qui n'ont pas visité le site depuis ce nombre de jours devront revalider leur adresse e-mail avant de se connecter. Mettre à 0 pour désactiver." top_menu: "Choisissez les éléments qui apparaissent dans la navigation de la page d'accueil, ainsi que leur ordre. Exemple latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Choisissez les éléments qui apparaissent dans le menu du message, ainsi que leur ordre. Exemple like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "Les éléments du menu qui seront masqués par défaut jusqu'à extension du menu." @@ -1564,7 +1562,7 @@ fr: topics_per_period_in_top_page: "Nombre de meilleurs sujets affichés lorsqu'on sélectionne « Voir plus » des meilleurs sujets." redirect_users_to_top_page: "Rediriger automatiquement les nouveaux utilisateurs et les longues absences sur la page Top." top_page_default_timeframe: "Période par défaut pour la page la plus vue." - moderators_view_emails: "Permettre aux modérateurs de voir les adresses courriel des utilisateurs" + moderators_view_emails: "Permettre aux modérateurs de voir les adresses e-mail des utilisateurs" prioritize_username_in_ux: "Afficher le nom d'utilisateur en premier sur la page d'un utilisateur, sa carte et ses messages (si cette option est désactivée, le nom complet est affiché en premier)" enable_rich_text_paste: "Permettre la conversion automatique du HTML en Markdown lorsque du texte est collé dans l'éditeur. (Expérimental)" send_old_credential_reminder_days: "Rappeler l'utilisation de vieux identifiants après ce nombre de jours" @@ -1574,14 +1572,14 @@ fr: enable_whispers: "Autoriser les communications privées entre responsables au sein d'un sujet." whispers_allowed_groups: "Autoriser la communication privée au sein des sujets pour les membres de groupes spécifiques." allow_index_in_robots_txt: "Indiquer dans le fichier robots.txt que ce site peut être indexé par les moteurs de recherche Web. Dans des cas exceptionnels, vous pouvez remplacer définitivement le fichier robots.txt." - blocked_email_domains: "Une liste de domaines de courriels, séparés par des barres verticales (caractère « | ») que les utilisateurs ne sont pas autorisés à utiliser pour s'inscrire. Exemple : mailinator.com|trashmail.net" - allowed_email_domains: "Une liste de domaines de courriels, séparés par des barres verticales (caractère « | ») que les utilisateurs DOIVENT utiliser pour s'inscrire. ATTENTION : les utilisateurs avec une adresse de courriel d'un domaine non repris dans la liste ne seront pas autorisés à s'inscrire !" + blocked_email_domains: "Une liste de domaines de messageries, séparés par des barres verticales (caractère « | ») que les utilisateurs ne sont pas autorisés à utiliser pour s'inscrire. Exemple : mailinator.com|trashmail.net" + allowed_email_domains: "Une liste de domaines de messageries, séparés par des barres verticales (caractère « | ») que les utilisateurs DOIVENT utiliser pour s'inscrire. ATTENTION : les utilisateurs avec une adresse e-mail d'un domaine non repris dans la liste ne seront pas autorisés à s'inscrire !" normalize_emails: "Vérifiez que l'adresse e-mail normalisée n'est pas déjà utilisée. L'adresse normalisée s'obtient en ôtant tous les points et caractères éventuellement situés entre les symboles « + » et « @ » de l'adresse indiquée par l'utilisateur." - auto_approve_email_domains: "Les utilisateurs dont le domaine de l'adresse courriel appartient à cette liste seront automatiquement approuvés." + auto_approve_email_domains: "Les utilisateurs dont le domaine de l'adresse e-mail appartient à cette liste seront automatiquement approuvés." hide_email_address_taken: "Ne pas indiquer aux utilisateurs qu'un compte associé à une adresse e-mail donnée existe sur le serveur, au moment de l'inscription ou lors de la récupération d'un mot de passe perdu. Exiger une adresse e-mail complète lors de la récupération d'un mot de passe perdu." log_out_strict: "Lors de la déconnexion, déconnecter TOUTES les sessions de l'utilisateur sur tous les appareils" version_checks: "Vérifier périodiquement les serveurs Discourse pour obtenir des informations de mises à jour et les afficher sur le tableau de bord /admin" - new_version_emails: "Envoyer un courriel à contact_email quand une nouvelle version de Discourse est disponible." + new_version_emails: "Envoyer un e-mail à contact_email quand une nouvelle version de Discourse est disponible." invite_expiry_days: "La période (en jours) pendant laquelle les clés d'invitation sont valides" invite_only: "Tous les nouveaux utilisateurs doivent être explicitement invités par des utilisateurs de confiance ou des responsables. L'inscription publique est désactivée." login_required: "Exiger l'authentification pour lire le contenu du site et interdire l'accès anonyme." @@ -1596,7 +1594,7 @@ fr: block_common_passwords: "Ne pas autoriser les mots de passe qui font partie des 10 000 mots de passe les plus utilisés." auth_skip_create_confirm: Lors de l'inscription via une authentification externe, ignorer la fenêtre contextuelle de création de compte. À utiliser de préférence avec les paramètres sso_overrides_email, sso_overrides_username et sso_overrides_name. auth_immediately: "Rediriger automatiquement vers le système d'authentification externe, sans interaction avec l'utilisateur. Cela n'a d'effet que lorsque le paramètre login_required est activé et qu'un seul système d'authentification externe est défini." - enable_discourse_connect: "Activer le système d''authentification DiscourseConnect (précédemment appelé « Discourse SSO »). (ATTENTION : LES ADRESSES DE COURRIEL DES UTILISATEURS *DOIVENT* ÊTRE VALIDÉES PAR LE SITE EXTERNE !)" + enable_discourse_connect: "Activer le système d''authentification DiscourseConnect (précédemment appelé « Discourse SSO »). (ATTENTION : LES ADRESSES E-MAIL DES UTILISATEURS *DOIVENT* ÊTRE VALIDÉES PAR LE SITE EXTERNE !)" verbose_discourse_connect_logging: "Journaliser les diagnostics étendus liés au fonctionnement de DiscourseConnect dans le répertoire /logs" enable_discourse_connect_provider: "Implémenter le protocole fournisseur de DiscourseConnect (précédemment appelé « Discourse SSO ») au point de terminaison /session/sso_provider, nécessite que le paramètre discourse_connect_provider soit défini" discourse_connect_url: "URL du point de terminaison DiscourseConnect (comprenant http:// ou https://)" @@ -1604,7 +1602,7 @@ fr: discourse_connect_provider_secrets: "Une liste de couples nom de domaine - clé secrète utilisant DiscourseConnect. Assurez-vous que la clé secrète DiscourseConnect compte au minimum 10 caractères. Le caractère de remplacement « * » peut servir à désigner n'importe quel nom de domaine ou n'importe quelle sous-partie d'un nom de domaine (p. ex. : *.exemple.com)." discourse_connect_overrides_bio: "Écrase la biographie du profil utilisateur et empêche l'utilisateur de la modifier" discourse_connect_overrides_groups: "Synchroniser l'ensemble des appartenances à des groupes définies manuellement avec les groupes spécifiés dans l'attribut « groups » (ATTENTION : si vous ne spécifiez aucun groupe, toutes les appartenances à des groupes définies manuellement par l'utilisateur seront remises à zéro)" - auth_overrides_email: "Écrase l'adresse de courriel définie localement en la remplaçant par l'adresse de courriel définie sur le site externe à chaque connexion, et interdit de la modifier localement. S'applique à tous les fournisseurs de service d'authentification. (ATTENTION : des divergences peuvent se produire en raison d'un algorithme de normalisation des adresses de courriel locales)" + auth_overrides_email: "Écrase l'adresse e-mail définie localement en la remplaçant par l'adresse e-mail définie sur le site externe à chaque connexion, et interdit de la modifier localement. S'applique à tous les fournisseurs de service d'authentification. (ATTENTION : des divergences peuvent se produire en raison d'un algorithme de normalisation des adresses e-mail locales)" auth_overrides_username: "Écrase le nom d'utilisateur défini localement en le remplaçant par le nom d'utilisateur défini sur le site externe à chaque connexion, et interdit de le modifier localement. S'applique à tous les fournisseurs de service d'authentification. (ATTENTION : des divergences peuvent se produire en raison de contraintes de longueur ou de complexité des noms d'utilisateur)" auth_overrides_name: "Écrase le nom complet défini localement en le remplaçant par le nom complet défini sur le site externe à chaque connexion, et interdit de le modifier localement. S'applique à tous les fournisseurs de service d'authentification." discourse_connect_overrides_avatar: "Écrase l'image de profil en la remplaçant par une image de profil définie par un site externe, par le biais de données transmises avec le protocole DiscourseConnect. Lorsque ce paramètre est activé, les utilisateurs ne sont pas autorisés à envoyer et utiliser leurs propres images de profil dans Discourse." @@ -1615,7 +1613,7 @@ fr: discourse_connect_not_approved_url: "Rediriger les comptes DiscourseConnect non validés vers cette URL" discourse_connect_allows_all_return_paths: "Ne pas restreindre le nom de domaine du champ return_paths indiqué par DiscourseConnect (si ce paramètres est désactivé, l'emplacement indiqué par « return path » doit se trouver sur le site actuel)" enable_local_logins: "Activer l'authentification locale avec nom d'utilisateur et mot de passe. ATTENTION : si ce paramètre est désactivé, il vous sera peut-être impossible de vous connecter si vous n'avez pas configuré au moins une méthode d'authentification alternative." - enable_local_logins_via_email: "Permettre aux utilisateurs de demander qu'un lien de connexion en un clic leur soit envoyé par courriel." + enable_local_logins_via_email: "Permettre aux utilisateurs de demander qu'un lien de connexion en un clic leur soit envoyé par e-mail." allow_new_registrations: "Autoriser l'inscription des nouveaux utilisateurs. Décocher pour empêcher la création de nouveau compte." enable_signup_cta: "Afficher un rappel aux visiteurs pour les encourager à créer un compte." enable_google_oauth2_logins: "Activer l'authentification Google Oauth2. C'est la méthode d'authentification que Google prend désormais en charge. Nécessite un identifiant et une clé secrète. Voir Comment configurer l'authentification Google pour Discourse (en anglais)." @@ -1755,7 +1753,7 @@ fr: min_trust_to_edit_post: "Le niveau de confiance minimal requis pour modifier des messages." min_trust_to_allow_self_wiki: "Le niveau de confiance minimal requis pour transformer ses propres messages en type wiki." min_trust_to_send_messages: "OBSOLÈTE, utilisez plutôt le paramètre « Groupes activés par messages directs ». Le niveau de confiance minimal requis pour créer de nouveaux messages directs." - min_trust_to_send_email_messages: "Le niveau de confiance minimal pour envoyer un message privé par courriel." + min_trust_to_send_email_messages: "Le niveau de confiance minimal pour envoyer un message privé par e-mail." min_trust_to_flag_posts: "Le niveau de confiance minimal requis pour signaler des messages" min_trust_to_post_links: "Le niveau de confiance minimal requis pour inclure des liens dans les messages" min_trust_to_post_embedded_media: "Le niveau de confiance minimal requis pour intégrer des éléments multimédia dans un message" @@ -1776,9 +1774,9 @@ fr: max_here_mentioned: "Nombre maximal de personnes recevant une notification lorsque la mention @here est utilisée." min_trust_level_for_here_mention: "Niveau de confiance minimal requis pour utiliser la mention @here." create_thumbnails: "Créer un aperçu et une visionneuse pour les images qui sont trop grandes pour le message." - email_time_window_mins: "Attendre (n) minutes avant l'envoi des courriels de notification, afin de laisser une chance aux utilisateurs de modifier ou de finaliser leurs messages." - personal_email_time_window_seconds: "Attendre (n) secondes avant d'envoyer des courriels de notification directs, afin de donner aux utilisateurs la possibilité de modifier et de finaliser leurs messages." - email_posts_context: "Combien de réponses précédentes doit-on inclure dans les courriels de notification pour situer le contexte." + email_time_window_mins: "Attendre (n) minutes avant l'envoi des e-mails de notification, afin de laisser une chance aux utilisateurs de modifier ou de finaliser leurs messages." + personal_email_time_window_seconds: "Attendre (n) secondes avant d'envoyer des e-mails de notification directs, afin de donner aux utilisateurs la possibilité de modifier et de finaliser leurs messages." + email_posts_context: "Combien de réponses précédentes doit-on inclure dans les e-mails de notification pour situer le contexte." flush_timings_secs: "À quelle fréquence les données de timing doivent être vidées, en secondes." title_max_word_length: "Le nombre maximal de caractères dans le titre d'un sujet." title_min_entropy: "L'entropie minimale (caractères uniques, les caractères en langues étrangères comptent davantage) requise pour le titre d'un sujet." @@ -1824,10 +1822,10 @@ fr: staff_like_weight: "La pondération à accorder aux « J'aime » provenant d'un responsable (les autres « J'aime » ont une pondération de 1)." topic_view_duration_hours: "Compter la vue d'un sujet une seule fois par IP ou par utilisateur toutes les N heures" user_profile_view_duration_hours: "Compter la vue d'un profil d'utilisateur une seule fois par IP ou par utilisateur qui visite toutes les N heures" - levenshtein_distance_spammer_emails: "Une adresse courriel sera attribuée à un spammeur connu même si elle diffère par ce nombre de caractères." + levenshtein_distance_spammer_emails: "Une adresse e-mail sera attribuée à un spammeur connu même si elle diffère par ce nombre de caractères." max_new_accounts_per_registration_ip: "S'il y a déjà (n) comptes avec un niveau de confiance de 0 utilisant cette adresse IP (et si aucun n'est un responsable ni un utilisateur avec un niveau de confiance de 2 ou plus), ne plus accepter de nouvelles inscriptions en provenance de cette adresse IP. Le réglage de cette valeur à 0 désactive cette limite." min_ban_entries_for_roll_up: "En cliquant sur le bouton Consolider, une liste d'au moins (N) adresses interdites sera remplacée par une plage de sous réseau." - max_age_unmatched_emails: "Effacer les adresses courriel sous surveillance sans correspondance après (N) jours" + max_age_unmatched_emails: "Effacer les adresses e-mail sous surveillance sans correspondance après (N) jours" max_age_unmatched_ips: "Effacer les adresses IP sous surveillance sans correspondance après (N) jours" num_flaggers_to_close_topic: "Nombre minimal de signalements uniques requis pour suspendre automatiquement un sujet pour intervention" num_hours_to_close_topic: "Nombre d'heures de fermeture d'un sujet pour intervention." @@ -1843,91 +1841,91 @@ fr: high_trust_flaggers_auto_hide_posts: "Les messages des nouveaux utilisateurs seront automatiquement masqués après avoir été signalés comme spam par un utilisateur d'un niveau de confiance 3 et plus" cooldown_hours_until_reflag: "Combien de temps les utilisateurs doivent attendre avant de pouvoir signaler à nouveau un message" slow_mode_prevents_editing: "Le « mode lent » empêche-t-il l'édition après editing_grace_period ?" - reply_by_email_enabled: "Activer les réponses aux sujets via courriel." - reply_by_email_address: "Modèle pour la réponse par courriel entrant, exemple : %%{reply_key}@reply.example.com ou replies+%%{reply_key}@example.com" - alternative_reply_by_email_addresses: "Liste des modèles alternatifs pour les adresses des courriels entrants de la réponse par courriel. Exemple : %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" - incoming_email_prefer_html: "Utilisez du HTML plutôt que du texte pour les courriels entrants." - strip_incoming_email_lines: "Supprimer les espaces en début et fin de chaque ligne de courriel entrants." - disable_emails: "Empêcher Discourse d'envoyer tout type de courriels. Sélectionnez « oui » pour désactiver les courriels pour tous les utilisateurs. Sélectionnez « non responsables » pour désactiver les courriels uniquement pour les utilisateurs qui ne sont pas des responsables." - strip_images_from_short_emails: "Retirer les images des courriels dont la taille est inférieure à 2 800 octets" - short_email_length: "Taille des courriels courts en octets" - display_name_on_email_from: "Afficher les noms complets dans le champ « De » des courriels" - unsubscribe_via_email: "Autoriser les utilisateurs à se désabonner des courriels en envoyant un courriel avec « unsubscribe » dans le sujet ou le corps du message." - unsubscribe_via_email_footer: "Inclure un lien de désabonnement dans le pied des courriels envoyés" + reply_by_email_enabled: "Activer les réponses aux sujets par e-mail." + reply_by_email_address: "Modèle pour la réponse par e-mail entrant, exemple : %%{reply_key}@reply.example.com ou replies+%%{reply_key}@example.com" + alternative_reply_by_email_addresses: "Liste des modèles alternatifs pour les adresses des e-mails entrants de la réponse par e-mail. Exemple : %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" + incoming_email_prefer_html: "Utilisez du HTML plutôt que du texte pour les e-mails entrants." + strip_incoming_email_lines: "Supprimer les espaces en début et fin de chaque ligne d'e-mail entrants." + disable_emails: "Empêcher Discourse d'envoyer tout type d'e-mails. Sélectionnez « oui » pour désactiver les e-mails pour tous les utilisateurs. Sélectionnez « non responsables » pour désactiver les e-mails uniquement pour les utilisateurs qui ne sont pas des responsables." + strip_images_from_short_emails: "Retirer les images des e-mails dont la taille est inférieure à 2 800 octets" + short_email_length: "Taille des e-mails courts en octets" + display_name_on_email_from: "Afficher les noms complets dans le champ « De » des e-mails" + unsubscribe_via_email: "Autoriser les utilisateurs à se désabonner des e-mails en envoyant un e-mail avec « unsubscribe » dans le sujet ou le corps du message." + unsubscribe_via_email_footer: "Inclure un lien de désabonnement dans le pied des e-mails envoyés" delete_email_logs_after_days: "Effacer les journaux de messagerie après (N) jours. 0 pour conserver indéfiniment." - disallow_reply_by_email_after_days: "Interdire la réponse par courriel après (N) jours. 0 pour permettre indéfiniment." - max_emails_per_day_per_user: "Nombre maximal de courriels à envoyer aux utilisateurs par jour. 0 pour désactiver la limite" - enable_staged_users: "Créer automatiquement des utilisateurs distants lors du traitement des courriels entrants." - maximum_staged_users_per_email: "Nombre maximal d'utilisateurs distants créés lors du traitement d'un courriel entrant." + disallow_reply_by_email_after_days: "Interdire la réponse par e-mail après (N) jours. 0 pour permettre indéfiniment." + max_emails_per_day_per_user: "Nombre maximal d'e-mails à envoyer aux utilisateurs par jour. 0 pour désactiver la limite" + enable_staged_users: "Créer automatiquement des utilisateurs distants lors du traitement des e-mails entrants." + maximum_staged_users_per_email: "Nombre maximal d'utilisateurs distants créés lors du traitement d'un e-mail entrant." maximum_recipients_per_new_group_email: "Bloquez les e-mails entrants contenant trop de destinataires." - auto_generated_allowlist: "Liste des adresses courriel qui ne seront pas vérifiées pour du contenu généré automatiquement. Exemple : foo@bar.com|discourse@bar.com" - block_auto_generated_emails: "Bloquer les courriels entrants identifiés comme étant générés automatiquement." - ignore_by_title: "Ignorer les courriels entrants selon leur titre." + auto_generated_allowlist: "Liste des adresses e-mail qui ne seront pas vérifiées pour du contenu généré automatiquement. Exemple : foo@bar.com|discourse@bar.com" + block_auto_generated_emails: "Bloquer les e-mails entrants identifiés comme étant générés automatiquement." + ignore_by_title: "Ignorer les e-mails entrants selon leur titre." mailgun_api_key: "Clé API secrète de Mailgun utilisée pour vérifier les messages du Webhook." - soft_bounce_score: "Valeur ajoutée au taux de courriels non délivrés de l'utilisateur quand un courriel ne peut pas être délivré temporairement." - hard_bounce_score: "Valeur ajoutée au taux de courriels non délivrés de l'utilisateur quand plus aucun courriel ne peut être délivré à son adresse." - bounce_score_threshold: "Taux maximal de courriels non délivrés à partir duquel nous arrêterons d'envoyer des courriels à un utilisateur." - reset_bounce_score_after_days: "Réinitialiser automatiquement le taux de courriels non délivrés après X jours." + soft_bounce_score: "Valeur ajoutée au taux d'e-mails non délivrés de l'utilisateur quand un e-mail ne peut pas être délivré temporairement." + hard_bounce_score: "Valeur ajoutée au taux d'e-mails non délivrés de l'utilisateur quand plus aucun e-mail ne peut être délivré à son adresse." + bounce_score_threshold: "Taux maximal d'e-mails non délivrés à partir duquel nous arrêterons d'envoyer des e-mails à un utilisateur." + reset_bounce_score_after_days: "Réinitialiser automatiquement le taux d'e-mails non délivrés après X jours." blocked_attachment_content_types: "Liste des mots-clés utilisés pour filtrer les pièces jointes en fonction du type de contenu." blocked_attachment_filenames: "Liste des mots-clés utilisés pour filtrer les pièces jointes en fonction du nom de fichier." - forwarded_emails_behaviour: "Comment traiter un courriel transféré à Discourse" - always_show_trimmed_content: "Toujours montrer la partie réduite des courriels entrants. ATTENTION : cela peut révéler les adresses courriel." - trim_incoming_emails: "Débarrasser les courriels entrants des passages qui ne sont pas pertinents." - private_email: "Ne pas inclure le contenu de messages ou de sujets dans le titre ou le corps du courriel. REMARQUE : désactive également les courriels de résumé." - email_total_attachment_size_limit_kb: "Taille totale maximale des fichiers joints aux courriels expédiés, en Ko. Si ce paramètre est défini à la valeur 0, l'envoi de pièces jointes sera désactivé." - post_excerpts_in_emails: "Dans les courriels de notification, toujours envoyer des extraits au lieu de messages complets." - raw_email_max_length: "Combien de caractères doivent être stockés pour le courriel entrant." - raw_rejected_email_max_length: "Combien de caractères doivent être stockés pour le courriel entrant rejeté." - delete_rejected_email_after_days: "Supprimer les courriels rejetés remontant à plus de (n) jours." + forwarded_emails_behaviour: "Comment traiter un e-mail transféré à Discourse" + always_show_trimmed_content: "Toujours montrer la partie réduite des e-mails entrants. ATTENTION : cela peut révéler les adresses e-mail." + trim_incoming_emails: "Débarrasser les e-mails entrants des passages qui ne sont pas pertinents." + private_email: "Ne pas inclure le contenu de messages ou de sujets dans le titre ou le corps de l'e-mail. REMARQUE : désactive également les e-mails de résumé." + email_total_attachment_size_limit_kb: "Taille totale maximale des fichiers joints aux e-mails expédiés, en Ko. Si ce paramètre est défini à la valeur 0, l'envoi de pièces jointes sera désactivé." + post_excerpts_in_emails: "Dans les e-mails de notification, toujours envoyer des extraits au lieu de messages complets." + raw_email_max_length: "Combien de caractères doivent être stockés pour l'e-mail entrant." + raw_rejected_email_max_length: "Combien de caractères doivent être stockés pour l'e-mail entrant rejeté." + delete_rejected_email_after_days: "Supprimer les e-mails rejetés remontant à plus de (n) jours." require_change_email_confirmation: "Demandez aux utilisateurs qui ne sont pas des responsables de confirmer leur ancienne adresse e-mail avant de la modifier. Ne s'applique pas aux responsables, ils doivent toujours confirmer leur ancienne adresse e-mail." - manual_polling_enabled: "Envoyer les courriels en utilisant l'API des réponses par courriel." - pop3_polling_enabled: "Utiliser POP3 pour les réponses via courriel." + manual_polling_enabled: "Envoyer les e-mails en utilisant l'API des réponses par e-mail." + pop3_polling_enabled: "Utiliser POP3 pour les réponses par e-mail." pop3_polling_ssl: "Utiliser SSL pour les connexions au serveur POP3. (Recommandé)" pop3_polling_openssl_verify: "Vérifier le certificat TLS du serveur (activé par défaut)" - pop3_polling_period_mins: "La période en minutes entre chaque vérification du compte POP3 des courriels.\nNote : nécessite un redémarrage de la machine." + pop3_polling_period_mins: "La période en minutes entre chaque vérification du compte POP3 des e-mails.\nNote : nécessite un redémarrage de la machine." pop3_polling_port: "Le port sur lequel interroger un compte POP3." - pop3_polling_host: "L'hôte à interroger pour les courriels via POP3." - pop3_polling_username: "Le nom d'utilisateur du compte POP3 à interroger pour les courriels." - pop3_polling_password: "Le mot de passe du compte POP3 à interroger pour les courriels." - pop3_polling_delete_from_server: "Supprimer les courriels du serveur. NOTE : si vous désactivez ceci vous devrez vider manuellement votre boîte de réception courriel." - log_mail_processing_failures: "Enregistrer tous les problèmes de courriel dans /logs" - email_in: 'Autoriser les utilisateurs à créer de nouveaux sujets par courriel (nécessite une requête manuelle ou via POP3). Configurez les adresses dans l''onglet « Paramètres » de chaque catégorie.' - email_in_min_trust: "Le niveau de confiance minimal qu'un utilisateur doit avoir pour être autorisé à créer de nouveaux sujets par courriel." - email_in_authserv_id: "L'identifiant du service vérifiant l'authentification des courriels entrants. Voir https://meta.discourse.org/t/134358 pour obtenir plus d'informations." - email_in_spam_header: "En-tête courriel pour détecter du spam." + pop3_polling_host: "L'hôte à interroger pour les e-mails via POP3." + pop3_polling_username: "Le nom d'utilisateur du compte POP3 à interroger pour les e-mails." + pop3_polling_password: "Le mot de passe du compte POP3 à interroger pour les e-mails." + pop3_polling_delete_from_server: "Supprimer les e-mails du serveur. NOTE : si vous désactivez ceci vous devrez vider manuellement votre boîte de réception." + log_mail_processing_failures: "Enregistrer tous les problèmes d'e-mail dans /logs" + email_in: 'Autoriser les utilisateurs à créer de nouveaux sujets par e-mail (nécessite une requête manuelle ou via POP3). Configurez les adresses dans l''onglet « Paramètres » de chaque catégorie.' + email_in_min_trust: "Le niveau de confiance minimal qu'un utilisateur doit avoir pour être autorisé à créer de nouveaux sujets par e-mail." + email_in_authserv_id: "L'identifiant du service vérifiant l'authentification des e-mails entrants. Voir https://meta.discourse.org/t/134358 pour obtenir plus d'informations." + email_in_spam_header: "En-tête d'e-mail pour détecter du spam." enable_imap: "Activer IMAP pour synchroniser les messages de groupes." enable_imap_write: "Activer la synchronisation IMAP bidirectionnelle. Si elle est désactivée, toutes les opérations d'écriture sur les comptes IMAP sont désactivées." - enable_imap_idle: "Utiliser le mécanisme IMAP IDLE pour attendre de nouveaux courriels." + enable_imap_idle: "Utiliser le mécanisme IMAP IDLE pour attendre de nouveaux e-mails." enable_smtp: "Activer SMTP pour l'envoi de notifications concernant les messages de groupe." - imap_polling_period_mins: "La durée en minutes entre chaque vérification des comptes IMAP pour les nouveaux courriels." - imap_polling_old_emails: "Le nombre maximal d'anciens courriels (traités) à mettre à jour chaque fois qu'une boîte IMAP est interrogée (0 pour tous)." - imap_polling_new_emails: "Le nombre maximal de nouveaux courriels (non traités) à mettre à jour chaque fois qu'une boîte IMAP est interrogée." - imap_batch_import_email: "Le nombre minimal de nouveaux courriels qui déclenchent le mode d'importation (désactive les alertes de messages)." - email_prefix: "Le [label] qui sera utilisé dans le sujet des courriels. Par défaut il prend la valeur de « title »." - email_site_title: "Le titre du site utilisé comme expéditeur pour les courriels du site. Par défaut il prend la valeur de « title ». Si votre « title » utilise des caractères interdits dans les courriels, utilisez ce paramètre." - find_related_post_with_key: "N'utiliser que la « reply key » pour trouver le message auquel on a répondu. ATTENTION : la désactivation de cette option permet l'usurpation d'identité de l'utilisateur sur la base de l'adresse courriel." + imap_polling_period_mins: "La durée en minutes entre chaque vérification des comptes IMAP pour les nouveaux e-mails." + imap_polling_old_emails: "Le nombre maximal d'anciens e-mails (traités) à mettre à jour chaque fois qu'une boîte IMAP est interrogée (0 pour tous)." + imap_polling_new_emails: "Le nombre maximal de nouveaux e-mails (non traités) à mettre à jour chaque fois qu'une boîte IMAP est interrogée." + imap_batch_import_email: "Le nombre minimal de nouveaux e-mails qui déclenchent le mode d'importation (désactive les alertes de messages)." + email_prefix: "Le [label] qui sera utilisé dans le sujet des e-mails. Par défaut il prend la valeur de « title »." + email_site_title: "Le titre du site utilisé comme expéditeur pour les e-mails du site. Par défaut il prend la valeur de « title ». Si votre « title » utilise des caractères interdits dans les e-mails, utilisez ce paramètre." + find_related_post_with_key: "N'utiliser que la « reply key » pour trouver le message auquel on a répondu. ATTENTION : la désactivation de cette option permet l'usurpation d'identité de l'utilisateur sur la base de l'adresse e-mail." minimum_topics_similar: "Combien de sujets ont besoin d'exister dans la base de données avant que des sujets similaires soient présentés lors de la rédaction de nouveaux sujets." relative_date_duration: "Nombre de jours après la création d'un message à partir desquels les dates seront affichées en absolu (20 Fév.) plutôt qu'en relatif (7j)" delete_user_max_post_age: "Interdire la suppression des utilisateurs dont le premier message est daté de plus de (n) jours." delete_all_posts_max: "Le nombre maximal de messages qui peuvent être supprimés en une seule fois avec le bouton « Supprimer tous les messages ». Si un utilisateur détient un nombre de messages supérieur à cette limite, ses messages ne pourront pas être supprimés en une seule fois et l'utilisateur ne pourra pas être supprimé." delete_user_self_max_post_count: "Le nombre maximal de messages qu'un utilisateur peut avoir pour l'autoriser à supprimer son propre compte. La valeur -1 empêche les utilisateurs de supprimer leur propre compte." username_change_period: "Le nombre de jours maximal après l'inscription pendant lesquels l'utilisateur peut encore modifier son nom d'utilisateur (0 pour empêcher la modification du nom d'utilisateur)." - email_editable: "Autoriser les utilisateurs à modifier leur adresse courriel après l'inscription." + email_editable: "Autoriser les utilisateurs à modifier leur adresse e-mail après l'inscription." logout_redirect: "URL vers laquelle rediriger le navigateur après déconnexion (par exemple : https://example.com/logout)" allow_uploaded_avatars: "Autoriser les utilisateurs à envoyer une photo de profil personnalisée." default_avatars: "Adresses URL des avatars qui seront utilisés par défaut pour les nouveaux utilisateurs jusqu'à ce qu'ils les modifient." - automatically_download_gravatars: "Télécharger les Gravatars des utilisateurs lors de la création du compte ou lors de la modification de l'adresse courriel." - digest_topics: "Le nombre maximal de sujets populaires à afficher dans le résumé par courriel." - digest_posts: "Le nombre maximal de messages populaires à afficher dans le résumé par courriel." - digest_other_topics: "Le nombre maximal de sujets à afficher dans la section « Nouveautés dans les sujets et catégories que vous suivez » du résumé par courriel." - digest_min_excerpt_length: "Longueur minimale (en caractères) de l'extrait des messages dans le résumé par courriel." - suppress_digest_email_after_days: "Ne pas envoyer de résumés par courriel aux utilisateurs qui n'ont pas visité le site depuis plus de (n) jours." - digest_suppress_categories: "Ne pas inclure ces catégories dans les résumés par courriel." - disable_digest_emails: "Désactiver les résumés par courriel pour tous les utilisateurs." - apply_custom_styles_to_digest: "Appliquer des modèles et un CSS personnalisés pour les courriels de résumé." - email_accent_bg_color: "La couleur d'accentuation utilisée comme arrière-plan de certains éléments des courriels HTML. Saisissez un nom de couleur (« red ») ou une valeur hexadécimale (« #FF0000 »)." - email_accent_fg_color: "La couleur des textes rendus sur la couleur d'arrière-plan des courriels HTML. Saisissez un nom de couleur (« white ») ou une valeur hexadécimale (« #FFFFFF »)." - email_link_color: "La couleur des liens dans les courriels HTML. Saisissez un nom de couleur (« blue ») ou une valeur hexadécimale (« #0000FF »)." + automatically_download_gravatars: "Télécharger les Gravatars des utilisateurs lors de la création du compte ou lors de la modification de l'adresse e-mail." + digest_topics: "Le nombre maximal de sujets populaires à afficher dans le résumé par e-mail." + digest_posts: "Le nombre maximal de messages populaires à afficher dans le résumé par e-mail." + digest_other_topics: "Le nombre maximal de sujets à afficher dans la section « Nouveautés dans les sujets et catégories que vous suivez » du résumé par e-mail." + digest_min_excerpt_length: "Longueur minimale (en caractères) de l'extrait des messages dans le résumé par e-mail." + suppress_digest_email_after_days: "Ne pas envoyer de résumés par e-mail aux utilisateurs qui n'ont pas visité le site depuis plus de (n) jours." + digest_suppress_categories: "Ne pas inclure ces catégories dans les résumés par e-mail." + disable_digest_emails: "Désactiver les résumés par e-mail pour tous les utilisateurs." + apply_custom_styles_to_digest: "Appliquer des modèles et un CSS personnalisés pour les e-mails de résumé." + email_accent_bg_color: "La couleur d'accentuation utilisée comme arrière-plan de certains éléments des e-mails HTML. Saisissez un nom de couleur (« red ») ou une valeur hexadécimale (« #FF0000 »)." + email_accent_fg_color: "La couleur des textes rendus sur la couleur d'arrière-plan des e-mails HTML. Saisissez un nom de couleur (« white ») ou une valeur hexadécimale (« #FFFFFF »)." + email_link_color: "La couleur des liens dans les e-mails HTML. Saisissez un nom de couleur (« blue ») ou une valeur hexadécimale (« #0000FF »)." detect_custom_avatars: "Vérifier ou non si les utilisateurs ont envoyé une photo de profil personnalisée." max_daily_gravatar_crawls: "Nombre maximal de fois où Discourse vérifiera Gravatar pour des avatars personnalisés en une journée." public_user_custom_fields: "Une liste des champs personnalisables qui peuvent être récupérés avec l'API." @@ -1935,7 +1933,7 @@ fr: enable_user_directory: "Activer l'annuaire des utilisateurs" enable_group_directory: "Activer l'annuaire des groupes" enable_category_group_moderation: "Autoriser les groupes à modérer le contenu dans des catégories spécifiques" - group_in_subject: "Mettre %%{optional_pm} dans le sujet du courriel au nom du premier groupe du message direct. Voir : personnaliser le format du sujet des courriels standards" + group_in_subject: "Mettre %%{optional_pm} dans le sujet de l'e-mail au nom du premier groupe du message direct. Voir : personnaliser le format du sujet des e-mails standards" allow_anonymous_posting: "Permettre aux utilisateurs de passer en mode anonyme" anonymous_posting_min_trust_level: "Le niveau de confiance minimal pour passer en mode anonyme." anonymous_account_duration_minutes: "Pour protéger l'anonymat, créer un nouveau compte anonyme toutes les N minutes pour chaque utilisateur. Exemple : si la valeur 600 est choisie, lorsque 600 minutes seront passées après le dernier message ET que l'utilisateur passera en mode anonyme, un nouveau compte anonyme lui sera créé." @@ -1981,7 +1979,7 @@ fr: svg_icon_subset: "Ajouter des icônes supplémentaires FontAwesome 5 que vous souhaitez inclure dans vos médias. Utilisez le préfixe « fa- » pour les icônes solides, « far- » pour les icônes normales et « fab- » pour les icônes de marques." max_prints_per_hour_per_user: "Nombre maximal d'accès à la page /print (mettre à 0 pour désactiver)" full_name_required: "Le nom complet est requis dans le profil utilisateur." - enable_names: "Autoriser l'affichage des noms complets des utilisateurs dans leur profil, sur leur carte d'utilisateur et dans les courriels. Décocher pour masquer les noms complets partout." + enable_names: "Autoriser l'affichage des noms complets des utilisateurs dans leur profil, sur leur carte d'utilisateur et dans les e-mails. Décocher pour masquer les noms complets partout." display_name_on_posts: "Afficher le nom complet de l'utilisateur dans ses messages en plus de son nom d'utilisateur." show_time_gap_days: "Si deux messages sont publiés avec ce nombre de jours d'écart, afficher cette durée dans le sujet." short_progress_text_threshold: "Si le nombre de messages dans un sujet dépasse cette valeur, la barre de progression affichera uniquement le nombre de messages actuel. Si vous modifiez la largeur de la barre de progression, vous devrez peut-être modifier cette valeur." @@ -2022,7 +2020,7 @@ fr: approve_unless_trust_level: "Les messages des utilisateurs qui n'ont pas atteint ce niveau de confiance doivent être approuvés" approve_new_topics_unless_trust_level: "Les nouveaux sujets des utilisateurs en dessous de ce niveau de confiance doivent être approuvés" approve_unless_staged: "Les nouveaux sujets et messages des utilisateurs distants doivent être approuvés" - notify_about_queued_posts_after: "Après tant d'heures écoulées, envoyer un courriel à tous les modérateurs si des messages sont en attente d'examen. Désactiver l'envoi de ces courriels en renseignant la valeur « 0 »." + notify_about_queued_posts_after: "Après tant d'heures écoulées, envoyer un e-mail à tous les modérateurs si des messages sont en attente d'examen. Désactiver l'envoi de ces e-mails en renseignant la valeur « 0 »." auto_close_messages_post_count: "Nombre maximal de messages dans un message privé avant qu'il soit automatiquement fermé (0 pour désactiver)" auto_close_topics_post_count: "Nombre maximal de messages dans un sujet avant qu'il soit automatiquement fermé (0 pour désactiver)" auto_close_topics_create_linked_topic: "Créer un nouveau sujet lié lorsqu'un sujet est automatiquement fermé du fait du paramètre « auto close topics post count »" @@ -2040,15 +2038,15 @@ fr: enable_page_publishing: "Autoriser les responsables de publier des sujets vers de nouvelles URL utilisant leur propre style." show_published_pages_login_required: "Les utilisateurs anonymes peuvent voir les pages publiées, même quand la connexion est nécessaire." skip_auto_delete_reply_likes: "Lors de la suppression automatique des anciennes réponses, cette option permet d'ignorer la suppression des messages ayant ce nombre ou un nombre supérieur de « J'aime »." - default_email_digest_frequency: "Par défaut, à quelle fréquence les utilisateurs reçoivent les résumés par courriel." - default_include_tl0_in_digests: "Par défaut, inclure les messages des nouveaux utilisateurs dans les résumés par courriel. Les utilisateurs peuvent changer cela dans leurs préférences." - default_email_level: "Définissez le niveau de notification courriel par défaut pour les sujets standards." - default_email_messages_level: "Définissez le niveau de notification courriel par défaut quand quelqu'un envoie un message direct à un utilisateur." - default_email_mailing_list_mode: "Envoyer un courriel pour chaque nouveau message." - default_email_mailing_list_mode_frequency: "Par défaut, les utilisateurs ayant activé la liste de diffusion recevront des courriels à cette fréquence." - disable_mailing_list_mode: "Interdire aux utilisateurs d'activer le mode « liste de diffusion » (empêche tout courriel d'être expédié vers une liste de diffusion)." - default_email_previous_replies: "Inclure par défaut les réponses précédentes dans les courriels." - default_email_in_reply_to: "Inclure par défaut l'extrait du message auquel se fait la réponse dans les courriels." + default_email_digest_frequency: "Par défaut, à quelle fréquence les utilisateurs reçoivent les résumés par e-mail." + default_include_tl0_in_digests: "Par défaut, inclure les messages des nouveaux utilisateurs dans les résumés par e-mail. Les utilisateurs peuvent changer cela dans leurs préférences." + default_email_level: "Définissez le niveau de notification par e-mail par défaut pour les sujets standards." + default_email_messages_level: "Définissez le niveau de notification par e-mail par défaut quand quelqu'un envoie un message direct à un utilisateur." + default_email_mailing_list_mode: "Envoyer un e-mail pour chaque nouveau message." + default_email_mailing_list_mode_frequency: "Par défaut, les utilisateurs ayant activé la liste de diffusion recevront des e-mails à cette fréquence." + disable_mailing_list_mode: "Interdire aux utilisateurs d'activer le mode « liste de diffusion » (empêche tout e-mail d'être expédié vers une liste de diffusion)." + default_email_previous_replies: "Inclure par défaut les réponses précédentes dans les e-mails." + default_email_in_reply_to: "Inclure par défaut l'extrait du message auquel se fait la réponse dans les e-mails." default_other_new_topic_duration_minutes: "Condition par défaut pour considérer un sujet comme nouveau." default_other_auto_track_topics_after_msecs: "Durée par défaut après laquelle un sujet est automatiquement suivi." default_other_notification_level_when_replying: "Niveau de notification global par défaut quand un utilisateur répond à un sujet." @@ -2127,7 +2125,7 @@ fr: enable_new_user_profile_nav_groups: "EXPÉRIMENTAL : les utilisateurs des groupes sélectionnés verront apparaître le nouveau menu de navigation du profil utilisateur" errors: invalid_css_color: "Couleur non valide. Indiquez un nom de couleur ou une valeur hexadécimale." - invalid_email: "Adresse courriel invalide." + invalid_email: "Adresse e-mail invalide." invalid_username: "Il n'y a aucun utilisateur ayant ce nom d'utilisateur." valid_username: "Ce nom d'utilisateur est déjà pris." invalid_group: "Il n'y a aucun groupe ayant ce nom." @@ -2142,15 +2140,15 @@ fr: invalid_string_min: "La chaîne de caractères doit contenir au moins %{min} caractères." invalid_string_max: "La chaîne de caractères doit contenir au plus %{max} caractères." invalid_json: "Données JSON non valides." - invalid_reply_by_email_address: "La valeur doit contenir « %{reply_key} » et être différente du courriel de notification." - invalid_alternative_reply_by_email_addresses: "Toutes les valeurs doivent contenir « %{reply_key} » et être différentes du courriel de notification." + invalid_reply_by_email_address: "La valeur doit contenir « %{reply_key} » et être différente de l'e-mail de notification." + invalid_alternative_reply_by_email_addresses: "Toutes les valeurs doivent contenir « %{reply_key} » et être différentes de l'e-mail de notification." invalid_domain_hostname: "Ne peut inclure les caractères « * » ou « ? »." pop3_polling_host_is_empty: "Vous devez définir « pop3 polling host » avant d'activer le relevé via POP3." pop3_polling_username_is_empty: "Vous devez définir « pop3 polling username » avant d'activer le relevé via POP3." pop3_polling_password_is_empty: "Vous devez définir « pop3 polling password » avant d'activer le relevé via POP3." pop3_polling_authentication_failed: "Échec de l'authentification POP3. Veuillez vérifier vos identifiants POP3." - reply_by_email_address_is_empty: "Vous devez configurer le paramètre « reply by email address » avant d'activer les réponses par courriel." - email_polling_disabled: "Vous devez activer le relevé manuel ou via POP3 avant d'activer les réponses par courriel." + reply_by_email_address_is_empty: "Vous devez configurer le paramètre « reply by email address » avant d'activer les réponses par e-mail." + email_polling_disabled: "Vous devez activer le relevé manuel ou via POP3 avant d'activer les réponses par e-mail." user_locale_not_enabled: "Vous devez d'abord activer « allow user locale » avant d'activer ce paramètre." personal_message_enabled_groups_invalid: "Vous devez spécifier au moins un groupe pour ce paramètre. Si vous ne souhaitez pas que quelqu'un d'autre que les responsables envoie des messages directs, choisissez le groupe des responsables." invalid_regex: "L'expression régulière est invalide ou non autorisée." @@ -2198,12 +2196,12 @@ fr: discourse_connect: login_error: "Erreur de connexion" not_found: "Votre compte est introuvable. Veuillez contacter l'administrateur du site." - account_not_approved: "Votre compte est en attente d'approbation. Vous recevrez une notification par courriel lorsqu'il sera approuvé." + account_not_approved: "Votre compte est en attente d'approbation. Vous recevrez une notification par e-mail lorsqu'il sera approuvé." unknown_error: "Votre compte présente un problème. Nous vous invitons à contacter l'administrateur du site." timeout_expired: "Le délai de connexion au compte est arrivé à expiration, nous vous invitons à réessayer." - no_email: "Aucune adresse courriel n'a été fournie. Veuillez contacter l'administrateur du site." + no_email: "Aucune adresse e-mail n'a été fournie. Veuillez contacter l'administrateur du site." blank_id_error: "Le champ « external_id » est requis mais il a été laissé vide." - email_error: "Il n'a pas été possible de créer un compte avec l'adresse de courriel %{email}. Nous vous invitons à contacter l'administrateur du site." + email_error: "Il n'a pas été possible de créer un compte avec l'adresse e-mail %{email}. Nous vous invitons à contacter l'administrateur du site." missing_secret: "L'authentification a échoué du fait de l'absence d'une clé secrète. Nous vous invitons à contacter l'administrateur du site afin de résoudre ce problème." invite_redeem_failed: "Il n'a pas été possible d'utiliser ce lien d'invitation. Nous vous invitons à contacter l'administrateur du site." original_poster: "Créateur du sujet" @@ -2297,13 +2295,13 @@ fr: security_key_no_matching_credential_error: "Aucun identifiant correspondant n'a pu être trouvé dans la clé de sécurité donnée." security_key_support_missing_error: "Votre appareil ou navigateur actuel ne prend pas en charge l'utilisation des clés de sécurité. Veuillez utiliser une autre méthode." security_key_invalid: "Une erreur est survenue lors de la validation de la clé de sécurité." - not_approved: "Votre compte n'a pas encore été approuvé. Vous recevrez une notification par courriel lorsque vous pourrez vous connecter." - incorrect_username_email_or_password: "Nom d'utilisateur, adresse courriel ou mot de passe incorrect(e)" + not_approved: "Votre compte n'a pas encore été approuvé. Vous recevrez une notification par e-mail lorsque vous pourrez vous connecter." + incorrect_username_email_or_password: "Nom d'utilisateur, adresse e-mail ou mot de passe incorrect(e)" incorrect_password: "Mot de passe incorrect" wait_approval: "Merci pour votre inscription. Nous vous informerons lorsque votre compte sera approuvé." active: "Votre compte est activé et prêt à l'emploi." - activate_email: "

    C'est presque terminé ! Nous avons envoyé un courriel d'activation à %{email}. Merci de suivre les instructions qui y figurent pour activer votre compte.

    Si vous ne recevez pas ce courriel, vérifiez le dossier du courrier indésirable dans votre messagerie.

    " - not_activated: "Vous ne pouvez pas vous connecter pour le moment. Nous vous avons envoyé un courriel d'activation. Merci de suivre les instructions qui y figurent pour activer votre compte." + activate_email: "

    C'est presque terminé ! Nous avons envoyé un e-mail d'activation à %{email}. Merci de suivre les instructions qui y figurent pour activer votre compte.

    Si vous ne recevez pas cet e-mail, vérifiez le dossier du courrier indésirable dans votre messagerie.

    " + not_activated: "Vous ne pouvez pas vous connecter pour le moment. Nous vous avons envoyé un e-mail d'activation. Merci de suivre les instructions qui y figurent pour activer votre compte." not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter en tant que %{username} depuis cette adresse IP." admin_not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter en tant qu'administrateur à partir de cette adresse IP." reset_not_allowed_from_ip_address: "Vous ne pouvez pas réinitialiser le mot de passe depuis cette adresse IP." @@ -2312,7 +2310,7 @@ fr: suspended_with_reason_forever: "Compte supsendu : %{reason}" errors: "%{errors}" not_available: "Indisponible. Essayer %{suggestion} ?" - something_already_taken: "Un problème est survenu. Votre nom d'utilisateur ou votre adresse courriel est peut-être déjà enregistré(e). Essayez le lien d'oubli du mot de passe." + something_already_taken: "Un problème est survenu. Votre nom d'utilisateur ou votre adresse e-mail est peut-être déjà enregistré(e). Essayez le lien d'oubli du mot de passe." omniauth_error: generic: "Nous sommes désolés, une erreur s'est produite lors de l'autorisation de votre compte. Veuillez réessayer." csrf_detected: "L'autorisation a expiré ou vous avez changé de navigateur. Veuillez réessayer." @@ -2321,10 +2319,10 @@ fr: omniauth_error_unknown: "Un problème est survenu lors de votre connexion. Veuillez réessayer." omniauth_confirm_title: "Connectez-vous à l'aide de %{provider}" omniauth_confirm_button: "Continuer" - authenticator_error_no_valid_email: "Aucune adresse courriel associée à %{account} n'est autorisée. Vous devrez peut-être configurer votre compte avec une autre adresse." + authenticator_error_no_valid_email: "Aucune adresse e-mail associée à %{account} n'est autorisée. Vous devrez peut-être configurer votre compte avec une autre adresse." new_registrations_disabled: "La création de nouveaux comptes n'est pas autorisée pour le moment." password_too_long: "Les mots de passe sont limités à 200 caractères." - email_too_long: "Le courriel renseigné est trop long. Les noms de boîte courriel ne doivent pas dépasser 254 caractères, et les noms de domaines ne doivent pas dépasser 253 caractères." + email_too_long: "L'e-mail renseigné est trop long. Les noms d'adresses e-mail ne doivent pas dépasser 254 caractères et les noms de domaines ne doivent pas dépasser 253 caractères." wrong_invite_code: "Le code d'invitation est invalide." reserved_username: "Ce nom d'utilisateur n'est pas autorisé." missing_user_field: "Vous n'avez pas renseigné l'ensemble des champs utilisateur" @@ -2367,7 +2365,7 @@ fr: updating_user_ids: "Mise à jour des identifiants utilisateur…" deleting_source_user: "Suppression de l'utilisateur source…" user: - deactivated: "A été désactivé car trop de courriels envoyés à « %{email} » n'ont pas été délivrés." + deactivated: "A été désactivé car trop d'e-mails envoyés à « %{email} » n'ont pas été délivrés." deactivated_by_staff: "Désactivé par un responsable" deactivated_by_inactivity: one: "Désactivé automatiquement après %{count} jour d'inactivité" @@ -2389,9 +2387,9 @@ fr: must_not_end_with_confusing_suffix: "ne doit pas se terminer par un suffixe déroutant comme « .json » ou « .png » etc." email: invalid: "est invalide." - not_allowed: "n'est pas autorisé pour ce fournisseur de courriels. Veuillez utiliser une autre adresse." + not_allowed: "n'est pas autorisé pour ce fournisseur d'e-mails. Veuillez utiliser une autre adresse." blocked: "n'est pas autorisé." - revoked: "N'enverra plus de courriels à « %{email} » jusqu'au %{date}." + revoked: "N'enverra plus d'e-mails à « %{email} » jusqu'au %{date}." does_not_exist: "S.O." ip_address: blocked: "Les nouvelles inscriptions ne sont pas acceptées depuis votre adresse IP." @@ -2401,12 +2399,12 @@ fr: auto_rejected: "Refusé automatiquement en raison de l'âge. Voir le paramètre de site « auto_handle_queued_age »." destroy_reasons: unused_staged_user: "Utilisateur distant inutilisé" - fixed_primary_email: "Correction du courriel principal pour l'utilisateur distant" + fixed_primary_email: "Correction de l'e-mail principal pour l'utilisateur distant" same_ip_address: "Même adresse IP (%{ip_address}) que d'autres utilisateurs" inactive_user: "Utilisateur inactif" reviewable_reject_auto: "Traiter automatiquement les éléments à examiner en file d'attente" reviewable_reject: "L'inscription de cet utilisateur a été refusée" - email_in_spam_header: "Le premier courriel de l'utilisateur a été signalé comme spam" + email_in_spam_header: "Le premier e-mail de l'utilisateur a été signalé comme spam" already_silenced: "L'utilisateur a déjà été mis en sourdine par %{staff} il y a %{time_ago}." already_suspended: "L'utilisateur a déjà été suspendu par %{staff} il y a %{time_ago}." cannot_delete_has_posts: @@ -2414,15 +2412,15 @@ fr: other: "L'utilisateur %{username} a %{count} messages dans des sujets publics ou messages personnels, vous ne pouvez donc pas le supprimer." unsubscribe_mailer: title: "Désabonnement" - subject_template: "Confirmez que vous ne souhaitez plus recevoir de courriels de mises à jour de %{site_title}" + subject_template: "Confirmez que vous ne souhaitez plus recevoir d'e-mails de mises à jour de %{site_title}" text_body_template: | - Quelqu'un (peut-être vous ?) a demandé à ne plus recevoir de mises à jour pour %{site_domain_name} sur cette adresse courriel. + Quelqu'un (peut-être vous ?) a demandé à ne plus recevoir de mises à jour pour %{site_domain_name} sur cette adresse e-mail. Pour le confirmer, merci de cliquer sur ce lien : %{confirm_unsubscribe_link} - Si vous souhaitez continuer à recevoir ces courriels, vous pouvez ignorer ce message. + Si vous souhaitez continuer à recevoir ces e-mails, vous pouvez ignorer ce message. invite_mailer: title: "Invitation" subject_template: "%{inviter_name} vous a invité(e) à participer à « %{topic_title} » sur %{site_domain_name}" @@ -2509,14 +2507,14 @@ fr: %{base_url}/u/password-reset/%{email_token} - (Si le lien est expiré, cliquez sur « J'ai oublié mon mot de passe » au moment de vous connecter avec votre adresse courriel.) + (Si le lien est expiré, cliquez sur « J'ai oublié mon mot de passe » au moment de vous connecter avec votre adresse e-mail.) download_backup_mailer: title: "Téléchargement de la sauvegarde" subject_template: "[%{email_prefix}] Téléchargement de la sauvegarde du site" text_body_template: | Voici le lien vers la [sauvegarde du site](%{backup_file_path}) que vous avez demandée. - Nous avons envoyé ce lien à votre adresse courriel validée pour des raisons de sécurité. + Nous avons envoyé ce lien à votre adresse e-mail validée pour des raisons de sécurité. (Si vous *n'avez pas* effectué cette demande, il y a lieu de vous inquiéter : quelqu'un a accès à l'administration de votre site.) no_token: | @@ -2529,8 +2527,8 @@ fr: [Confirmer le compte administrateur](%{admin_confirm_url}) test_mailer: - title: "Envoyer un courriel de test" - subject_template: "[%{email_prefix}] Test de délivrabilité du courriel" + title: "Envoyer un e-mail de test" + subject_template: "[%{email_prefix}] Test de délivrabilité de l'e-mail" text_body_template: | Ceci est un e-mail de test expédié par @@ -2557,7 +2555,7 @@ fr: - Effectuez facilement la mise à jour depuis votre navigateur en utilisant la **[mise à jour en 1 clic](%{base_url}/admin/upgrade)** - - Découvrez les nouveautés en consultant les [notes de version](https://meta.discourse.org/tags/release-notes) ou l'[historique GitHub](https://github.com/discourse/discourse/commits/master) + - Découvrez les nouveautés en consultant les [notes de version](https://meta.discourse.org/tag/release-notes) ou l'[historique GitHub](https://github.com/discourse/discourse/commits/main) - Rendez vous sur [meta.discourse.org](https://meta.discourse.org) pour consulter des actualités, des discussions et obtenir de l'aide concernant Discourse new_version_mailer_with_notes: @@ -2571,7 +2569,7 @@ fr: - Effectuez facilement la mise à jour depuis votre navigateur en utilisant la **[mise à jour en 1 clic](%{base_url}/admin/upgrade)** - - Découvrez les nouveautés en consultant les [notes de version](https://meta.discourse.org/tags/release-notes) ou l'[historique GitHub](https://github.com/discourse/discourse/commits/master) + - Découvrez les nouveautés en consultant les [notes de version](https://meta.discourse.org/tag/release-notes) ou l'[historique GitHub](https://github.com/discourse/discourse/commits/main) - Rendez vous sur [meta.discourse.org](https://meta.discourse.org) pour consulter des actualités, des discussions et obtenir de l'aide concernant Discourse @@ -2745,7 +2743,7 @@ fr: - Nous vous avons créé un nouveau compte : **%{username}**. Vous pourrez modifier votre nom d'utilisateur ou votre mot de passe en vous rendant sur [votre profil utilisateur][prefs]. - - Au moment de vous connecter, nous vous prions **d'indiquer l'adresse de courriel à laquelle votre invitation a été adressée**. Autrement, nous ne saurons pas qu'il s'agit de vous ! + - Au moment de vous connecter, nous vous prions **d'indiquer l'adresse e-mail à laquelle votre invitation a été adressée**. Autrement, nous ne saurons pas qu'il s'agit de vous ! %{new_user_tips} @@ -2848,198 +2846,198 @@ fr: subject_template: "Échec de l'exportation des données" text_body_template: "Nous sommes désolés, votre exportation de données a échoué. Veuillez consulter les journaux ou [contacter un responsable](%{base_url}/about)." email_reject_insufficient_trust_level: - title: "Courriel rejeté - Niveau de confiance insuffisant" - subject_template: "[%{email_prefix}] Problème de courriel - Niveau de confiance insuffisant" + title: "E-mail rejeté - Niveau de confiance insuffisant" + subject_template: "[%{email_prefix}] Problème d'e-mail - Niveau de confiance insuffisant" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Votre compte n'a pas le niveau de confiance requis pour créer un nouveau sujet via cette adresse courriel. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). + Votre compte n'a pas le niveau de confiance requis pour créer un nouveau sujet via cette adresse e-mail. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_user_not_found: - title: "Courriel rejeté - Utilisateur introuvable" - subject_template: "[%{email_prefix}] Problème de courriel -- L'utilisateur est introuvable" + title: "E-mail rejeté - Utilisateur introuvable" + subject_template: "[%{email_prefix}] Problème d'e-mail -- L'utilisateur est introuvable" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Votre réponse a été envoyée depuis une adresse inconnue. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). email_reject_screened_email: - title: "Courriel rejeté - Courriel sous surveillance" - subject_template: "[%{email_prefix}] Problème de courriel - Courriel bloqué" + title: "E-mail rejeté - E-mail sous surveillance" + subject_template: "[%{email_prefix}] Problème d'e-mail - E-mail bloqué" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Votre réponse a été envoyée depuis une adresse bloquée. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). email_reject_not_allowed_email: - title: "Courriel rejeté - Courriel bloqué" - subject_template: "[%{email_prefix}] Problème de courriel - Courriel bloqué" + title: "E-mail rejeté - E-mail non autorisé" + subject_template: "[%{email_prefix}] Problème d'e-mail - E-mail bloqué" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Votre réponse a été envoyée depuis une adresse bloquée. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). email_reject_inactive_user: - title: "Courriel rejeté - Utilisateur inactif" - subject_template: "[%{email_prefix}] Problème de courriel - Utilisateur inactif" + title: "E-mail rejeté - Utilisateur inactif" + subject_template: "[%{email_prefix}] Problème d'e-mail - Utilisateur inactif" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Le compte associé à cette adresse n'est pas activé. Veuillez activer votre compte avant d'envoyer des courriels. + Le compte associé à cette adresse n'est pas activé. Veuillez activer votre compte avant d'envoyer des e-mails. email_reject_silenced_user: - title: "Courriel rejeté - Utilisateur mis en sourdine" - subject_template: "[%{email_prefix}] Problème de courriel - Utilisateur mis en sourdine" + title: "E-mail rejeté - Utilisateur mis en sourdine" + subject_template: "[%{email_prefix}] Problème d'e-mail - Utilisateur mis en sourdine" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Le compte associé à cette adresse a été mis en sourdine. email_reject_reply_user_not_matching: - title: "Courriel rejeté - L'utilisateur ne correspond pas" - subject_template: "[%{email_prefix}] Problème de courriel - Adresse de réponse inattendue" + title: "E-mail rejeté - L'utilisateur ne correspond pas" + subject_template: "[%{email_prefix}] Problème d'e-mail - Adresse de réponse inattendue" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Votre réponse nous est parvenue d'une autre adresse courriel que celle attendue, nous ne savons donc pas s'il s'agit de la même personne. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). + Votre réponse nous est parvenue d'une autre adresse e-mail que celle attendue, nous ne savons donc pas s'il s'agit de la même personne. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). email_reject_empty: - title: "Courriel rejeté - Vide" - subject_template: "[%{email_prefix}] Problème de courriel - Aucun contenu" + title: "E-mail rejeté - Vide" + subject_template: "[%{email_prefix}] Problème d'e-mail - Aucun contenu" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Assurez-vous d'avoir écrit votre réponse au début du courriel. + Assurez-vous d'avoir écrit votre réponse au début de l'e-mail. Si vous obtenez ce message alors que vous aviez inclus une réponse, réessayez avec une mise en forme plus simple. email_reject_parsing: - title: "Courriel rejeté - Parsing" - subject_template: "[%{email_prefix}] Problème de courriel - Contenu non reconnu" + title: "E-mail rejeté - Parsing" + subject_template: "[%{email_prefix}] Problème d'e-mail - Contenu non reconnu" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Nous n'avons pas pu trouver votre réponse dans le courriel. **Assurez-vous que votre réponse se trouve au début du courriel** - nous ne pouvons pas traiter les réponses en ligne. + Nous n'avons pas pu trouver votre réponse dans l'e-mail. **Assurez-vous que votre réponse se trouve au début de l'e-mail** - nous ne pouvons pas traiter les réponses en ligne. email_reject_invalid_access: - title: "Courriel rejeté - Accès invalide" - subject_template: "[%{email_prefix}] Problème de courriel - Accès invalide" + title: "E-mail rejeté - Accès invalide" + subject_template: "[%{email_prefix}] Problème d'e-mail - Accès invalide" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Votre compte n'a pas le niveau de confiance requis pour créer un nouveau sujet dans cette catégorie. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_strangers_not_allowed: - title: "Courriel rejeté - Inconnus interdits" - subject_template: "[%{email_prefix}] Problème de courriel - Accès invalide" + title: "E-mail rejeté - Inconnus interdits" + subject_template: "[%{email_prefix}] Problème d'e-mail - Accès invalide" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - La catégorie vers laquelle vous avez envoyé ce courriel autorise uniquement les réponses d'utilisateurs avec un compte valide et une adresse courriel connue. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). + La catégorie vers laquelle vous avez envoyé cet e-mail autorise uniquement les réponses d'utilisateurs avec un compte valide et une adresse e-mail connue. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_invalid_post: - title: "Courriel rejeté - Message invalide" - subject_template: "[%{email_prefix}] Problème de courriel - Erreur d'envoi d'un message" + title: "E-mail rejeté - Message invalide" + subject_template: "[%{email_prefix}] Problème d'e-mail - Erreur d'envoi d'un message" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Cela peut-être dû à une mise en forme trop complexe ou à un message trop long ou trop court. Veuillez réessayer ou rédigez votre message directement sur le site si le problème persiste. email_reject_invalid_post_specified: - title: "Courriel rejeté - Message invalide spécifié" - subject_template: "[%{email_prefix}] Problème de courriel - Erreur d'envoi d'un message" + title: "E-mail rejeté - Message invalide spécifié" + subject_template: "[%{email_prefix}] Problème d'e-mail - Erreur d'envoi d'un message" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Raison : %{post_error} Si vous pouvez corriger les erreurs, veuillez réessayer. - date_invalid: "Aucune date de création de message trouvée. Est-ce qu'il manque un en-tête Date à ce courriel ?" + date_invalid: "Aucune date de création de message trouvée. Est-ce qu'il manque un en-tête Date à cet e-mail ?" email_reject_post_too_short: - title: "Courriel rejeté trop court" - subject_template: "[%{email_prefix}] Problème de courriel - Message trop court" + title: "E-mail rejeté trop court" + subject_template: "[%{email_prefix}] Problème d'e-mail - Message trop court" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Afin de favoriser des discussions plus approfondies, les réponses très courtes ne sont pas permises. Est-ce que vous pouvez reformuler votre réponse avec au moins %{count} caractères ? Vous pouvez aussi aimer un message par courriel en répondant « +1 ». + Afin de favoriser des discussions plus approfondies, les réponses très courtes ne sont pas permises. Est-ce que vous pouvez reformuler votre réponse avec au moins %{count} caractères ? Vous pouvez aussi aimer un message par e-mail en répondant « +1 ». email_reject_invalid_post_action: - title: "Courriel rejeté - Message action invalide" - subject_template: "[%{email_prefix}] Problème de courriel - Action de message invalide" + title: "E-mail rejeté - Message action invalide" + subject_template: "[%{email_prefix}] Problème d'e-mail - Action de message invalide" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Le Post Action n'était pas reconnu. Veuillez réessayer ou rédigez votre message directement sur le site si le problème persiste. email_reject_reply_key: - title: "Courriel rejeté - Clé de réponse" - subject_template: "[%{email_prefix}] Problème de courriel - Clé de réponse inconnue" + title: "E-mail rejeté - Clé de réponse" + subject_template: "[%{email_prefix}] Problème d'e-mail - Clé de réponse inconnue" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. La clé de réponse est invalide ou inconnue et nous ne pouvons pas savoir à quel message s’adresse cette réponse. [Contactez un responsable](%{base_url}/about). email_reject_bad_destination_address: - title: "Courriel rejeté - Mauvaise adresse de destination" - subject_template: "[%{email_prefix}] Problème de courriel - Adresse de destinataire inconnue" + title: "E-mail rejeté - Mauvaise adresse de destination" + subject_template: "[%{email_prefix}] Problème d'e-mail - Adresse de destinataire inconnue" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Voici quelques points à vérifier : - - Utilisez-vous plusieurs adresses de courriel ? Avez-vous répondu en utilisant une adresse différente de celle que vous avez utilisé au départ ? Les réponses par courriel nécessitent que vous utilisiez la même adresse de courriel. + - Utilisez-vous plusieurs adresses e-mail ? Avez-vous répondu en utilisant une adresse différente de celle que vous avez utilisé au départ ? Les réponses par e-mail nécessitent que vous utilisiez la même adresse e-mail. - Votre client de messagerie a-t-il correctement utilisé le champ « Reply-To: » pour définir le destinataire de votre réponse ? Certains logiciels utilisent malheureusement le champ « From: » à la place, ce qui est incorrect et ne fonctionne pas dans le cas qui nous préoccupe. - - L'en-tête « Message-ID » du courriel a-t-il été modifié ? Cet en-tête doit conserver une valeur constante. + - L'en-tête « Message-ID » de l'e-mail a-t-il été modifié ? Cet en-tête doit conserver une valeur constante. En cas de besoin, vous pourrez obtenir davantage d'aide en consultant la page %{base_url}/about. email_reject_old_destination: - title: "Courriel rejeté - Destination obsolète" - subject_template: "[%{email_prefix}] Problème de courriel - Vous essayez de répondre à une ancienne notification" + title: "E-mail rejeté - Destination obsolète" + subject_template: "[%{email_prefix}] Problème d'e-mail - Vous essayez de répondre à une ancienne notification" text_body_template: | - Nous sommes désolé, mais l'envoi de votre courriel à destination de %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolé, mais l'envoi de votre e-mail à destination de %{destination} (intitulé %{former_title}) n'a pas fonctionné. Pour des raisons de sécurité, nous n'acceptons les réponses aux notifications que pendant %{number_of_days} jours. Veuillez vous rendre sur [le sujet](%{short_url}) pour continuer la conversation. email_reject_topic_not_found: - title: "Courriel rejeté - Sujet introuvable" - subject_template: "[%{email_prefix}] Problème de courriel - Sujet introuvable" + title: "E-mail rejeté - Sujet introuvable" + subject_template: "[%{email_prefix}] Problème d'e-mail - Sujet introuvable" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Le sujet auquel vous répondez n'existe plus. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_topic_closed: - title: "Courriel rejeté - Sujet fermé" - subject_template: "[%{email_prefix}] Problème de courriel - Sujet fermé" + title: "E-mail rejeté - Sujet fermé" + subject_template: "[%{email_prefix}] Problème d'e-mail - Sujet fermé" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Le sujet auquel vous répondez est actuellement fermé et n'accepte plus de réponses. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_auto_generated: - title: "Courriel rejeté - Réponse automatique" - subject_template: "[%{email_prefix}] Problème de courriel - Réponse automatique" + title: "E-mail rejeté - Réponse automatique" + subject_template: "[%{email_prefix}] Problème d'e-mail - Réponse automatique" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Votre courriel a été marqué comme « généré automatiquement », ce qui signifie qu'il a été créé par un ordinateur au lieu d'être rédigé par un humain ; nous ne pouvons pas accepter ce type de courriel. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). + Votre e-mail a été marqué comme « généré automatiquement », ce qui signifie qu'il a été créé par un ordinateur au lieu d'être rédigé par un humain ; nous ne pouvons pas accepter ce type d'e-mail. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_unrecognized_error: - title: "Courriel rejeté - Erreur inconnue" - subject_template: "[%{email_prefix}] Problème de courriel - Erreur inconnue" + title: "E-mail rejeté - Erreur inconnue" + subject_template: "[%{email_prefix}] Problème d'e-mail - Erreur inconnue" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Une erreur inconnue est survenue lors du traitement de votre courriel. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). + Une erreur inconnue est survenue lors du traitement de votre e-mail. Essayez de l'envoyer depuis une autre adresse ou [contactez un responsable](%{base_url}/about). email_reject_attachment: title: "Pièce jointe rejetée" - subject_template: "[%{email_prefix}] Problème de courriel - Pièce jointe rejetée" + subject_template: "[%{email_prefix}] Problème d'e-mail - Pièce jointe rejetée" text_body_template: | - Nous sommes désolés, mais des pièces jointes au courriel que vous avez envoyé à %{destination} (intitulé %{former_title}) ont été rejetées. + Nous sommes désolés, mais des pièces jointes à l'e-mail que vous avez envoyé à %{destination} (intitulé %{former_title}) ont été rejetées. Détails : %{rejected_errors} Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_reply_not_allowed: - title: "Courriel rejeté - Réponses interdites" - subject_template: "[%{email_prefix}] Problème de courriel - Réponse non autorisée" + title: "E-mail rejeté - Réponses interdites" + subject_template: "[%{email_prefix}] Problème d'e-mail - Réponse non autorisée" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Vous n'êtes pas autorisé(e) à répondre au sujet. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_reply_to_digest: - title: "Courriel rejeté - Réponse au résumé" - subject_template: "[%{email_prefix}] Problème de courriel - Réponse au résumé" + title: "E-mail rejeté - Réponse au résumé" + subject_template: "[%{email_prefix}] Problème d'e-mail - Réponse au résumé" text_body_template: | - Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. + Nous sommes désolés, mais l'envoi de votre e-mail à %{destination} (intitulé %{former_title}) n'a pas fonctionné. - Vous avez répondu à un courriel de résumé, ce qui n'est pas accepté. + Vous avez répondu à un e-mail de résumé, ce qui n'est pas accepté. Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable](%{base_url}/about). email_reject_too_many_recipients: @@ -3052,23 +3050,23 @@ fr: Si vous pensez qu'il s'agit d'une erreur, [contactez un responsable] (%{base_url}/about). email_error_notification: - title: "Notification d'erreur courriel" - subject_template: "[%{email_prefix}] Problème de courriel - Erreur d'authentification POP3" + title: "Notification d'erreur d'e-mail" + subject_template: "[%{email_prefix}] Problème d'e-mail - Erreur d'authentification POP3" text_body_template: | - Malheureusement, une erreur d'authentification s'est produite lors de la récupération des courriels depuis le serveur POP. + Malheureusement, une erreur d'authentification s'est produite lors de la récupération des e-mails depuis le serveur POP. Assurez-vous que les paramètres de connexion POP sont corrects dans les [paramètres du site](%{base_url}/admin/site_settings/category/email). S'il y a une interface Web pour le compte POP, vous devrez peut-être vous y connecter pour vérifier les paramètres. email_revoked: - title: "Courriel révoqué" - subject_template: "Est-ce que votre adresse courriel est correcte ?" + title: "E-mail révoqué" + subject_template: "Est-ce que votre adresse e-mail est correcte ?" text_body_template: | - Nous sommes désolés, mais nous avons des difficultés à vous joindre par courriel. Nos derniers courriels n'ont pas été délivrés. + Nous sommes désolés, mais nous avons des difficultés à vous joindre par e-mail. Nos derniers e-mailsn'ont pas été délivrés. - Pouvez-vous vous assurer que [votre adresse courriel](%{base_url}/my/preferences/email) est valide et fonctionne ? Vous pouvez également ajouter notre adresse courriel à votre carnet d'adresses ou liste de contacts pour améliorer la réception de nos courriels. + Pouvez-vous vous assurer que [votre adresse e-mail](%{base_url}/my/preferences/email) est valide et fonctionne ? Vous pouvez également ajouter notre adresse e-mail à votre carnet d'adresses ou liste de contacts pour améliorer la réception de nos e-mails. email_bounced: | - Le courriel envoyé à %{email} n'a pas été délivré . + L'e-mail envoyé à %{email} n'a pas été délivré . ### Détails @@ -3184,13 +3182,13 @@ fr: Des messages de nouveaux utilisateurs sont en attente de modération. Nous vous invitons à [les approuver ou les rejeter](%{base_url}/review?type=ReviewableQueuedPost). unsubscribe_link: | - Pour vous désabonner de ces courriels, [cliquez ici](%{unsubscribe_url}). + Pour vous désabonner de ces e-mails, [cliquez ici](%{unsubscribe_url}). unsubscribe_link_and_mail: | - Pour vous désabonner de ces courriels, [cliquez ici](%{unsubscribe_url}). + Pour vous désabonner de ces e-mails, [cliquez ici](%{unsubscribe_url}). unsubscribe_mailing_list: | - Vous recevez ce courriel car vous avez activé la liste de diffusion. + Vous recevez cet e-mail car vous avez activé la liste de diffusion. - Pour vous désabonner de ces courriels, [cliquez ici](%{unsubscribe_url}). + Pour vous désabonner de ces e-mails, [cliquez ici](%{unsubscribe_url}). subject_re: "Re : " subject_pm: "[MD] " email_from: "%{user_name} via %{site_name}" @@ -3198,16 +3196,16 @@ fr: user_notifications: previous_discussion: "Réponses précédentes" reached_limit: - one: "Rappel : nous envoyons un maximum de %{count} courriel quotidien. Accédez au site pour le consulter." - other: "Rappel : nous envoyons un maximum de %{count} courriels quotidiens. Accédez au site pour voir lesquels pourraient être retenus. P.S. : merci d'être populaire !" + one: "Rappel : nous envoyons un maximum de %{count} e-mail quotidien. Accédez au site pour le consulter." + other: "Rappel : nous envoyons un maximum de %{count} e-mails quotidiens. Accédez au site pour voir lesquels pourraient être retenus. P.S. : merci d'être populaire !" in_reply_to: "En réponse à" unsubscribe: title: "Se désabonner" - description: "Ces courriels ne vous intéressent pas ? Aucun problème ! Cliquez ci-dessous pour vous désabonner immédiatement :" - reply_by_email: "[Voir le sujet](%{base_url}%{url}) ou répondre à ce courriel pour répondre." - reply_by_email_pm: "[Voir le message](%{base_url}%{url}) ou répondre à ce courriel pour répondre à %{participants}." - only_reply_by_email: "Répondre à ce courriel pour répondre." - only_reply_by_email_pm: "Répondre à ce courriel pour répondre à %{participants}." + description: "Ces e-mailsne vous intéressent pas ? Aucun problème ! Cliquez ci-dessous pour vous désabonner immédiatement :" + reply_by_email: "[Voir le sujet](%{base_url}%{url}) ou répondre à cet e-mail pour répondre." + reply_by_email_pm: "[Voir le message](%{base_url}%{url}) ou répondre à cet e-mail pour répondre à %{participants}." + only_reply_by_email: "Répondre à cet e-mail pour répondre." + only_reply_by_email_pm: "Répondre à cet e-mail pour répondre à %{participants}." visit_link_to_respond: "[Voir le sujet](%{base_url}%{url}) pour répondre." visit_link_to_respond_pm: "[Voir le message](%{base_url}%{url}) pour répondre à %{participants}." reply_above_line: "## Veuillez saisir votre réponse au-dessus de cette ligne. ##" @@ -3464,11 +3462,11 @@ fr: title: "Le compte existe déjà" subject_template: "[%{email_prefix}] Le compte existe déjà" text_body_template: | - Vous venez d'essayer de créer un compte sur %{site_name}, ou d'essayer de changer l'adresse courriel d'un compte vers %{email}. Cependant, un compte existe déjà pour %{email}. + Vous venez d'essayer de créer un compte sur %{site_name}, ou d'essayer de changer l'adresse e-mail d'un compte vers %{email}. Cependant, un compte existe déjà pour %{email}. Si vous avez oublié votre mot de passe, [réinitialisez-le maintenant](%{base_url}/password-reset). - Si vous n'avez pas essayé de créer un compte avec l'adresse %{email} ou de changer votre adresse courriel, ne vous inquiétez pas. Vous pouvez ignorer ce message. + Si vous n'avez pas essayé de créer un compte avec l'adresse %{email} ou de changer votre adresse e-mail, ne vous inquiétez pas. Vous pouvez ignorer ce message. Si vous avez des questions, [contactez notre équipe](%{base_url}/about). account_second_factor_disabled: @@ -3495,7 +3493,7 @@ fr: more_new: "Nouveau pour vous" subject_template: "[%{email_prefix}] Résumé" unsubscribe: "Ce résumé vous est envoyé par %{site_link} lorsque vous ne vous connectez pas pendant un moment. Vous pouvez %{email_preferences_link} ou %{unsubscribe_link} pour vous désabonner." - your_email_settings: "vos préférences de courriel" + your_email_settings: "vos préférences d'e-mail" click_here: "cliquez ici" from: "%{site_name}" preheader: "Un bref résumé depuis votre dernière visite le %{last_seen_at}" @@ -3505,7 +3503,7 @@ fr: text_body_template: | Quelqu'un a demandé la réinitialisation de votre mot de passe sur [%{site_name}](%{base_url}). - Si ce n'est pas vous, vous pouvez ignorer ce courriel. + Si ce n'est pas vous, vous pouvez ignorer cet e-mail. Cliquez sur le lien suivant pour définir un nouveau mot de passe : @@ -3516,7 +3514,7 @@ fr: text_body_template: | Voici votre lien pour vous connecter à [%{site_name}](%{base_url}). - Si vous n'en avez pas fait la demande, vous pouvez ignorer ce courriel. + Si vous n'en avez pas fait la demande, vous pouvez ignorer cet e-mail. Sinon, cliquez sur le lien suivant pour vous connecter : @@ -3525,9 +3523,9 @@ fr: title: "Définir le mot de passe" subject_template: "[%{email_prefix}] Définir le mot de passe" text_body_template: | - Quelqu'un a demandé un mot de passe pour votre compte sur [%{site_name}](%{base_url}). Vous pouvez vous identifier en utilisant un service en ligne pris en charge (Google, Facebook, etc.) qui est associé avec cette adresse courriel. + Quelqu'un a demandé un mot de passe pour votre compte sur [%{site_name}](%{base_url}). Vous pouvez vous identifier en utilisant un service en ligne pris en charge (Google, Facebook, etc.) qui est associé avec cette adresse e-mail. - Si vous n'avez pas fait cette demande, vous pouvez ignorer ce courriel. + Si vous n'avez pas fait cette demande, vous pouvez ignorer cet e-mail. Cliquez sur le lien suivant pour définir un mot de passe : @@ -3538,7 +3536,7 @@ fr: text_body_template: | Quelqu'un a demandé à se connecter sur votre compte sur [%{site_name}](%{base_url}). - Si vous n'avez pas fait cette demande, vous pouvez ignorer ce courriel. + Si vous n'avez pas fait cette demande, vous pouvez ignorer cet e-mail. Cliquez sur le lien ci-dessous pour vous connecter : @@ -3553,59 +3551,59 @@ fr: %{base_url}/u/password-reset/%{email_token} confirm_new_email: - title: "Confirmer votre nouvelle adresse courriel" - subject_template: "[%{email_prefix}] Confirmez votre nouvelle adresse courriel" + title: "Confirmer votre nouvelle adresse e-mail" + subject_template: "[%{email_prefix}] Confirmez votre nouvelle adresse e-mail" text_body_template: | - Confirmez votre nouvelle adresse courriel pour %{site_name} en cliquant sur le lien suivant : + Confirmez votre nouvelle adresse e-mail pour %{site_name} en cliquant sur le lien suivant : %{base_url}/u/confirm-new-email/%{email_token} Si vous n'avez pas demandé ce changement, veuillez contacter un [administrateur](%{base_url}/about). confirm_new_email_via_admin: - title: "Confirmer votre nouvelle adresse courriel" - subject_template: "[%{email_prefix}] Confirmez votre nouvelle adresse courriel" + title: "Confirmer votre nouvelle adresse e-mail" + subject_template: "[%{email_prefix}] Confirmez votre nouvelle adresse e-mail" text_body_template: | - Confirmez votre nouvelle adresse courriel pour %{site_name} en cliquant sur le lien suivant : + Confirmez votre nouvelle adresse e-mail pour %{site_name} en cliquant sur le lien suivant : %{base_url}/u/confirm-new-email/%{email_token} Ce changement d'adresse a été demandé par un administrateur du site. Si vous n'avez pas demandé ce changement, veuillez contacter un [administrateur](%{base_url}/about). confirm_old_email: - title: "Confirmez votre ancienne adresse courriel" - subject_template: "[%{email_prefix}] Confirmez votre adresse courriel actuelle" + title: "Confirmez votre ancienne adresse e-mail" + subject_template: "[%{email_prefix}] Confirmez votre adresse e-mail actuelle" text_body_template: | - Avant de pouvoir modifier votre adresse courriel, nous avons besoin de confirmer que vous contrôlez bien ce compte de messagerie. Une fois cette étape terminée, nous vous demanderons de confirmer la nouvelle adresse courriel. + Avant de pouvoir modifier votre adresse e-mail, nous avons besoin de confirmer que vous contrôlez bien ce compte de messagerie. Une fois cette étape terminée, nous vous demanderons de confirmer la nouvelle adresse e-mail. - Confirmez votre adresse courriel actuelle pour %{site_name} en cliquant sur le lien suivant : + Confirmez votre adresse e-mail actuelle pour %{site_name} en cliquant sur le lien suivant : %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email_add: title: "Confirmation à la précédente adresse (ajout)" - subject_template: "[%{email_prefix}] Confirmez votre adresse courriel actuelle" + subject_template: "[%{email_prefix}] Confirmez votre adresse e-mail actuelle" text_body_template: | - Avant de pouvoir ajouter une nouvelle adresse courriel, nous avons besoin de confirmer que vous contrôlez bien ce compte de messagerie. Une fois cette étape terminée, nous vous demanderons de confirmer la nouvelle adresse courriel. + Avant de pouvoir ajouter une nouvelle adresse e-mail, nous avons besoin de confirmer que vous contrôlez bien ce compte de messagerie. Une fois cette étape terminée, nous vous demanderons de confirmer la nouvelle adresse e-mail. - Confirmez votre adresse courriel actuelle pour %{site_name} en cliquant sur le lien suivant : + Confirmez votre adresse e-mail actuelle pour %{site_name} en cliquant sur le lien suivant : %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: - title: "Notifier l'ancienne adresse courriel" - subject_template: "[%{email_prefix}] Votre adresse courriel a été modifiée" + title: "Notifier l'ancienne adresse e-mail" + subject_template: "[%{email_prefix}] Votre adresse e-mail a été modifiée" text_body_template: | - Ceci est un message automatique pour vous informer que votre adresse courriel pour + Ceci est un message automatique pour vous informer que votre adresse e-mail pour %{site_name} a été modifiée. S'il s'agit d'une erreur, veuillez contacter un administrateur du site. - Votre adresse courriel a été modifiée comme : + Votre adresse e-mail a été modifiée comme : %{new_email} notify_old_email_add: title: "Notification à la précédente adresse (ajout)" - subject_template: "[%{email_prefix}] Une nouvelle adresse courriel a été ajoutée" + subject_template: "[%{email_prefix}] Une nouvelle adresse e-mail a été ajoutée" text_body_template: | - Ceci est un message automatique pour vous informer qu'une adresse courriel pour %{site_name} a été ajoutée. S'il s'agit d'une erreur, veuillez contacter un administrateur du site. + Ceci est un message automatique pour vous informer qu'une adresse e-mail pour %{site_name} a été ajoutée. S'il s'agit d'une erreur, veuillez contacter un administrateur du site. - Votre adresse courriel ajoutée : + Votre adresse e-mail ajoutée : %{new_email} signup_after_approval: @@ -3712,7 +3710,6 @@ fr: png_to_jpg_conversion_failure_message: "Une erreur est survenue lors de la conversion du format PNG en JPG." optimize_failure_message: "Une erreur est survenue lors de l'optimisation de l'image envoyée." download_failure: "Le téléchargement du fichier depuis la source externe a échoué." - size_mismatch_failure: "La taille du fichier envoyé vers S3 ne correspondait pas à la taille attendue. %{additional_detail}" create_multipart_failure: "Impossible de créer un téléversement partitionné dans la boutique externe." abort_multipart_failure: "Impossible d'interrompre le téléversement partitionné dans la boutique externe." complete_multipart_failure: "Impossible de terminer le téléversement partitionné dans la boutique externe." @@ -3732,7 +3729,7 @@ fr: too_large: "(image plus grande que %{max_size_kb} Ko)" too_large_humanized: "(taille de l'image supérieure à %{max_size})" avatar: - missing: "Nous sommes désolés, nous ne parvenons pas à trouver un avatar associé à cette adresse courriel. Pouvez-vous essayer de l'envoyer à nouveau ?" + missing: "Nous sommes désolés, nous ne parvenons pas à trouver un avatar associé à cette adresse e-mail. Pouvez-vous essayer de l'envoyer à nouveau ?" flag_reason: sockpuppet: "Un nouvel utilisateur a créé un sujet et un autre nouvel utilisateur avec la même adresse IP (%{ip_address}) a répondu. Voir le paramètre « flag_sockpuppets »." spam_hosts: "Ce nouvel utilisateur a tenté de créer plusieurs messages avec des liens vers le même domaine. Tous les messages de cet utilisateur qui contiennent des liens doivent être examinés. Cette règle peut être ajustée avec le paramètre « newuser_spam_host_threshold »." @@ -3745,20 +3742,20 @@ fr: user_email_anonymous_user: "L'utilisateur est anonyme" user_email_user_suspended_not_pm: "L'utilisateur est suspendu, pas un message" user_email_seen_recently: "L'utilisateur a été vu récemment" - user_email_notification_already_read: "La notification de ce courriel a déjà été lue" + user_email_notification_already_read: "La notification de cet e-mail a déjà été lue" user_email_notification_topic_nil: "post.topic est nul" user_email_post_user_deleted: "L'auteur du message a été supprimé." user_email_post_deleted: "le message a été supprimé par son auteur" user_email_user_suspended: "l'utilisateur a été suspendu" user_email_already_read: "l'utilisateur a déjà lu ce message" user_email_access_denied: "l'utilisateur n'est pas autorisé à voir ce message" - user_email_no_email: "Aucun courriel n'est associé à l'ID utilisateur %{user_id}" + user_email_no_email: "Aucun e-mail n'est associé à l'ID utilisateur %{user_id}" sender_message_blank: "le message est vide" sender_message_to_blank: "message.to est vide" sender_text_part_body_blank: "text_part.body est vide" sender_body_blank: "sans contenu" sender_post_deleted: "le message a été supprimé" - sender_message_to_invalid: "le destinataire a une adresse courriel invalide" + sender_message_to_invalid: "le destinataire a une adresse e-mail invalide" sender_topic_deleted: "le sujet a été supprimé" group_smtp_post_deleted: "le message a été supprimé" group_smtp_topic_deleted: "le sujet a été supprimé" @@ -4082,7 +4079,7 @@ fr: Nous recueillons des informations lorsque vous vous inscrivez sur notre site et nous recueillons des données lorsque vous participez au forum en lisant, en écrivant et en évaluant le contenu partagé ici. - Lorsque vous vous inscrivez sur notre site, vous pouvez être invité(e) à saisir votre nom et votre adresse courriel. Vous pouvez, toutefois, visiter notre site sans vous inscrire. Votre adresse courriel sera vérifiée par l'envoi d'un courriel contenant un lien unique. Si ce lien est utilisé, nous saurons que vous êtes le ou la propriétaire de l'adresse courriel renseignée. + Lorsque vous vous inscrivez sur notre site, vous pouvez être invité(e) à saisir votre nom et votre adresse e-mail. Vous pouvez, toutefois, visiter notre site sans vous inscrire. Votre adresse e-mail sera vérifiée par l'envoi d'un e-mail contenant un lien unique. Si ce lien est utilisé, nous saurons que vous êtes le ou la propriétaire de l'adresse e-mail renseignée. Une fois que vous êtes inscrit(e) et que vous publiez un message, nous en enregistrons l'adresse IP de provenance. Nous pouvons également conserver les journaux des serveurs qui comprennent l'adresse IP de chaque demande envoyée à notre serveur. @@ -4096,7 +4093,7 @@ fr: * Pour améliorer le service client : vos informations nous aident à répondre plus efficacement à vos demandes de service et d'assistance. - * Pour envoyer des courriels périodiques : l'adresse courriel que vous fournissez peut être utilisée pour vous envoyer des informations, les notifications que vous avez demandées telles que les modifications apportées aux thèmes, les réponses mentionnant votre nom d'utilisateur, les réponses à des demandes de renseignements et/ou d'autres demandes ou questions. + * Pour envoyer des e-mails périodiques : l'adresse e-mail que vous fournissez peut être utilisée pour vous envoyer des informations, les notifications que vous avez demandées telles que les modifications apportées aux thèmes, les réponses mentionnant votre nom d'utilisateur, les réponses à des demandes de renseignements et/ou d'autres demandes ou questions. @@ -4160,7 +4157,7 @@ fr: badges: mass_award: errors: - invalid_csv: Une erreur s'est produite à la ligne %{line_number}. Veuillez confirmer que le fichier CSV contient une adresse courriel par ligne. + invalid_csv: Une erreur s'est produite à la ligne %{line_number}. Veuillez confirmer que le fichier CSV contient une adresse e-mail par ligne. too_many_csv_entries: Trop de lignes dans le fichier CSV. Veuillez fournir un fichier CSV contenant un maximum de %{count} lignes. badge_disabled: Veuillez préalablement activer le badge %{badge_name}. cant_grant_multiple_times: Impossible d'attribuer le badge %{badge_name} plusieurs fois à un même utilisateur. @@ -4380,10 +4377,10 @@ fr: long_description: | Ce badge est accordé la première fois que vous publiez un lien seul sur une ligne, qui a été développé automatiquement en une Onebox avec un bref résumé, un titre et (le cas échéant) une image. first_reply_by_email: - name: Première réponse par courriel - description: A répondu à un message par courriel + name: Première réponse par e-mail + description: A répondu à un message par e-mail long_description: | - Ce badge est accordé la première fois que vous répondez à un message par courriel :e-mail:. + Ce badge est accordé la première fois que vous répondez à un message par e-mail :e-mail:. new_user_of_the_month: name: "Nouvel utilisateur du mois" description: Contributions remarquables durant le premier mois @@ -4406,12 +4403,12 @@ fr: Ce badge est attribué lorsque vous vous connectez 365 jours de suite. Ouah ! Une année entière ! badge_title_metadata: "Badge %{display_name} sur %{site_title}" admin_login: - success: "Courriel envoyé" + success: "Email envoyé" errors: - unknown_email_address: "Adresse courriel inconnue." + unknown_email_address: "Adresse e-mail inconnue." invalid_token: "Jeton invalide." - email_input: "Courriel de l'administrateur" - submit_button: "Envoyer un courriel" + email_input: "E-mail de l'administrateur" + submit_button: "Envoyer un e-mail" safe_mode: "Mode sans échec : désactivez tous les thèmes ou extensions lors de la connexion" performance_report: initial_post_raw: Ce sujet comprend des rapports de performance journaliers concernant votre site. @@ -4452,13 +4449,13 @@ fr: button: "Créer" title: "Créer un compte administrateur" help: "Créez un nouveau compte pour commencer." - no_emails: "Malheureusement aucune adresse courriel d'administrateur n'a été définie lors de la configuration. Veuillez ajouter un courriel de développeur dans le fichier de configuration ou créer un compte administrateur depuis la console." + no_emails: "Malheureusement aucune adresse e-mail d'administrateur n'a été définie lors de la configuration. Veuillez ajouter un e-mail de développeur dans le fichier de configuration ou créer un compte administrateur depuis la console." confirm_email: - title: "Confirmer votre adresse courriel" - message: "

    Nous avons envoyé un courriel d'activation à %{email}. Veuillez suivre les instructions qui y figurent pour activer votre compte.

    Si vous ne le recevez pas, vérifiez votre dossier de courrier indésirable et assurez-vous de configurer correctement le courriel.

    " + title: "Confirmer votre adresse e-mail" + message: "

    Nous avons envoyé un e-mail d'activation à %{email}. Veuillez suivre les instructions qui y figurent pour activer votre compte.

    Si vous ne le recevez pas, vérifiez votre dossier de courrier indésirable et assurez-vous de configurer correctement l'e-mail.

    " resend_email: - title: "Renvoyer le courriel d'activation" - message: "

    Nous avons renvoyé le courriel d'activation à %{email}" + title: "Renvoyer l'e-mail d'activation" + message: "

    Nous avons renvoyé l'e-mail d'activation à %{email}" safe_mode: title: "Activer le mode sans échec" description: "Le mode sans échec vous permet de tester votre site sans charger d'extension ni de thème." @@ -4634,7 +4631,7 @@ fr: category: "Les messages de cette catégorie doivent être approuvés manuellement par un responsable. Voir %{link}." must_approve_users: "Tous les nouveaux utilisateurs doivent être approuvés par un responsable. Voir %{link}." invite_only: "Tous les nouveaux utilisateurs doivent être invités. Voir %{link}." - email_auth_res_enqueue: "Ce courriel n'a pas passé une vérification DMARC, il est fort probable qu'il ne provienne pas de l'expéditeur annoncé. Vérifiez les en-têtes du courriel brut pour plus d'informations." + email_auth_res_enqueue: "Cet e-mail n'a pas passé une vérification DMARC, il est fort probable qu'il ne provienne pas de l'expéditeur annoncé. Vérifiez les en-têtes de l'e-mail brut pour obtenir plus d'informations." email_spam: "Cette adresse e-mail a été identifiée comme un courrier indésirable d'après l'en-tête défini dans %{link}." suspect_user: "Ce nouvel utilisateur a saisi les informations de son profil sans lire aucun sujet ou article, ce qui suggère fortement qu'il s'agit peut-être d'un spammeur. Voir %{link}." contains_media: "Ce message contient un ou plusieurs contenus multimédias ou vidéo. Voir %{link}." @@ -4704,7 +4701,7 @@ fr: description: "L'utilisateur sera supprimé du forum." block: title: "Supprimer et bloquer l'utilisateur" - description: "L'utilisateur sera supprimé et nous bloquerons ses adresses courriel et IP." + description: "L'utilisateur sera supprimé et nous bloquerons ses adresses e-mail et IP." reject: title: "Rejeter" bundle_title: "Refuser…" diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml index 6cb09ef809..5860ba242d 100644 --- a/config/locales/server.gl.yml +++ b/config/locales/server.gl.yml @@ -690,7 +690,6 @@ gl: one: "hai case %{count} ano" other: "hai case %{count} anos" password_reset: - no_token: "Sentímolo, esa ligazón de cambio de contrasinal é antiga de máis. Prema no botón Iniciar sesión e déalle a «Esquecín o meu contrasinal» para obter unha nova ligazón." choose_new: "Elixir un novo contrasinal" choose: "Elixir un contrasinal" update: "Actualizar o contrasinal" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 326efdd2fb..b25618f846 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -266,6 +266,7 @@ he: max_redemptions_allowed_one: "להזמנות דוא״ל זה אמור להיות 1." redemption_count_less_than_max: "אמור להיות קטן מ־%{max_redemptions_allowed}." email_xor_domain: "שדות הדוא״ל ושם התחום אסורים לשימוש בו־זמנית" + existing_user_success: "ההזמנה נוצלה בהצלחה" bulk_invite: file_should_be_csv: "הקובץ שנשלח אמור להיות בתסדיר csv." max_rows: "%{max_bulk_invites} ההזמנות הראשונות נשלחו. כדאי לנסות לפצל את הקובץ לחלקים קטנים יותר." @@ -545,7 +546,7 @@ he: ניתן לערוך תגובות קודמות שלך כדי להוסיף ציטוט על ידי סימון טקסט ובחירה בכפתור שנגלה ציטוט תגובה. יותר קל לכולם לקרוא נושאים שיש בהם פחות תגובות עמוקות מאשר הרבה תגובות קטנות. - dominating_topic: פרסמת כאן למעלה מ־%{percent}% מהתגובות, האם כדאי לשמוע דעות של אנשים נוספים? + dominating_topic: פרסמת כאן יותר מ־%{percent}% מהתגובות; נוכל להציע לך לתת לאנשים אחרים הזדמנות להשתתף? get_a_room: הגבת ל־‎@%{reply_username} ‏%{count} פעמים, ידעת שניתן לשלוח הודעה פרטית במקום? too_many_replies: | ### הגעת למספר התגובות המרבי לנושא זה @@ -866,7 +867,7 @@ he: many: "לפני %{count} שנים כמעט" other: "לפני %{count} שנים כמעט" password_reset: - no_token: "הקישור להחלפת הסיסמה ישן מדי, עמך הסליחה. נא לבחור בכפתור הכניסה ולהשתמש באפשרות ‚שכחתי את הסיסמה שלי’ כדי לקבל קישור חדש.י" + no_token: 'אוי ויי! הקישור בו השתמשת לא פעיל עוד. אפשר להיכנס כעת. אם שכחת את הסיסמה שלך, אפשר לבקש קישור כדי לאפס אותה.' choose_new: "נא לבחור בסיסמה חדשה" choose: "בחירת סיסמה" update: "עדכון סיסמה" @@ -2939,6 +2940,12 @@ he: bulk_invite_succeeded: title: "הזמנה קבוצתית הצליחה" subject_template: "ההזמנה הקבוצתית עובדה בהצלחה" + text_body_template: | + ההזמנה המרוכזת שלך עברה עיבוד, %{sent} הזמנות נשלחו בדוא״ל, %{skipped} דולגו ו־%{warnings} אזהרות. + + ``` text + %{logs} + ``` bulk_invite_failed: title: "הזמנה קבוצתית נכשלה" subject_template: "ההזמנה המרוכזת עובדה עם שגיאות" @@ -3835,7 +3842,7 @@ he: png_to_jpg_conversion_failure_message: "אירעה שגיאה בעת המרה מ־PNG ל־JPG." optimize_failure_message: "אירעה שגיאה בעת מיטוב התמונה שהועלתה." download_failure: "הורדת הקובץ מהספק החיצוני נכשלה." - size_mismatch_failure: "גודל הקובץ שנשלח ל־S3 לא תואם לגודל המשלוח המוצהר. %{additional_detail}" + size_mismatch_failure: "גודל הקובץ שנשלח ל־S3 לא תואם לגודל המיועד למשלוח כלפי חוץ. %{additional_detail}" create_multipart_failure: "יצירת העלאה מרובת חלקים באחסון החיצוני נכשלה." abort_multipart_failure: "ביטול העלאה מרובת חלקים באחסון החיצוני נכשל." complete_multipart_failure: "השלמת העלאה מרובת חלקים באחסון החיצוני נכשלה." @@ -4904,6 +4911,10 @@ he: user_status: errors: ends_at_should_be_greater_than_set_at: "ends_at אמור להיות גדול מאשר set_at" + webhooks: + payload_url: + blocked_or_internal: "לא ניתן להשתמש בכתובת המטען כי היא תואמת ל־IP חסום או פנימי" + unsafe: "לא ניתן להשתמש בכתובת המטען כיוון שהיא לא מאובטחת" activemodel: errors: <<: *errors diff --git a/config/locales/server.hu.yml b/config/locales/server.hu.yml index bcdd5d53ee..db1284bd8d 100644 --- a/config/locales/server.hu.yml +++ b/config/locales/server.hu.yml @@ -487,7 +487,6 @@ hu: one: "majdnem %{count} éve" other: "majdnem %{count} éve" password_reset: - no_token: "Ez a jelszómódosítási hivatkozás túl régi. Ahhoz, hogy új hivatkozást kapjon, válassza a Bejelentkezés gombot, és használja az „Elfelejtettem a jelszót” lehetőséget." choose_new: "Válasszon új jelszót" choose: "Válasszon jelszót" update: "Jelszó frissítése" diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml index 13b1f83bed..c57cf80eea 100644 --- a/config/locales/server.hy.yml +++ b/config/locales/server.hy.yml @@ -534,7 +534,6 @@ hy: one: "գրեթե %{count} տարի առաջ" other: "գրեթե %{count} տարի առաջ" password_reset: - no_token: "Ներողություն, գաղտնաբառի փոփոխության այդ հղումը շատ հին է: Ընտրեք Մուտք Գործել կոճակը և օգտագործեք 'Ես մոռացել եմ իմ գաղտնաբառը'՝ նոր հղում ստանալու համար:" choose_new: "Ընտրել նոր գաղտնաբառ" choose: "Ընտրել գաղտնաբառ" update: "Թարմացնել Գաղտնաբառը" diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index de6d95d78c..5d0d0ff850 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -495,7 +495,6 @@ it: Puoi modificare la risposta precedente per aggiungere una citazione evidenziando il testo e selezionando il pulsante Cita che comparirà. E' più facile per chiunque leggere argomenti che hanno poche risposte ma più dettagliate, rispetto a molte brevi risposte singole. - dominating_topic: Hai pubblicato più del %{percent}% delle risposte qui; lascia esprimere anche qualcun altro. get_a_room: Abbiamo notato che hai già risposto %{count} volte a @%{reply_username}. Sapevi che puoi anche scambiare messaggi personali con questa persona? too_many_replies: | ### Hai raggiungo il limite di risposte per questo argomento @@ -764,7 +763,6 @@ it: one: "quasi %{count} anno fa" other: "quasi %{count} anni fa" password_reset: - no_token: "Spiacenti, il collegamento per il cambio password è scaduto. Clicca su Accedi e poi su 'Ho dimenticato la password' per ricevere un nuovo collegamento." choose_new: "Scegli una nuova password" choose: "Scegli una password" update: "Aggiorna Password" @@ -3554,7 +3552,6 @@ it: png_to_jpg_conversion_failure_message: "Si è verificato un errore durante la conversione da PNG a JPG." optimize_failure_message: "Si è verificato un errore nell'ottimizzare l'immagine caricata." download_failure: "Il download del file dal provider esterno non è riuscito." - size_mismatch_failure: "La dimensione del file caricato su S3 non corrispondeva alla dimensione prevista per lo stub di caricamento esterno. %{additional_detail}" create_multipart_failure: "Errore nella creazione del caricamento multipart nell'archivio esterno." abort_multipart_failure: "Errore nell’annullamento del caricamento multipart nell'archivio esterno." complete_multipart_failure: "Impossibile completare il caricamento multipart nell'archivio esterno." diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index 384a791e71..6861426bbf 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -443,25 +443,25 @@ ja: - 一般的に使用されている言葉をトピックに含めると、ほかのユーザーが*検索*しやすくなります。トピックを関連トピックとグループ化するには、カテゴリ (またはタグ) を選択してください。 - 詳細については、[コミュニティガイドラインをご覧ください](%{base_path}/guidelines)。このパネルは最初の%{education_posts_text}にのみ表示されます。 + 詳細については、[コミュニティーガイドラインをご覧ください](%{base_path}/guidelines)。このパネルは最初の%{education_posts_text}にのみ表示されます。 "new-reply": | %{site_name} へようこそ — **貢献していただきありがとうございます!** - - 他のコミュニティメンバーに親切に接してください。 + - 他のコミュニティーメンバーに親切に接してください。 ‐ あなたの返信によって会話が改善されますか? ‐ 建設的な批判は歓迎されますが、ユーザーではなく*アイデア*を批判してください。 - 詳細は、[コミュニティガイドラインをご覧ください](%{base_path}/guidelines)。このパネルは最初の%{education_posts_text}にのみ表示されます。 + 詳細は、[コミュニティーガイドラインをご覧ください](%{base_path}/guidelines)。このパネルは最初の%{education_posts_text}にのみ表示されます。 avatar: | ### アカウントに写真はいかがですか? - トピックと返信をいくつか投稿していますが、プロフィール写真が単なる文字になっています。 + トピックと返信をいくつか投稿していますが、プロファイル写真が単なる文字になっています。 - **[ユーザープロフィールにアクセス](%{profile_path})**して、あなたを表す写真をアップロードしませんか? + **[ユーザープロファイルにアクセス](%{profile_path})**して、あなたを表す写真をアップロードしませんか? - 全員が特有のプロフィール写真を設定していれば、会話のディスカッションをフォローしやすく、面白い人たちを見つけやすくなります! + 全員が特有のプロファイル写真を設定していれば、会話のディスカッションをフォローしやすく、面白い人たちを見つけやすくなります! sequential_replies: | ### 複数の投稿にまとめて返信しましょう @@ -470,7 +470,6 @@ ja: 前の返信を編集する際に、テキストをハイライトして引用して返信ボタンを選択すると、返信に引用を追加することができます。 短い個別の返信を多数読むよりも、詳細な返信を数件読む方が、トピックをフォローしやすくなります。 - dominating_topic: 返信の %{percent}% 以上はあなたが投稿したものです。他にあなたが意見を聞きたい人はいませんか? get_a_room: '@%{reply_username} に %{count} 回返信しましたが、個人メッセージを送信できることをご存知ですか?' too_many_replies: | ### このトピックへの返信数の制限に達しました @@ -530,7 +529,7 @@ ja: user_profile: attributes: featured_topic_id: - invalid: "このトピックをプロフィールに掲載することはできません。" + invalid: "このトピックをプロファイルに掲載することはできません。" user_email: attributes: user_id: @@ -581,14 +580,14 @@ ja: staff_category_name: "スタッフ" staff_category_description: "スタッフディスカッション用の非公開カテゴリです。トピックは管理者とモデレーターのみが閲覧できます。" discourse_welcome_topic: - title: "コミュニティへようこそ!" + title: "コミュニティーへようこそ!" body: |2 この固定トピックの最初の写真は、ようこそメッセージとしてホームページを初めて訪問するすべてのユーザーに表示されるものであるため、重要です! - コミュニティの簡単な説明になるように、**これを編集**しましょう。 + コミュニティーの簡単な説明になるように、**これを編集**しましょう。 - - これは誰のためのコミュニティですか? + - これは誰のためのコミュニティーですか? - どのような情報を得られますか? - 参加するメリットは何ですか? - 詳細 (リンク、リソースなど) はどこに記載されていますか? @@ -643,8 +642,8 @@ ja: slow_down: "この操作の実行回数が多すぎます。後でもう一度お試しください。" too_many_requests: "この操作の実行回数が多すぎます。%{time_left}経ってからもう一度お試しください。" by_type: - first_day_replies_per_day: "あなたの熱意には感謝していますが、コミュニティの安全上の理由により、新規ユーザーが初日に作成できる返信の上限に達しました。%{time_left}経ってから、返信を作成してください。" - first_day_topics_per_day: "あなたの熱意には感謝していますが、コミュニティの安全上の理由により、新規ユーザーが初日に作成できるトピックの上限に達しました。%{time_left}経ってから、新しいトピックを作成してください。" + first_day_replies_per_day: "あなたの熱意には感謝していますが、コミュニティーの安全上の理由により、新規ユーザーが初日に作成できる返信の上限に達しました。%{time_left}経ってから、返信を作成してください。" + first_day_topics_per_day: "あなたの熱意には感謝していますが、コミュニティーの安全上の理由により、新規ユーザーが初日に作成できるトピックの上限に達しました。%{time_left}経ってから、新しいトピックを作成してください。" create_topic: "トピックの作成に急ぎ過ぎているようです。%{time_left}経ってから、もう一度お試しください。" create_post: "返信に急ぎ過ぎているようです。%{time_left}経ってから、もう一度お試しください。" delete_post: "投稿の削除に急ぎ過ぎているようです。%{time_left}経ってから、もう一度お試しください。" @@ -713,7 +712,6 @@ ja: almost_x_years: other: "ほぼ %{count} 年前" password_reset: - no_token: "パスワード変更用のリンクは古すぎます。ログインボタンを選択し、「パスワードを忘れました」を使って新しいリンクを取得してください。" choose_new: "新しいパスワードを選択する" choose: "パスワードを選択する" update: "パスワードを更新" @@ -809,8 +807,8 @@ ja: email_body: "%{link}\n\n%{message}" inappropriate: title: "不適切" - description: 'この投稿には一般的な人が攻撃的、虐待的、またはコミュニティガイドラインに違反すると見なすコンテンツが含まれています。' - short_description: 'コミュニティガイドラインの違反' + description: 'この投稿には一般的な人が攻撃的、虐待的、またはコミュニティーガイドラインに違反すると見なすコンテンツが含まれています。' + short_description: 'コミュニティーガイドラインの違反' notify_user: title: "@%{username} にメッセージを送る" description: "この投稿についてこのユーザーと直接話すことを希望します。" @@ -875,9 +873,9 @@ ja: short_description: "これは広告です" inappropriate: title: "不適切" - description: 'このトピックには一般的な人が攻撃的、虐待的、またはコミュニティガイドラインに違反すると見なすコンテンツが含まれています。' + description: 'このトピックには一般的な人が攻撃的、虐待的、またはコミュニティーガイドラインに違反すると見なすコンテンツが含まれています。' long_form: "不適切として通報" - short_description: 'コミュニティガイドラインの違反' + short_description: 'コミュニティーガイドラインの違反' notify_moderators: title: "その他" description: 'このトピックはガイドライン利用規約または上記に記載されていない別の理由で、一般のスタッフによる注意が必要です。' @@ -886,8 +884,8 @@ ja: email_title: 'トピック "%{title}" はモデレーターの注意が必要です' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

    あなたの投稿はコミュニティから通報されました。投稿したメッセージを確認してください。

    ' - user_must_edit: "

    この投稿はコミュニティから通報されたため、一時的に非表示にされています。

    " + you_must_edit: '

    あなたの投稿はコミュニティーから通報されました。投稿したメッセージを確認してください。

    ' + user_must_edit: "

    この投稿はコミュニティーから通報されたため、一時的に非表示にされています。

    " ignored: hidden_content: "

    無視されたコンテンツ

    " archetypes: @@ -1046,17 +1044,17 @@ ja: title: "DAU/MAU" xaxis: "日" yaxis: "DAU/MAU" - description: "前日にログインしたメンバーの数を先月にログインしたメンバーの数で割った数 - コミュニティの「粘着性」を示す割合 % を返します。30% 以上を目指してください。" + description: "前日にログインしたメンバーの数を先月にログインしたメンバーの数で割った数 - コミュニティーの「粘着性」を示す割合 % を返します。30% 以上を目指してください。" daily_engaged_users: title: "1 日のエンゲージユーザー" xaxis: "日" yaxis: "エンゲージユーザー" description: "前日に「いいね!」または投稿したユーザーの数。" profile_views: - title: "ユーザープロフィールビュー" + title: "ユーザープロファイルビュー" xaxis: "日" - yaxis: "ユーザープロフィールの閲覧数" - description: "ユーザープロフィールの新しいビューの合計。" + yaxis: "ユーザープロファイルの閲覧数" + description: "ユーザープロファイルの新しいビューの合計。" topics: title: "トピック" xaxis: "日" @@ -1485,7 +1483,7 @@ ja: allowed_iframes: "Discourse が投稿で安全に許可できる iframe src ドメインプレフィックスのリスト" allowed_crawler_user_agents: "サイトへのアクセスを許可する必要のあるウェブクローラーのユーザーエージェント。警告!これを設定すると、ここにリストされていないすべてのクローラーが拒否されます!" blocked_crawler_user_agents: "サイトへのアクセスを許可してはいけないウェブクローラーを識別する、ユーザーエージェント文字列の大文字と小文字を区別しない一意の語。ホワイトリストが定義されている場合は適用されません。" - slow_down_crawler_user_agents: '""slow down crawler rate" 設定の構成のとおりに速度の制限が必要なウェブクローラーのユーザーエージェント。各値の長さは 3 文字以上です。' + slow_down_crawler_user_agents: '"slow down crawler rate" 設定の構成のとおりに速度の制限が必要なウェブクローラーのユーザーエージェント。各値の長さは 3 文字以上です。' slow_down_crawler_rate: "このレートに slow_down_crawler_user_agents が指定されている場合、このレートはすべてのクローラーに適用されます (リクエスト間の遅延秒数)" content_security_policy: "Content-Security-Policy を有効にする" content_security_policy_report_only: "Content-Security-Policy-Report-Only を有効にする" @@ -1550,7 +1548,7 @@ ja: discourse_connect_url: "DiscourseConnect エンドポイントの URL (http:// または https:// を含める必要があります)" discourse_connect_secret: "DiscourseConnect 認証情報の暗号化に利用する秘密の文字列。10文字以上である必要があります" discourse_connect_provider_secrets: "DiscourseConnect を使用しているドメインとシークレットのペアのリスト。DiscourseConnect シークレットは 10 文字以上であることを確認してください。ワイルドカード記号 * を使用して、任意のドメインまたはその一部のみに一致させることができます (例: *.example.com)。" - discourse_connect_overrides_bio: "ユーザープロフィールのユーザー略歴をオーバーライドし、ユーザーが変更できないようにする" + discourse_connect_overrides_bio: "ユーザープロファイルのユーザー略歴をオーバーライドし、ユーザーが変更できないようにする" discourse_connect_overrides_groups: "すべての手動グループメンバーシップをグループ属性に指定されたグループと同期する (警告: グループを指定しない場合、ユーザーのすべての手動グループメンバーシップがクリアされます)" auth_overrides_email: "ログインするたびにローカルメールを外部サイトメールでオーバーライドし、ローカルでの変更を防止する。すべての認証プロバイダーに適用されます (警告: ローカルメールの正規化により、矛盾が生じる可能性があります)" auth_overrides_username: "ログインするたびにローカルユーザー名を外部サイトのユーザー名でオーバーライドし、ローカルでの変更を防止する。すべての認証プロバイダーに適用されます (警告: ユーザー名の長さ/要件の違いにより、矛盾が発生する可能性があります)。" @@ -1558,7 +1556,7 @@ ja: discourse_connect_overrides_avatar: "ユーザーのアバターを DiscourseConnect ペイロードの値でオーバーライドする。有効である場合、ユーザーは Discourse にアバターをアップロードできなくなります。" discourse_connect_overrides_location: "ユーザーの場所を DiscourseConnect ペイロードの値でオーバーライドし、ローカルの変更を防止する。" discourse_connect_overrides_website: "ユーザーのウェブサイトを DiscourseConnect ペイロードの値でオーバーライドし、ローカルの変更を防止する。" - discourse_connect_overrides_profile_background: "ユーザープロフィールの背景を DiscourseConnect ペイロードの値でオーバーライドする。" + discourse_connect_overrides_profile_background: "ユーザープロファイルの背景を DiscourseConnect ペイロードの値でオーバーライドする。" discourse_connect_overrides_card_background: "ユーザーカードの背景を DiscourseConnect ペイロードの値でオーバーライドする。" discourse_connect_not_approved_url: "未承認の DiscourseConnect アカウントをこの URL にリダイレクトする" discourse_connect_allows_all_return_paths: "DiscourseConnect が指定する return_paths のドメインを制限しない (デフォルトでは、リターンパスは現在のサイトにある必要があります)" @@ -1707,7 +1705,7 @@ ja: min_trust_to_flag_posts: "投稿を通報するために必要な最低信頼レベル" min_trust_to_post_links: "投稿にリンクを含めるために必要な最低信頼レベル" min_trust_to_post_embedded_media: "投稿にメディア項目を埋め込むために必要な最低信頼レベル" - min_trust_level_to_allow_profile_background: "プロフィールの背景をアップロードするために必要な最低信頼レベル" + min_trust_level_to_allow_profile_background: "プロファイルの背景をアップロードするために必要な最低信頼レベル" min_trust_level_to_allow_user_card_background: "ユーザーカードの背景をアップロードするために必要な最低信頼レベル" min_trust_level_to_allow_invite: "ユーザーを招待するために必要な最低信頼レベル" min_trust_level_to_allow_ignore: "ユーザーを無視するために必要な最低信頼レベル" @@ -1771,7 +1769,7 @@ ja: allowed_spam_host_domains: "スパムホスト検査から除外するドメインのリスト。新規ユーザーはこれらのドメインへのリンクを使って投稿を作成することができます。" staff_like_weight: "スタッフの「いいね!」に与えられる重み (スタッフ以外の「いいね!」には 1 の重みがあります。)" topic_view_duration_hours: "N 時間ごとに IP/ユーザーあたりの新規トピックビューを 1 回カウントする" - user_profile_view_duration_hours: "N 時間ごとに IP/ユーザーあたりの新規ユーザープロフィールビューを 1 回カウントする" + user_profile_view_duration_hours: "N 時間ごとに IP/ユーザーあたりの新規ユーザープロファイルビューを 1 回カウントする" levenshtein_distance_spammer_emails: "迷惑メールのアドレスを照合する場合、あいまい一致を許可する文字数の差。" max_new_accounts_per_registration_ip: "この IP でアクセスする信頼レベル 0 のアカウントが (n) 個存在する場合 (さらにこれらがスタッフメンバーや TL2 以上のメンバーでない場合)、この IP から新たに登録できないようにします。制限を無効にするには 0 に設定します。" min_ban_entries_for_roll_up: "ロールアップボタンをクリックする際に少なくとも (N) 個のエントリーがある場合、新しいサブネット禁止エントリーを作成します。" @@ -1862,7 +1860,7 @@ ja: username_change_period: "アカウントがユーザー名を変更できる、登録後の最大日数 (0 に設定すると、ユーザー名の変更を禁止にします)。" email_editable: "登録後、ユーザーにメールアドレスの変更を許可します。" logout_redirect: "ログアウト後のブラウザのリダイレクト先 (例: https://example.com/logout)" - allow_uploaded_avatars: "ユーザーにカスタムプロフィール画像のアップロードを許可します。" + allow_uploaded_avatars: "ユーザーにカスタムプロファイル画像のアップロードを許可します。" default_avatars: "新規ユーザーが変更するまで使用されるデフォルトのアバターの URL です。" automatically_download_gravatars: "アカウントの作成時またはメールアドレスの変更時にユーザーの Gravatar をダウンロードします。" digest_topics: "メールの要約に表示する人気のあるトピックの最大数。" @@ -1876,7 +1874,7 @@ ja: email_accent_bg_color: "HTML メールの一部の要素の背景に使用されるアクセントカラー。色名 ('red') または 16 進数値 ('#FF0000') を入力してください。" email_accent_fg_color: "HTML メールのメール背景色にレンダリングされるテキストの色。色名 ('white') または 16 進数値 ('#FFFFFF') を入力してください。" email_link_color: "HTML メールのリンクの色。色名 ('blue') または 16 進数値 ('#0000FF') を入力してください。" - detect_custom_avatars: "ユーザーがカスタムプロフィール画像をアップロードしたかを確認するかどうか。" + detect_custom_avatars: "ユーザーがカスタムプロファイル画像をアップロードしたかを確認するかどうか。" max_daily_gravatar_crawls: "Discourse が 1 日に Gravatar でカスタムアバターの有無をチェックする回数の上限" public_user_custom_fields: "API で取得できるユーザーカスタムフィールドのリスト。" staff_user_custom_fields: "API でスタッフメンバー用に取得できるユーザーカスタムフィールドのリスト。" @@ -1887,11 +1885,11 @@ ja: allow_anonymous_posting: "ユーザーに匿名モードへの切り替えを許可" anonymous_posting_min_trust_level: "匿名で投稿するために必要な最低信頼レベル" anonymous_account_duration_minutes: "匿名性を守るため、N 分毎に各ユーザーの匿名アカウントを作成しなおします。例 : 600 に設定すると、最終投稿から 600 分が経過し、さらにユーザーが匿名に切り替えると、新しい匿名アカウントが作成されます。" - hide_user_profiles_from_public: "匿名ユーザーのユーザーカード、ユーザープロフィール、およびユーザーディレクトリを無効にする。" - allow_users_to_hide_profile: "ユーザーがプロフィールとプレゼンスを非表示することを許可する" - allow_featured_topic_on_user_profiles: "ユーザーがトピックへのリンクをユーザーカードとプロフィールに掲載することを許可する。" - show_inactive_accounts: "ログインユーザーが非アクティブアカウントのプロフィールを閲覧することを許可する。" - hide_suspension_reasons: "ユーザープロフィールに凍結理由を公開しない。" + hide_user_profiles_from_public: "匿名ユーザーのユーザーカード、ユーザープロファイル、およびユーザーディレクトリを無効にする。" + allow_users_to_hide_profile: "ユーザーがプロファイルとプレゼンスを非表示することを許可する" + allow_featured_topic_on_user_profiles: "ユーザーがトピックへのリンクをユーザーカードとプロファイルに掲載することを許可する。" + show_inactive_accounts: "ログインユーザーが非アクティブアカウントのプロファイルを閲覧することを許可する。" + hide_suspension_reasons: "ユーザープロファイルに凍結理由を公開しない。" log_personal_messages_views: "管理者による他のユーザー/グループの個人メッセージビューをログに記録する。" ignored_users_count_message_threshold: "特定のユーザーがこの数のユーザーに無視された場合、モデレーターに通知する。" ignored_users_message_gap_days: "多数のユーザーによって無視されたユーザーについてモデレーターに再通知するまでの時間。" @@ -1900,7 +1898,7 @@ ja: user_selected_primary_groups: "ユーザーが独自のプライマリーグループを設定することを許可する" max_notifications_per_user: "ユーザー当たりの通知の最大数。この数を超えると、古い通知は削除されます。毎週適用されます。0 に設定すると無効になります" allowed_user_website_domains: "ユーザーのウェブサイトはこれらのドメインに対して検証されます。パイプ区切りのリスト。" - allow_profile_backgrounds: "ユーザーにプロフィール背景のアップロードを許可します。" + allow_profile_backgrounds: "ユーザーにプロファイル背景のアップロードを許可します。" sequential_replies_threshold: "連続返信が多すぎることを通知されるまでに、ユーザーがトピックで連続して作成できる投稿数。" get_a_room_threshold: "警告を受けるまでに、ユーザーが同じトピックの同じ人物に行える投稿数。" enable_mobile_theme: "モバイルデバイスにモバイル対応のテーマを使用します (デスクトップサイトに切り替え可能)。完全にレスポンシブなカスタムスタイルシートを使用する場合はこの設定を無効にしてください。" @@ -1928,7 +1926,7 @@ ja: penalty_step_hours: "ユーザーの投稿禁止または凍結に使用されるデフォルトのペナルティ (時間)。最初の違反はデフォルトで最初の値になり、2 番目の違反はデフォルトで 2 番目の値になります。" svg_icon_subset: "アセットに含める追加の FontAwesome 5 アイコンを追加する。ソリッドアイコンには 'fa-'、通常のアイコンには 'far-'、ブランドアイコンには 'fab-' のプレフィックスを使用します。" max_prints_per_hour_per_user: "/print ページの最大インプレッション数 (0 に設定すると無効になります)" - full_name_required: "ユーザープロフィールのフルネームを必須にします。" + full_name_required: "ユーザープロファイルのフルネームを必須にします。" enable_names: "ユーザーのプロフィール、ユーザーカード、メールアドレスでのフルネームを表示します。無効にすると、フルネームはすべての場所で非表示になります。" display_name_on_posts: "投稿に、@username に加えてユーザーのフルネームを表示します。" show_time_gap_days: "2 つの投稿の作成日の間が指定された日数以上の場合、トピックに時間の差を表示します。" @@ -2159,7 +2157,7 @@ ja: frequent_poster: "頻繁な投稿者" poster_description_joiner: "、" redirected_to_top_reasons: - new_user: "コミュニティへようこそ!最も人気のある最近のトピックはこちらです。" + new_user: "コミュニティーへようこそ!最も人気のある最近のトピックはこちらです。" not_seen_in_a_month: "お帰りなさい!最近見かけませんでしたね。あなたがいない間に最も人気の高かったトピックはこちらです。" merge_posts: edit_reason: @@ -2491,15 +2489,15 @@ ja: %{notes} flag_reasons: - off_topic: "あなたの投稿は「話題に関係ない」として通報されました。コミュニティはあなたの投稿がタイトルと最初の投稿で定義されているトピックにふさわしくないと判断しました。" - inappropriate: "あなたの投稿は**不適切**として通報されました。コミュニティは投稿が攻撃的、虐待的、または[コミュニティガイドライン](%{base_path}/guidelines)に違反すると感じています。" - spam: "あなたの投稿は「迷惑」として通報されました。コミュニティはあなたの投稿に営利目的、宣伝目的の性質があり、トピックに意図される有用性または関連性がないと判断しました。" - notify_moderators: "あなたの投稿は「モデレーターの注意要」として通報されました。コミュニティはあなたの投稿にスタッフメンバーによる手動介入が必要であると判断しました。" + off_topic: "あなたの投稿は「話題に関係ない」として通報されました。コミュニティーはあなたの投稿がタイトルと最初の投稿で定義されているトピックにふさわしくないと判断しました。" + inappropriate: "あなたの投稿は**不適切**として通報されました。コミュニティーは投稿が攻撃的、虐待的、または[コミュニティガイドライン](%{base_path}/guidelines)に違反すると感じています。" + spam: "あなたの投稿は「迷惑」として通報されました。コミュニティーはあなたの投稿に営利目的、宣伝目的の性質があり、トピックに意図される有用性または関連性がないと判断しました。" + notify_moderators: "あなたの投稿は「モデレーターの注意要」として通報されました。コミュニティーはあなたの投稿にスタッフメンバーによる手動介入が必要であると判断しました。" responder: - off_topic: "投稿は**話題に関係ない**として通報されました。コミュニティはあなたの投稿がタイトルと最初の投稿で定義されているトピックにふさわしくないと判断しました。" - inappropriate: "投稿は**不適切**として通報されました。コミュニティは投稿が攻撃的、虐待的、または[コミュニティガイドライン](%{base_path}/guidelines)に違反すると感じています。" - spam: "投稿は**迷惑**として通報されました。コミュニティはあなたの投稿に営利目的、宣伝目的の性質があり、トピックに意図される有用性または関連性がないと判断しました。" - notify_moderators: "投稿は**モデレーターの注意要**として通報されました。コミュニティはあなたの投稿にスタッフメンバーによる手動介入が必要であると判断しました。" + off_topic: "投稿は**話題に関係ない**として通報されました。コミュニティーはあなたの投稿がタイトルと最初の投稿で定義されているトピックにふさわしくないと判断しました。" + inappropriate: "投稿は**不適切**として通報されました。コミュニティーは投稿が攻撃的、虐待的、または[コミュニティーガイドライン](%{base_path}/guidelines)に違反すると感じています。" + spam: "投稿は**迷惑**として通報されました。コミュニティーはあなたの投稿に営利目的、宣伝目的の性質があり、トピックに意図される有用性または関連性がないと判断しました。" + notify_moderators: "投稿は**モデレーターの注意要**として通報されました。コミュニティーはあなたの投稿にスタッフメンバーによる手動介入が必要であると判断しました。" flags_dispositions: agreed: "ご連絡いただきありがとうございます。問題があることを認め、現在調査しています。" agreed_and_deleted: "ご連絡いただきありがとうございます。問題があることを認め、投稿を削除しました。" @@ -2507,7 +2505,7 @@ ja: ignored: "ご連絡いただきありがとうございます。現在調査しています。" ignored_and_deleted: "ご連絡いただきありがとうございます。投稿を削除しました。" temporarily_closed_due_to_flags: - other: "このトピックは、大量のコミュニティ通報が寄せられたため、一時的に %{count} 時間以上クローズされています。" + other: "このトピックは、大量のコミュニティー通報が寄せられたため、一時的に %{count} 時間以上クローズされています。" system_messages: reviewables_reminder: subject_template: "レビューキューにレビューが必要な項目があります" @@ -2517,7 +2515,7 @@ ja: contents_hidden: "コンテンツを見るには投稿にアクセスしてください。" post_hidden: title: "非表示の投稿" - subject_template: "コミュニティ通報によって非表示にされた投稿" + subject_template: "コミュニティー通報によって非表示にされた投稿" text_body_template: | こんにちは。 @@ -2527,14 +2525,14 @@ ja: %{flag_reason} - この投稿は、コミュニティからの通報により非表示にされました。そのため、コミュニティからのフィードバックを反映するようにあなたの投稿を書き直すことを検討してください。**%{edit_delay} 分後に編集を開始できます。編集が終わると、自動的に非表示状態が解除されます。** + この投稿は、コミュニティーからの通報により非表示にされました。そのため、コミュニティーからのフィードバックを反映するようにあなたの投稿を書き直すことを検討してください。**%{edit_delay} 分後に編集を開始できます。編集が終わると、自動的に非表示状態が解除されます。** - ただし、投稿が再びコミュニティによって非表示にされると、スタッフが処理するまで非表示のママとなります。 + ただし、投稿が再びコミュニティーによって非表示にされると、スタッフが処理するまで非表示のママとなります。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 post_hidden_again: title: "再度非表示" - subject_template: "コミュニティ通報によって非表示にされた投稿、スタッフ通知済み" + subject_template: "コミュニティー通報によって非表示にされた投稿、スタッフ通知済み" text_body_template: | こんにちは。 @@ -2544,9 +2542,9 @@ ja: %{flag_reason} - コミュニティがこの投稿を通報したため、非表示になっています。**この投稿は 2 回以上非表示となったため、スタッフメンバーが処理するまで非表示のままとなります。** + コミュニティーがこの投稿を通報したため、非表示になっています。**この投稿は 2 回以上非表示となったため、スタッフメンバーが処理するまで非表示のままとなります。** - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 queued_by_staff: title: "承認待ちの投稿" subject_template: "スタッフが投稿を非表示にしました。承認待ち" @@ -2559,7 +2557,7 @@ ja: あなたの投稿は、スタッフがレビューするまで非表示のままとなります。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 flags_disagreed: title: "通報された投稿をスタッフが復元しました" subject_template: "通報された投稿をスタッフが復元しました" @@ -2568,7 +2566,7 @@ ja: これは %{site_name} からの自動メッセージです。[あなたの投稿](%{base_url}%{url})が復元されたことをお知らせします。 - この投稿はコミュニティによって通報され、スタッフメンバーがそれを復元することを選択しました。 + この投稿はコミュニティーによって通報され、スタッフメンバーがそれを復元することを選択しました。 [details="クリックして復元された投稿を展開"] ``` markdown @@ -2585,13 +2583,13 @@ ja: %{flag_reason} - この投稿はコミュニティによって通報され、スタッフメンバーが削除することを選択しました。 + この投稿はコミュニティーによって通報され、スタッフメンバーが削除することを選択しました。 ``` markdown %{flagged_post_raw_content} ``` - 詳細については、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + 詳細については、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 flags_agreed_and_post_deleted_for_responders: title: "スタッフにより、通報された投稿から返信が削除されました" subject_template: "スタッフにより、通報された投稿から返信が削除されました" @@ -2602,7 +2600,7 @@ ja: %{flag_reason} - この投稿はコミュニティによって通報され、スタッフメンバーが削除することを選択しました。 + この投稿はコミュニティーによって通報され、スタッフメンバーが削除することを選択しました。 ``` markdown %{flagged_post_raw_content} @@ -2614,12 +2612,12 @@ ja: %{flagged_post_response_raw_content} ``` - 削除の理由に関する詳細については、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + 削除の理由に関する詳細については、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 usage_tips: text_body_template: | 新規ユーザーとして開始するための簡単なヒントについては、[こちらのブログ記事をご覧ください](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/)。 - ここで参加すると、あなたのことを知れるようになり、新規ユーザーの制限が一時的に解除されます。時間が経つにつれ、共にコミュニティを管理するための特別な機能が含まれた[信頼レベル](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)を獲得できるようになります。 + ここで参加すると、あなたのことを知れるようになり、新規ユーザーの制限が一時的に解除されます。時間が経つにつれ、共にコミュニティーを管理するための特別な機能が含まれた[信頼レベル](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)を獲得できるようになります。 welcome_user: title: "ようこそ" subject_template: "%{site_name} へようこそ!" @@ -2628,7 +2626,7 @@ ja: %{new_user_tips} - コミュニティでは常に礼節ある行動 (%{base_url}/guidelines) を取ってもらえると信じています。 + コミュニティーでは常に礼節ある行動 (%{base_url}/guidelines) を取ってもらえると信じています。 ではお楽しみください! welcome_tl1_user: @@ -2637,7 +2635,7 @@ ja: text_body_template: | こんにちは。最近読みふけっているようですね。素晴らしいことです。そこで、あなたの[信頼レベル](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)を昇格しました! - あなたがここで時間を費やしていることを本当に嬉しく思っており、もっとあなたのことを知りたいと思っています。少しお時間をいただいて、[プロフィールを入力](%{base_url}/my/preferences/profile)していただくか、お気軽に[新しいトピックを開始](%{base_url}/categories)してください。 + あなたがここで時間を費やしていることを本当に嬉しく思っており、もっとあなたのことを知りたいと思っています。少しお時間をいただいて、[プロファイルを入力](%{base_url}/my/preferences/profile)していただくか、お気軽に[新しいトピックを開始](%{base_url}/categories)してください。 welcome_staff: title: "ようこそ - スタッフ" subject_template: "おめでとうございます。あなたに %{role} ステータスが付与されました!" @@ -2653,13 +2651,13 @@ ja: text_body_template: | %{site_name} への招待を承諾していただき、ありがとうございます -- ようこそ! - ‐ この新しいアカウント **%{username}** をあなたのために作成しました。[ユーザープロフィール][prefs]にアクセスして、名前またはパスワードを変更してください。 + ‐ この新しいアカウント **%{username}** をあなたのために作成しました。[ユーザープロファイル][prefs]にアクセスして、名前またはパスワードを変更してください。 ‐ ログインする際は、**元の招待と同じメールアドレスを使用**してください。異なるものを使用すると、あなたであることがわかりません! %{new_user_tips} - コミュニティでは常に[礼節ある行動](%{base_url}/guidelines) を取ってもらえると信じています。 + コミュニティーでは常に[礼節ある行動](%{base_url}/guidelines) を取ってもらえると信じています。 ではお楽しみください! @@ -2669,7 +2667,7 @@ ja: text_body_template: | あなたの[信頼レベル](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)をもう 1 つ昇格しました! - 信頼レベル 2 に達したということは、このコミュニティのメンバーとして見なされるのに十分な閲覧と積極的な参加を行ったということです。 + 信頼レベル 2 に達したということは、このコミュニティーのメンバーとして見なされるのに十分な閲覧と積極的な参加を行ったということです。 経験豊富なユーザーとして、[便利なヒントとコツ](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/)が役に立つことと思います。 @@ -2991,33 +2989,33 @@ ja: text_body_template: | こんにちは。 - これは、%{site_name} からの自動メッセージです。@%{username} が %{ignores_threshold} 人のユーザーから無視されていることをお知らせします。これは、コミュニティに問題が発生している可能性があることを示します。 + これは、%{site_name} からの自動メッセージです。@%{username} が %{ignores_threshold} 人のユーザーから無視されていることをお知らせします。これは、コミュニティーに問題が発生している可能性があることを示します。 このユーザーからの[最近の投稿をレビュー](%{base_url}/u/%{username}/summary)し、場合によっては[無視およびミュートされたユーザーのレポート](%{base_url}/admin/reports/top_ignored_users)のほかのユーザーについてもレビューすることをお勧めします。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 too_many_spam_flags: title: "迷惑通報が多すぎます" subject_template: "新規アカウントは保留中です" text_body_template: | こんにちは。 - これは、%{site_name} からの自動メッセージです。あなたの投稿はコミュニティから通報されたため、一時的に非表示になっていることをお知らせします。 + これは、%{site_name} からの自動メッセージです。あなたの投稿はコミュニティーから通報されたため、一時的に非表示になっていることをお知らせします。 予防措置としてあなたの新しいアカウントは投稿禁止となっているため、スタッフメンバーがあなたのアカウントをレビューするまで返信またはトピックを作成することはできません。ご不便をおかけして申し訳ありません。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 too_many_tl3_flags: title: "TL3 通報が多すぎます" subject_template: "新規アカウントは保留中です" text_body_template: | こんにちは。 - これは、%{site_name} からの自動メッセージです。あなたのアカウントとは、コミュニティ通報が多数あるため保留中になっていることをお知らせします。 + これは、%{site_name} からの自動メッセージです。あなたのアカウントは、コミュニティー通報が多数あるため保留中になっていることをお知らせします。 予防措置としてあなたの新しいアカウントは投稿禁止となっているため、スタッフメンバーがあなたのアカウントをレビューするまで新しい返信またはトピックを作成することはできません。ご不便をおかけして申し訳ありません。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 silenced_by_staff: title: "スタッフにより投稿禁止にされました" subject_template: "アカウントは一時的に保留中です" @@ -3028,10 +3026,10 @@ ja: 引き続き閲覧できますが、[スタッフメンバー](%{base_url}/about)があなたの直近の投稿をレビューするまで返信またはトピックを作成することはできません。ご不便をおかけして申し訳ありません。 - その他のガイダンスについては、[コミュニティガイドライン](%{base_url}/guidelines)をご覧ください。 + その他のガイダンスについては、[コミュニティーガイドライン](%{base_url}/guidelines)をご覧ください。 user_automatically_silenced: title: "ユーザーは自動的に投稿禁止にされました" - subject_template: "新規ユーザー %{username} はコミュニティ通報により投稿禁止になりました" + subject_template: "新規ユーザー %{username} はコミュニティー通報により投稿禁止になりました" text_body_template: | これは自動メッセージです。 @@ -3089,7 +3087,7 @@ ja: このアワードは毎月 2 つの新規ユーザーのみに与えられるもので、[バッジページ](%{url})に永久的に表示されるようになります。 - あなたは、あっという間にコミュニティの貴重なメンバーとなりました。参加していただきありがとうございます。これからも素晴らしい貢献を楽しみにしています! + あなたは、あっという間にコミュニティーの貴重なメンバーとなりました。参加していただきありがとうございます。これからも素晴らしい貢献を楽しみにしています! queued_posts_reminder: title: "キューに入れられた投稿に関するリマインダー" subject_template: @@ -3521,7 +3519,7 @@ ja: signup_after_approval: title: "登録 - 承認後" subject_template: "%{site_name} での承認が完了しました!" - text_body_template: "%{site_name} へようこそ!\n\nスタッフメンバーが %{site_name} のあなたのアカウントを承認しました。\n\nこれで、次の場所からログインすると、新しいアカウントにアクセスできるようになりました。\n%{base_url}\n\n上記のリンクをクリックできない場合は、それをコピーしてウェブブラウザのアドレスバーに貼り付けてください。\n\n%{new_user_tips}\n\nコミュニティでは常に[礼節ある行動](%{base_url}/guidelines)を取ってもらえると信じています。 \n\nではお楽しみください!\n" + text_body_template: "%{site_name} へようこそ!\n\nスタッフメンバーが %{site_name} のあなたのアカウントを承認しました。\n\nこれで、次の場所からログインすると、新しいアカウントにアクセスできるようになりました。\n%{base_url}\n\n上記のリンクをクリックできない場合は、それをコピーしてウェブブラウザのアドレスバーに貼り付けてください。\n\n%{new_user_tips}\n\nコミュニティーでは常に[礼節ある行動](%{base_url}/guidelines)を取ってもらえると信じています。 \n\nではお楽しみください!\n" signup_after_reject: title: "登録 - 拒否後" subject_template: "%{site_name} での承認が拒否されました" @@ -3607,7 +3605,6 @@ ja: png_to_jpg_conversion_failure_message: "PNG から JPG への変換中にエラーが発生しました。" optimize_failure_message: "アップロードされた画像を最適化中にエラーが発生しました。" download_failure: "外部プロバイダーからのファイルのダウンロードに失敗しました。" - size_mismatch_failure: "S3 にアップロードされたファイルのサイズが、外部アップロードスタブの意図したサイズに一致しませんでした。%{additional_detail}" create_multipart_failure: "外部ストアでのマルチパートアップロードの作成に失敗しました。" abort_multipart_failure: "外部ストアでのマルチパートアップロードの中止に失敗しました。" complete_multipart_failure: "外部ストアでのマルチパートアップロードを完了できませんでした。" @@ -3701,9 +3698,9 @@ ja: ## [これは礼節を以って行われる公開ディスカッションです](#civilized) - このディスカッションフォーラムには、公共の公園を使用する場合と同じマナーで参加してください。このフォーラムも共有のコミュニティリソースであり、スキル、知識、および関心を会話を通じて共有する場所です。 + このディスカッションフォーラムには、公共の公園を使用する場合と同じマナーで参加してください。このフォーラムも共有のコミュニティーリソースであり、スキル、知識、および関心を会話を通じて共有する場所です。 - これらは絶対厳守の規則ではなく、コミュニティの人間の判断を支援し、この場所を、マナーある公の言説の場として思いやりのあるフレンドリーな場所に維持するためのガイドラインです。 + これらは絶対厳守の規則ではなく、コミュニティーの人間の判断を支援し、この場所を、マナーある公の言説の場として思いやりのあるフレンドリーな場所に維持するためのガイドラインです。 @@ -3732,21 +3729,21 @@ ja: ## [参加することが重要です](#participate) - ここにある会話によって新しい投稿の論調が決まります。このフォーラムが楽しい場所になるようなディスカッションに参加し、そうでないディスカッションには参加しないことで、このコミュニティの今後の改善にご協力ください。 + ここにある会話によって新しい投稿の論調が決まります。このフォーラムが楽しい場所になるようなディスカッションに参加し、そうでないディスカッションには参加しないことで、このコミュニティーの今後の改善にご協力ください。 - Discourse には、ブックマーク、「いいね!」、返信、編集、ウォッチ中、ミュートなど、コミュニティが集団的に素晴らしい (または最悪の) 貢献を認めるためのツールが備わっています。これらのツールを使用して、あなた自身のエクスペリエンスだけでなく、全員のエクスペリエンスを向上させることができます。 + Discourse には、ブックマーク、「いいね!」、返信、編集、ウォッチ中、ミュートなど、コミュニティーが集団的に素晴らしい (または最悪の) 貢献を認めるためのツールが備わっています。これらのツールを使用して、あなた自身のエクスペリエンスだけでなく、全員のエクスペリエンスを向上させることができます。 - コミュニティをますます素晴らしいものにしていきましょう。 + コミュニティーをますます素晴らしいものにしていきましょう。 ## [問題に遭遇したら、通報してください](#flag-problems) - モデレーターには特別な権限があり、このフォーラムの責任が課せられています。ただし、これはあなたにも同様です。あなたが協力することで、モデレーターは掃除係や警備員ではなく、コミュニティを改善する役割を果たすことができます。 + モデレーターには特別な権限があり、このフォーラムの責任が課せられています。ただし、これはあなたにも同様です。あなたが協力することで、モデレーターは掃除係や警備員ではなく、コミュニティーを改善する役割を果たすことができます。 好ましくない行動に遭遇したら、返信しないこと。返信すると好ましくない行動を注目したことになり、それをエスカレートさせてしまい、あなたの労力を消費して、全員の時間を無駄にしてしまいます。_とにかく通報してください_。十分な数の通報が集まれば、自動的またはモデレーターの介入による何らかの対応が行われます。 - モデレーターには、コミュニティを維持するため、いかなるコンテンツとユーザーアカウントをいかなる理由で何時にでも削除する権利が与えられています。モデレーターは新しい投稿をプレビューしません。モデレーターとサイト運営者はコミュニティに投稿されるいかなるコンテンツに対する責任を一切負いません。 + モデレーターには、コミュニティーを維持するため、いかなるコンテンツとユーザーアカウントをいかなる理由で何時にでも削除する権利が与えられています。モデレーターは新しい投稿をプレビューしません。モデレーターとサイト運営者はコミュニティーに投稿されるいかなるコンテンツに対する責任を一切負いません。 @@ -3787,7 +3784,7 @@ ja: ## [提供はあなた自身](#power) - このサイトは、あなたの[ローカルスタッフ](%{base_path}/about)とコミュニティである*あなた*によって運営されています。このフォーラムがどのように機能すべきかに関するその他の質問については、[サイトフィードバックカテゴリ](%{base_path}/c/site-feedback)に新規トピックをオープンし、話し合いましょう!メタトピックまたは通報で処理できない重大な問題または緊急の問題がある場合は、[スタッフページ](%{base_path}/about)からお問い合わせください。 + このサイトは、あなたの[ローカルスタッフ](%{base_path}/about)とコミュニティーである*あなた*によって運営されています。このフォーラムがどのように機能すべきかに関するその他の質問については、[サイトフィードバックカテゴリ](%{base_path}/c/site-feedback)に新規トピックをオープンし、話し合いましょう!メタトピックまたは通報で処理できない重大な問題または緊急の問題がある場合は、[スタッフページ](%{base_path}/about)からお問い合わせください。 @@ -4071,44 +4068,44 @@ ja: このバッジは、初めてウィキ投稿を編集したときに付与されます。 basic_user: name: ベーシック - description: すべての基本コミュニティ機能を付与しました + description: すべての基本コミュニティー機能を付与しました long_description: | - このバッジはユーザーが信頼レベル 1 に達すると付与されます。コミュニティの内容を理解するために、いくつかのトピックを読み続けていただきありがとうございます。新規ユーザーの制限は解除され、個人メッセージ、ウィキ編集、複数の画像とリンクを投稿する機能など、すべての基本的なコミュニティ機能が付与されています。 + このバッジはユーザーが信頼レベル 1 に達すると付与されます。コミュニティーの内容を理解するために、いくつかのトピックを読み続けていただきありがとうございます。新規ユーザーの制限は解除され、個人メッセージ、ウィキ編集、複数の画像とリンクを投稿する機能など、すべての基本的なコミュニティー機能が付与されています。 member: name: メンバー description: 招待、グループメッセージ、「いいね!」の増加を付与しました long_description: | - このバッジは、信頼レベル 2 に達すると付与されます。数週間にわたって、コミュニティにしっかりと参加していただきありがとうございました。ユーザーページまたは個別のトピックからの招待の送信、グループ個人メッセージの作成、1 日の「いいね!」の増量が付与されています。 + このバッジは、信頼レベル 2 に達すると付与されます。数週間にわたって、コミュニティーにしっかりと参加していただきありがとうございました。ユーザーページまたは個別のトピックからの招待の送信、グループ個人メッセージの作成、1 日の「いいね!」の増量が付与されています。 regular: name: レギュラー description: カテゴリ整理、名前変更、フォローリンク、ウィキ、「いいね!」の増加を付与しました long_description: | - このバッジは、信頼レベル 3 に達すると付与されます。数か月に渡り、コミュニティに定期的に参加していただきありがとうございました。これで、最もアクティブな読者の 1 人となり、コミュニティを素晴らしいものにしてくれる信頼ある貢献者となりました。カテゴリ整理やトピックの名前変更、より強力な迷惑通報の利用、非公開ラウンジエリアへのアクセスが可能となり、1 日当たりの「いいね!」が増えています。 + このバッジは、信頼レベル 3 に達すると付与されます。数か月に渡り、コミュニティーに定期的に参加していただきありがとうございました。これで、最もアクティブな読者の 1 人となり、コミュニティーを素晴らしいものにしてくれる信頼ある貢献者となりました。カテゴリ整理やトピックの名前変更、より強力な迷惑通報の利用、非公開ラウンジエリアへのアクセスが可能となり、1 日当たりの「いいね!」が増えています。 leader: name: リーダー description: グローバル編集、固定、クローズ、アーカイブ、分割とマージ、「いいね!」の増加を付与しました long_description: | - このバッジは、信頼レベル 4 に達すると付与されます。あなたはこのコミュニティのリーダーとしてスタッフに選ばれ、ここでのあなたの行動や言葉でコミュニティの他のメンバーに前向きな姿勢を示しています。すべての投稿を編集できるほか、表示固定、クローズ、リストから非表示、アーカイブ、分割、マージなどの一般的なトピックモデレーターアクションを実行できるようになりました。 + このバッジは、信頼レベル 4 に達すると付与されます。あなたはこのコミュニティーのリーダーとしてスタッフに選ばれ、ここでのあなたの行動や言葉でコミュニティーの他のメンバーに前向きな姿勢を示しています。すべての投稿を編集できるほか、表示固定、クローズ、リストから非表示、アーカイブ、分割、マージなどの一般的なトピックモデレーターアクションを実行できるようになりました。 welcome: name: ようこそ description: '「いいね!」をゲット' long_description: | - このバッジは、投稿ではじめて「いいね!」をもらった時に付与されます。おめでとうございます。ほかのコミュニティメンバーが面白い、すごい、または有益だと思う内容を投稿しました! + このバッジは、投稿ではじめて「いいね!」をもらった時に付与されます。おめでとうございます。ほかのコミュニティーメンバーが面白い、すごい、または有益だと思う内容を投稿しました! autobiographer: name: 自伝作家 - description: プロフィール情報を入力しました + description: プロファイル情報を入力しました long_description: | - このバッジは、あなたのユーザープロフィールを入力し、プロフィール写真を選択したときに付与されます。コミュニティにあなたのことやあなたが興味のあることを知らせることで、より緊密なコミュニティを作ることができます。参加しましょう! + このバッジは、あなたのユーザープロファイルを入力し、プロファイル写真を選択したときに付与されます。コミュニティーにあなたのことやあなたが興味のあることを知らせることで、より緊密なコミュニティーを作ることができます。参加しましょう! anniversary: name: アニバーサリー description: 1 回は投稿したことのあるメンバー歴 1 年のアクティブメンバー long_description: | - このバッジは、1 年間メンバーであり、その年に少なくとも 1 件の投稿がある場合に付与されます。参加を続け、コミュニティに貢献していただきありがとうございました。あなたなしでは続けられません。 + このバッジは、1 年間メンバーであり、その年に少なくとも 1 件の投稿がある場合に付与されます。参加を続け、コミュニティーに貢献していただきありがとうございました。あなたなしでは続けられません。 nice_post: name: ナイスな返信 description: 返信に 10 個の「いいね!」を受け取りました long_description: | - このバッジは、あなたの返信に「いいね!」が 10 個つけられたときに付与されます。あなたの返信はコミュニティに感動を与え、会話を前進させるのに役立ちました。 + このバッジは、あなたの返信に「いいね!」が 10 個つけられたときに付与されます。あなたの返信はコミュニティーに感動を与え、会話を前進させるのに役立ちました。 good_post: name: イカした返信 description: 返信に 25 個の「いいね!」を受け取りました @@ -4118,42 +4115,42 @@ ja: name: グレートな返信 description: 返信に 50 個の「いいね!」を受け取りました long_description: | - このバッジは、あなたの返信に「いいね!」が 50 個つけられたときに付与されます。わぉ!あなたの返信は刺激的、魅力的、陽気、もしくは洞察に満ちていて、コミュニティにとても気に入られました! + このバッジは、あなたの返信に「いいね!」が 50 個つけられたときに付与されます。わぉ!あなたの返信は刺激的、魅力的、陽気、もしくは洞察に満ちていて、コミュニティーにとても気に入られました! nice_topic: name: ナイスなトピック description: トピックに 10 個の「いいね!」を受け取りました long_description: | - このバッジは、あなたのトピックに「いいね!」が 10 個つけられたときに付与されます。あなたはコミュニティが楽しんだ興味深い会話を始めました。 + このバッジは、あなたのトピックに「いいね!」が 10 個つけられたときに付与されます。あなたはコミュニティーが楽しんだ興味深い会話を始めました。 good_topic: name: イカしたトピック description: トピックに 25 個の「いいね!」を受け取りました long_description: | - このバッジは、あなたのトピックに「いいね!」が 25 個つけられたときに付与されます。あなたはコミュニティを結集させ、活気に満ちた会話を始めました。 + このバッジは、あなたのトピックに「いいね!」が 25 個つけられたときに付与されます。あなたはコミュニティーを結集させ、活気に満ちた会話を始めました。 great_topic: name: グレートなトピック description: トピックに 50 個の「いいね!」を受け取りました long_description: | - このバッジは、あなたのトピックに「いいね!」が 50 個つけられたときに付与されます。あなたが始めた魅力的な会話から活気にあふれるディスカッションが生まれ、コミュニティに気に入られました! + このバッジは、あなたのトピックに「いいね!」が 50 個つけられたときに付与されます。あなたが始めた魅力的な会話から活気にあふれるディスカッションが生まれ、コミュニティーに気に入られました! nice_share: name: ナイスな共有 description: 25 人の訪問者に投稿を共有 long_description: | - このバッジは、共有したリンクを 25 人の外部訪問者がクリックすると付与されます。ディスカッションとこのコミュニティの存在を伝えてくれ、ありがとう! + このバッジは、共有したリンクを 25 人の外部訪問者がクリックすると付与されます。ディスカッションとこのコミュニティーの存在を伝えてくれ、ありがとう! good_share: name: イカした共有 description: 300 人の訪問者に投稿を共有 long_description: | - このバッジは、300 人の外部訪問者がクリックしたリンクを共有したときに付与されます。よくできました!あなたはたくさんの新しい人々に素晴らしいディスカッションを披露し、このコミュニティの成長を助けました。 + このバッジは、300 人の外部訪問者がクリックしたリンクを共有したときに付与されます。よくできました!あなたはたくさんの新しい人々に素晴らしいディスカッションを披露し、このコミュニティーの成長を助けました。 great_share: name: グレートな共有 description: 1000 人の訪問者に投稿を共有 long_description: | - このバッジは、1000 人の外部訪問者がクリックしたリンクを共有したときに付与されます。わぉ!あなたは大勢の新しいオーディエンスに興味深いディスカッションを促進し、コミュニティを大きく成長させることに役立ちました! + このバッジは、1000 人の外部訪問者がクリックしたリンクを共有したときに付与されます。わぉ!あなたは大勢の新しいオーディエンスに興味深いディスカッションを促進し、コミュニティーを大きく成長させることに役立ちました! first_like: name: 初めての「いいね!」 description: 投稿に「いいね!」した long_description: | - このバッジは、初めて :heart: ボタンを使って投稿を「いいね!」した時に付与されます。投稿への「いいね!」は、コミュニティメンバーが投稿した内容が「面白い!いいね!」と知らせる素晴らしい方法です。 愛を分かち合おう! + このバッジは、初めて :heart: ボタンを使って投稿を「いいね!」した時に付与されます。投稿への「いいね!」は、コミュニティーメンバーが投稿した内容が「面白い!いいね!」と知らせる素晴らしい方法です。 愛を分かち合おう! first_flag: name: はじめての通報 description: 投稿を通報した @@ -4163,22 +4160,22 @@ ja: name: プロモーター description: ユーザーを招待した long_description: | - このバッジは、あなたのユーザーページの招待ボタンまたはトピックの下にあるボタンを使って誰かをコミュニティに招待したときに付与されます。特定のディスカッションに興味があるかもしれない友人を招待すれば、新しい人をコミュニティに迎え入れることができます。ご協力ありがとうございます! + このバッジは、あなたのユーザーページの招待ボタンまたはトピックの下にあるボタンを使って誰かをコミュニティーに招待したときに付与されます。特定のディスカッションに興味があるかもしれない友人を招待すれば、新しい人をコミュニティーに迎え入れることができます。ご協力ありがとうございます! campaigner: name: 活動家 description: ベーシックユーザー 3 人を招待 long_description: | - このバッジは、あなたが招待した 3 人がサイトで十分な時間を費やしてベーシックユーザーとなったときに付与されます。コミュニティの活気を維持するには、定期的に参加して会話に新しい意見を加える新人が定期的に加わる必要があります。 + このバッジは、あなたが招待した 3 人がサイトで十分な時間を費やしてベーシックユーザーとなったときに付与されます。コミュニティーの活気を維持するには、定期的に参加して会話に新しい意見を加える新人が定期的に加わる必要があります。 champion: name: チャンピオン description: メンバー 5 人を招待 long_description: | - このバッジは、あなたが招待した 5 人がサイトで十分な時間を費やしてフルメンバーとなったときに付与されます。わぉ!新しいメンバーを招待し、コミュニティの多様性を拡大してくれてありがとうございました! + このバッジは、あなたが招待した 5 人がサイトで十分な時間を費やしてフルメンバーとなったときに付与されます。わぉ!新しいメンバーを招待し、コミュニティーの多様性を拡大してくれてありがとうございました! first_share: name: 初めてのシェア description: 投稿を共有した long_description: | - このバッジは、初めて共有ボタンを使用して返信やトピックへのリンクを共有したときに付与されます。リンクを共有すれば、興味深いディスカッションを世界中の人々に披露し、コミュニティを成長させる上で大きく役立ちます。 + このバッジは、初めて共有ボタンを使用して返信やトピックへのリンクを共有したときに付与されます。リンクを共有すれば、興味深いディスカッションを世界中の人々に披露し、コミュニティーを成長させる上で大きく役立ちます。 first_link: name: 初めてのリンク description: 別の投稿へのリンクを追加 @@ -4191,9 +4188,9 @@ ja: このバッジは、あなたが初めて返信で投稿を引用したときに付与されます。あなたの返信に、前の投稿の関連部分を引用すると、その話題について一緒に話し合うことができます。引用する最も簡単な方法は、投稿のある箇所をハイライト表示し、返信ボタンを押すことです。 たくさん引用してください! read_guidelines: name: ガイドライン読者 - description: コミュニティガイドラインを読んだ + description: コミュニティーガイドラインを読んだ long_description: | - このバッジは、コミュニティガイドラインを読んだときに付与されます。これらの簡単なガイドラインに従い、共有することで、全員にとって安全で楽しく持続可能なコミュニティを構築することができます。画面の向こうには、あなたのような人がいることを常に忘れないでください。親切に接しましょう! + このバッジは、コミュニティーガイドラインを読んだときに付与されます。これらの簡単なガイドラインに従い、共有することで、全員にとって安全で楽しく持続可能なコミュニティーを構築することができます。画面の向こうには、あなたのような人がいることを常に忘れないでください。親切に接しましょう! reader: name: 読者 description: 1 つのトピックに 100 件以上ある返信をすべて読んだ @@ -4218,22 +4215,22 @@ ja: name: 感謝 description: '「いいね!」を 1 つゲットした 20 件の投稿' long_description: | - このバッジは、20 件の異なる投稿で「いいね!」を 1 つ以上もらったときに付与されます。コミュニティは会話へのあなたの投稿を楽しんでいます! + このバッジは、20 件の異なる投稿で「いいね!」を 1 つ以上もらったときに付与されます。コミュニティーは会話へのあなたの投稿を楽しんでいます! respected: name: 尊敬 description: '「いいね!」を 2 つゲットした 100 件の投稿' long_description: | - このバッジは、100 件の異なる投稿で「いいね!」を 2 つ以上もらったときに付与されます。コミュニティは会話へのあなたの投稿をますます楽しんでいます! + このバッジは、100 件の異なる投稿で「いいね!」を 2 つ以上もらったときに付与されます。コミュニティーは会話へのあなたの投稿をますます楽しんでいます! admired: name: 賞賛 description: '「いいね!」を 5 つゲットした 300 件の投稿' long_description: | - このバッジは、300 件の異なる投稿で「いいね!」を 5 つ以上もらったときに付与されます。コミュニティは頻繁で質の高いあなたの投稿を心待ちにしています。 + このバッジは、300 件の異なる投稿で「いいね!」を 5 つ以上もらったときに付与されます。コミュニティーは頻繁で質の高いあなたの投稿を心待ちにしています。 out_of_love: name: 愛情 description: 1 日に %{max_likes_per_day} 個の「いいね!」を使用しました long_description: | - このバッジは、1 日の「いいね!」の全 %{max_likes_per_day} 個を使用したときに付与されます。ちょっと立ち止まって、あなたが楽しんだり称賛したりした投稿に「いいね!」をつけることで、ほかのコミュニティメンバーがさらに素晴らしいディスカッションを作成していく励みになります。 + このバッジは、1 日の「いいね!」の全 %{max_likes_per_day} 個を使用したときに付与されます。ちょっと立ち止まって、あなたが楽しんだり称賛したりした投稿に「いいね!」をつけることで、ほかのコミュニティーメンバーがさらに素晴らしいディスカッションを作成していく励みになります。 higher_love: name: 愛だらけ description: 1 日の %{max_likes_per_day} 個の「いいね!」を 5 回使用しました @@ -4243,7 +4240,7 @@ ja: name: 愛は盲目 description: 1 日の %{max_likes_per_day} 個の「いいね!」を 20 回使用しました long_description: | - このバッジは、1 日の「いいね!」を全 %{max_likes_per_day} 個 20 日間使用したときに付与されます。わぉ!あなたはコミュニティメンバーを励ますロールモデルです! + このバッジは、1 日の「いいね!」を全 %{max_likes_per_day} 個 20 日間使用したときに付与されます。わぉ!あなたはコミュニティーメンバーを励ますロールモデルです! thank_you: name: ありがとう description: もらった「いいね!」が 20 個、あげた「いいね!」が10 個 @@ -4371,7 +4368,7 @@ ja: contact_email: label: "連絡先" placeholder: "example@user.com" - description: "このコミュニティの責任者またはグループ。重要な更新に使用され、緊急連絡先としてあなたの紹介ページに表示されます。" + description: "このコミュニティーの責任者またはグループ。重要な更新に使用され、緊急連絡先としてあなたの紹介ページに表示されます。" default_locale: label: "言語" privacy: @@ -4388,7 +4385,7 @@ ja: extra_description: "ユーザーはスタッフの承認が必要です" ready: title: "あなたの Discourse の準備が整いました!" - description: "以上です!コミュニティを設定するための基本を完了しました。後は、コミュニティにアクセスして見て回り、ウェルカムトピックを書いて招待状を送信しましょう!

    お楽しみください!" + description: "以上です!コミュニティーを設定するための基本を完了しました。後は、コミュニティーにアクセスして見て回り、ウェルカムトピックを書いて招待状を送信しましょう!

    お楽しみください!" styling: title: "外観" fields: @@ -4460,14 +4457,14 @@ ja: description: "通報の警告やバックアップ完了の通知など、Discourse のすべての自動個人メッセージはこのユーザーから送信されます。" invites: title: "スタッフの招待" - description: "ほぼ完了です!コミュニティを始動させるために、興味深いトピックや返信でディスカッションを生み出せる人を何人が招待しましょう。" + description: "ほぼ完了です!コミュニティーを始動させるために、興味深いトピックや返信でディスカッションを生み出せる人を何人が招待しましょう。" disabled: "ローカルログインが無効になっているため、誰にも招待を送れません。次のステップに進んでください。" finished: title: "あなたの Discourse の準備が整いました!" description: |

    これらの設定を変更することがある場合は、いつでもこのウィザードを実行するか、あなたの管理者セクションにアクセスしてください。このセクションはサイトメニューのレンチアイコンの横にあります。

    強力なテーマシステムを使用すると、Discourse をさらにカスタマイズすることができます。たとえば、meta.discourse.org人気のテーマやコンポーネントをご覧ください。

    -

    それでは、新しいコミュニティの構築をお楽しみください!

    +

    それでは、新しいコミュニティーの構築をお楽しみください!

    search_logs: graph_title: "検索数" joined: "参加" @@ -4524,7 +4521,7 @@ ja: invite_only: "すべての新規ユーザーは招待される必要があります。%{link} をご覧ください。" email_auth_res_enqueue: "このメールは DMARC チェックに失敗しました。これは誰からのものでもないようです。詳細については、生のメールヘッダーを確認してください。" email_spam: "このメールは、%{link} に定義されているヘッダーによって、迷惑として通報されました。" - suspect_user: "この新規ユーザーは、トピックまたは投稿を一切読まずにプロフィール情報を入力しました。これは、迷惑行為者であることを強く示唆しています。%{link} をご覧ください。" + suspect_user: "この新規ユーザーは、トピックまたは投稿を一切読まずにプロファイル情報を入力しました。これは、迷惑行為者であることを強く示唆しています。%{link} をご覧ください。" contains_media: "この投稿には埋め込みメディアが含まれています。%{link} をご覧ください。" queued_by_staff: "スタッフメンバーは、この投稿にレビューが必要だと考えています。それまで非表示のままになります。" links: diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index a31610a09e..b64095ba34 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -679,7 +679,6 @@ ko: almost_x_years: other: "약 %{count}년 전" password_reset: - no_token: "이 비밀번호 변경 링크는 너무 오래됐습니다. 로그인 버튼을 눌러서 '비밀번호가 기억나지 않습니다'를 사용하여 새 링크를 받으세요." choose_new: "새 비밀번호를 입력하세요" choose: "비밀번호를 입력하세요" update: "비밀번호 업데이트" @@ -1752,14 +1751,14 @@ ko: raw_rejected_email_max_length: "거부된 수신 이메일에 저장해야 하는 문자 수입니다." delete_rejected_email_after_days: "(n)일 지난 거부된 이메일을 삭제합니다." manual_polling_enabled: "이메일 답장을 위해 API를 사용하여 이메일을 푸시합니다." - pop3_polling_enabled: "이메일 답장을 위해 POP3로 폴링합니다." + pop3_polling_enabled: "POP3로 이메일 응답 가져오기" pop3_polling_ssl: "POP3 서버 연결 중 SSL을 사용합니다. (추천)" pop3_polling_openssl_verify: "TLS 서버 인증서 검증(디폴트: 활성화됨)" pop3_polling_period_mins: "POP3 계정에서 이메일을 확인하는 간격(분)입니다. 참고: 재시작해야 합니다." - pop3_polling_port: "POP3 계정을 폴링할 포트입니다. " - pop3_polling_host: "POP3를 통해 이메일을 폴링할 호스트입니다." - pop3_polling_username: "이메일을 폴링할 POP3 계정의 아이디입니다." - pop3_polling_password: "이메일을 폴링할 POP3 계정의 비밀번호입니다." + pop3_polling_port: "POP3 계정을 연결할 포트 번호" + pop3_polling_host: "POP3 계정을 연결할 서버명" + pop3_polling_username: "이메일을 가져올 POP3 계정의 사용자 이름" + pop3_polling_password: "이메일을 가져올 POP3 계정의 비밀번호" pop3_polling_delete_from_server: "서버에서 이메일을 삭제하세요. 참고: 이 기능을 비활성화하면 받은 편지함을 수동으로 정리해야 합니다." log_mail_processing_failures: "모든 이메일 처리 실패를 /logs에 기록" email_in: '사용자가 이메일을 통해 새 주제를 게시할 수 있도록 허용합니다(수동 또는 pop3 폴링 필요). 각 카테고리의 ''설정'' 탭에서 주소를 환경설정합니다.' diff --git a/config/locales/server.lt.yml b/config/locales/server.lt.yml index 29d8f077b6..145ff20bea 100644 --- a/config/locales/server.lt.yml +++ b/config/locales/server.lt.yml @@ -681,7 +681,6 @@ lt: many: "beveik prieš %{count} metų" other: "beveik prieš %{count} metų" password_reset: - no_token: "Atsiprašome, slaptažodžio keitimo nuoroda yra per sena. Pasirinkite mygtuką \"Prisijungti\" ir naudokite „Pamiršau slaptažodį“, kad gautumėte naują nuorodą." choose_new: "Pasirinkite naują slaptažodį" choose: "Pasirinkite slaptažodį" update: "Atnaujinti slaptažodį" diff --git a/config/locales/server.nb_NO.yml b/config/locales/server.nb_NO.yml index 028a5c4cfb..64f9682385 100644 --- a/config/locales/server.nb_NO.yml +++ b/config/locales/server.nb_NO.yml @@ -524,7 +524,6 @@ nb_NO: one: "nesten %{count} år siden" other: "nesten %{count} år siden" password_reset: - no_token: "Beklager, den lenken for å endre passord er for gammel. Velg logg inn-knappen bruk 'Jeg glemte passordet mitt' for å motta en ny lenke." choose_new: "Velg et nytt passord" choose: "Velg et passord" update: "Oppdater passord" diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index 1f09474316..a07c9a73cb 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -215,7 +215,7 @@ nl: invalid_access: "U mag de opgevraagde bron niet bekijken." bulk_invite: file_should_be_csv: "Het geüploade bestand dient de CSV-indeling te hebben." - max_rows: "De eerste %{max_bulk_invites} uitnodigingen zijn verstuurd. Probeer het bestand in kleinere delen op te splitsen." + max_rows: "De eerste %{max_bulk_invites} uitnodigingen zijn verzonden. Probeer het bestand in kleinere delen op te splitsen." error: "Er is een fout opgetreden bij het uploaden van dat bestand. Probeer het later opnieuw." invite_link: max_redemptions_limit: "moet tussen 2 en %{max_limit} liggen." @@ -237,7 +237,7 @@ nl: provider_not_enabled: "U mag de opgevraagde bron niet bekijken. De authenticatieprovider is niet ingeschakeld." provider_not_found: "U mag de opgevraagde bron niet bekijken. De authenticatieprovider bestaat niet." read_only_mode_enabled: "De website bevindt zich in alleen-lezenmodus. Interacties zijn uitgeschakeld." - invalid_grant_badge_reason_link: "Externe of ongeldige Discourse-koppeling is niet toegestaan in badge-reden" + invalid_grant_badge_reason_link: "Externe of ongeldige Discourse-link is niet toegestaan in badgereden" email_template_cant_be_modified: "Deze e-mailsjabloon kan niet worden aangepast." invalid_whisper_access: "Fluisterberichten zijn niet ingeschakeld, of u kunt geen fluisterberichten maken" not_in_group: @@ -258,8 +258,8 @@ nl: continue: "Discussie voortzetten" error: "Fout bij inbedding" referer: "Verwijzing:" - error_topics: "De website-instelling `embed topics list` is niet ingeschakeld" - mismatch: "De verwijzing is niet verstuurd, of kwam met geen van de volgende hosts overeen:" + error_topics: "De website-instelling 'embed topics list' is niet ingeschakeld" + mismatch: "De verwijzer is niet verzonden, of kwam met geen van de volgende hosts overeen:" no_hosts: "Er zijn geen hosts ingesteld voor inbedding." configure: "Inbedding configureren" more_replies: @@ -280,43 +280,43 @@ nl: new_topic: "Nieuw topic maken" no_mentions_allowed: "Sorry, u kunt geen andere gebruikers noemen." too_many_mentions: - one: "Sorry, u kunt maar één andere gebruiker in een bericht noemen." - other: "Sorry, u kunt maar %{count} gebruikers in een bericht noemen." + one: "Sorry, u kunt slechts één andere gebruiker noemen in een bericht." + other: "Sorry, u kunt maximaal %{count} gebruikers noemen in een bericht." no_mentions_allowed_newuser: "Sorry, nieuwe gebruikers kunnen geen andere gebruikers noemen." too_many_mentions_newuser: - one: "Sorry, nieuwe gebruikers kunnen maar één andere gebruiker in een bericht noemen." - other: "Sorry, nieuwe gebruikers kunnen maar %{count} gebruikers in een bericht noemen." - no_embedded_media_allowed_trust: "Sorry, u kunt geen media-items in een bericht inbedden." - no_embedded_media_allowed: "Sorry, nieuwe gebruikers kunnen geen media-items in berichten inbedden." + one: "Sorry, nieuwe gebruikers kunnen slechts één andere gebruiker noemen in een bericht." + other: "Sorry, nieuwe gebruikers kunnen maximaal %{count} gebruikers noemen in een bericht." + no_embedded_media_allowed_trust: "Sorry, u kunt geen media-items insluiten in een bericht." + no_embedded_media_allowed: "Sorry, nieuwe gebruikers kunnen geen media-items insluiten in berichten." too_many_embedded_media: - one: "Sorry, nieuwe gebruikers kunnen slechts één ingebed media-item in een bericht plaatsen." - other: "Sorry, nieuwe gebruikers kunnen slechts %{count} ingebedde media-items in een bericht plaatsen." - no_attachments_allowed: "Sorry, nieuwe gebruikers kunnen geen bijlagen in berichten plaatsen." + one: "Sorry, nieuwe gebruikers kunnen slechts één ingesloten media-item in een bericht plaatsen." + other: "Sorry, nieuwe gebruikers kunnen slechts %{count} ingesloten media-items in een bericht plaatsen." + no_attachments_allowed: "Sorry, nieuwe gebruikers kunnen geen bijlagen toevoegen aan berichten." too_many_attachments: - one: "Sorry, nieuwe gebruikers kunnen maar één bijlage in een bericht plaatsen." - other: "Sorry, nieuwe gebruikers kunnen maar %{count} bijlagen in een bericht plaatsen." - no_links_allowed: "Sorry, nieuwe gebruikers kunnen geen koppelingen in berichten plaatsen." - links_require_trust: "Sorry, u kunt geen koppelingen in uw bericht opnemen." + one: "Sorry, nieuwe gebruikers kunnen slechts één bijlage toevoegen aan een bericht." + other: "Sorry, nieuwe gebruikers kunnen maximaal %{count} bijlagen toevoegen aan een bericht." + no_links_allowed: "Sorry, nieuwe gebruikers kunnen geen links plaatsen in berichten." + links_require_trust: "Sorry, u kunt geen links opnemen in uw berichten." too_many_links: - one: "Sorry, nieuwe gebruikers kunnen maar één koppeling in een bericht plaatsen." - other: "Sorry, nieuwe gebruikers kunnen maar %{count} koppelingen in een bericht plaatsen." - spamming_host: "Sorry, u kunt geen koppeling naar die host plaatsen." + one: "Sorry, nieuwe gebruikers kunnen slechts één link in een bericht plaatsen." + other: "Sorry, nieuwe gebruikers kunnen slechts %{count} links in een bericht plaatsen." + spamming_host: "Sorry, u kunt geen link naar die host plaatsen." user_is_suspended: "Geschorste gebruikers mogen geen berichten plaatsen." - topic_not_found: "Er is iets fout gegaan. Misschien is het topic gesloten of verwijderd terwijl u het bekeek?" + topic_not_found: "Er is iets misgegaan. Misschien is het topic gesloten of verwijderd terwijl u het bekeek?" not_accepting_pms: "Sorry, %{username} accepteert momenteel geen berichten." - max_pm_recipients: "Sorry, u kunt naar maximaal %{recipients_limit} ontvangers een bericht sturen." - pm_reached_recipients_limit: "Sorry, u kunt niet meer dan %{recipients_limit} ontvangers in een bericht hebben." + max_pm_recipients: "Sorry, u kunt een bericht naar maximaal %{recipients_limit} ontvangers sturen." + pm_reached_recipients_limit: "Sorry, u kunt niet meer dan %{recipients_limit} ontvangers hebben in een bericht." removed_direct_reply_full_quotes: "Citaat van hele voorgaande bericht is automatisch verwijderd." - secure_upload_not_allowed_in_public_topic: "Sorry, de volgende beveiligde upload(s) kan/kunnen niet in een publiek topic wordt gebruikt: %{upload_filenames}." - create_pm_on_existing_topic: "Sorry, u kunt geen PM maken bij een bestaand topic." - slow_mode_enabled: "Dit topic bevindt zich in de langzame modus." - just_posted_that: "lijkt te veel op wat u onlangs hebt geplaatst" + secure_upload_not_allowed_in_public_topic: "Sorry, de volgende beveiligde upload(s) kan/kunnen niet worden gebruikt in een openbaar topic: %{upload_filenames}." + create_pm_on_existing_topic: "Sorry, u kunt geen PB maken bij een bestaand topic." + slow_mode_enabled: "Dit topic is in de langzame modus." + just_posted_that: "lijkt te veel op wat u recent hebt geplaatst" invalid_characters: "bevat ongeldige tekens" is_invalid: "lijkt onduidelijk, is het een volledige zin?" next_page: "volgende pagina →" prev_page: "← vorige pagina" page_num: "Pagina %{num}" - home_title: "Hoofdpagina" + home_title: "Start" topics_in_category: "Topics in de categorie '%{category}'" rss_posts_in_topic: "RSS-feed van '%{topic}'" rss_topics_in_category: "RSS-feed van topics in de categorie '%{category}'" @@ -327,7 +327,7 @@ nl: one: "%{count} deelnemer" other: "%{count} deelnemers" read_full_topic: "Volledige topic lezen" - private_message_abbrev: "PB" + private_message_abbrev: "Ber" rss_description: latest: "Nieuwste topics" top: "Toptopics" @@ -413,11 +413,11 @@ nl: sequential_replies: | ### Overweeg meerdere berichten tegelijk te beantwoorden - Overweeg één antwoord dat citaten uit eerdere berichten or @naam-verwijzingen bevat in plaats van verschillende antwoorden in een topic achter elkaar. + Overweeg één antwoord dat citaten uit eerdere berichten of @naam-verwijzingen bevat in plaats van verschillende antwoorden achter elkaar te plaatsen in een topic. - U kunt uw eerdere antwoord bewerken om een citaat toe te voegen door tekst te accentueren en de knop Bericht citeren die verschijnt te selecteren. + U kunt uw eerdere antwoord bewerken om een citaat toe te voegen door tekst te markeren en de knop Bericht citeren die verschijnt te selecteren. - Het is voor iedereen makkelijker om topics te lezen die minder uitgebreide antwoorden bevatten dan die met veel kleine, individuele antwoorden. + Het is voor iedereen makkelijker om topics te lezen die minder, maar uitgebreidere antwoorden bevatten dan met veel kleine, individuele antwoorden. too_many_replies: | ### U hebt de antwoordlimiet voor dit topic bereikt @@ -436,7 +436,7 @@ nl: name: "Categorienaam" topic: title: "Titel" - featured_link: "Aanbevolen koppeling" + featured_link: "Aanbevolen link" category_id: "Categorie" post: raw: "Inhoud" @@ -447,19 +447,19 @@ nl: topic: attributes: base: - warning_requires_pm: "U kunt alleen waarschuwingen aan persoonlijke berichten toevoegen." + warning_requires_pm: "U kunt alleen waarschuwingen toevoegen aan persoonlijke berichten." too_many_users: "U kunt alleen waarschuwingen naar één gebruiker tegelijk sturen." - cant_send_pm: "Sorry, u kunt geen persoonlijk bericht sturen naar die persoon." + cant_send_pm: "Sorry, u kunt geen persoonlijk bericht sturen naar die gebruiker." no_user_selected: "U moet een geldige gebruiker selecteren." - reply_by_email_disabled: "Antwoorden per e-mail is uitgeschakeld." - target_user_not_found: "Een van de gebruikers waarnaar u dit bericht stuurt kon niet worden gevonden." + reply_by_email_disabled: "Antwoorden via e-mail is uitgeschakeld." + target_user_not_found: "Een van de gebruikers waarnaar u dit bericht stuurt, kon niet worden gevonden." unable_to_update: "Er is een fout opgetreden bij het bijwerken van dat topic." featured_link: invalid: "is ongeldig. URL moet http:// of https:// bevatten." user: attributes: password: - common: "is een van de 10000 meest gebruikte wachtwoorden. Gebruik een veiliger wachtwoord." + common: "is een van de 10.000 meest gebruikte wachtwoorden. Gebruik een veiliger wachtwoord." same_as_username: "is hetzelfde als uw gebruikersnaam. Gebruik een veiliger wachtwoord." same_as_email: "is hetzelfde als uw e-mailadres. Gebruik een veiliger wachtwoord." same_as_current: "is hetzelfde als uw huidige wachtwoord." @@ -470,11 +470,11 @@ nl: name: same_as_password: "is hetzelfde als uw wachtwoord." ip_address: - signup_not_allowed: "Registratie vanaf deze account is niet toegestaan." + signup_not_allowed: "Registratie vanaf dit account is niet toegestaan." user_profile: attributes: featured_topic_id: - invalid: "Dit topic kan niet op uw profiel worden aanbevolen." + invalid: "Dit topic kan niet in uw profiel worden uitgelicht." user_email: attributes: user_id: @@ -485,7 +485,7 @@ nl: invalid: "is geen geldige kleur" post_reply: base: - different_topic: "Bericht en antwoord moeten bij hetzelfde topic horen." + different_topic: "Bericht en antwoord moeten tot hetzelfde topic behoren." web_hook: attributes: payload_url: @@ -519,14 +519,14 @@ nl: discourse_welcome_topic: body: |2 - De eerste alinea van dit vastgemaakte topic is op uw startpagina zichtbaar als een welkomstbericht voor alle nieuwe bezoekers. Dit is belangrijk! + De eerste alinea van dit vastgemaakte topic is zichtbaar op uw startpagina als een welkomstbericht voor alle nieuwe bezoekers. Dit is belangrijk! - **Bewerk dit** naar een korte beschrijving van uw gemeenschap: + **Bewerk dit** tot een korte beschrijving van uw community: - Voor wie is deze bestemd? - Wat kunnen ze hier vinden? - Waarom zouden ze hier moeten komen? - - Waar kunnen ze meer lezen (koppelingen, bronnen, etc.)? + - Waar kunnen ze meer lezen (links, bronnen, enz.)? @@ -534,7 +534,7 @@ nl: admin_quick_start_title: "LEES MIJ EERST: Snelstartgids voor beheerders" category: topic_prefix: "Over de categorie %{category}" - replace_paragraph: "(Vervang deze eerste alinea door een korte beschrijving van uw nieuwe categorie. Deze leidraad verschijnt in het categorieselectiegebied, dus probeer deze onder de 200 tekens te houden.)" + replace_paragraph: "(Vervang deze eerste alinea door een korte beschrijving van de nieuwe categorie. Deze leidraad wordt weergegeven in het categorieselectiegebied, dus probeer deze onder de 200 tekens te houden.)" post_template: "%{replace_paragraph}\n\nGebruik de volgende alinea's voor een langere beschrijving, of om richtlijnen of regels voor de categorie op te stellen:\n\n- Waarom zouden mensen deze categorie gebruiken? Waar dient deze voor?\n\n- Op welke punten onderscheidt deze categorie zich van andere categorieën die we al hebben?\n\n- Wat dienen topics in deze categorie over het algemeen te bevatten?\n\n- Hebben we deze categorie nodig? Kunnen we deze samenvoegen met een andere categorie of subcategorie?\n" errors: not_found: "Categorie niet gevonden!" @@ -652,16 +652,15 @@ nl: one: "bijna %{count} jaar geleden" other: "bijna %{count} jaar geleden" password_reset: - no_token: "Sorry, die koppeling voor het wijzigen van uw wachtwoord is te oud. Selecteer de knop Aanmelden en gebruik 'Ik ben mijn wachtwoord vergeten' om een nieuwe koppeling te ontvangen." choose_new: "Een nieuw wachtwoord kiezen" choose: "Een wachtwoord kiezen" update: "Wachtwoord bijwerken" save: "Wachtwoord instellen" - title: "Wachtwoord herinitialiseren" - success: "U hebt uw wachtwoord succesvol gewijzigd en bent nu aangemeld." - success_unapproved: "U hebt uw wachtwoord succesvol gewijzigd." + title: "Wachtwoord herstellen" + success: "U hebt uw wachtwoord gewijzigd en bent nu aangemeld." + success_unapproved: "U hebt uw wachtwoord gewijzigd." email_login: - invalid_token: "Sorry, die koppeling voor het aanmelden via e-mail is te oud. Selecteer de knop Aanmelden en gebruik 'Ik ben mijn wachtwoord vergeten' om een nieuwe koppeling te ontvangen." + invalid_token: "Sorry, die e-mailaanmeldlink is te oud. Selecteer de knop Aanmelden en gebruik 'Ik ben mijn wachtwoord vergeten' om een nieuwe link te ontvangen." title: "E-mailaanmelding" user_auth_tokens: browser: @@ -699,7 +698,7 @@ nl: error: "Er is een fout opgetreden bij het wijzigen van uw e-mailadres. Misschien is het adres al in gebruik?" doesnt_exist: "Dat e-mailadres is niet aan uw account gekoppeld." error_staged: "Er is een fout opgetreden bij het wijzigen van uw e-mailadres. Het adres is al in gebruik door een staged gebruiker." - already_done: "Sorry, deze bevestigingskoppeling is niet meer geldig. Misschien is uw e-mailadres al gewijzigd?" + already_done: "Sorry, deze bevestigingslink is niet meer geldig. Misschien is uw e-mailadres al gewijzigd?" confirm: "Bevestigen" authorizing_new: title: "Bevestig uw nieuwe e-mailadres" @@ -709,13 +708,13 @@ nl: old_email: "Oude e-mailadres: %{email}" new_email: "Nieuwe e-mailadres: %{email}" almost_done_title: "Nieuwe e-mailadres bevestigen" - almost_done_description: "Er is een e-mail naar uw nieuwe e-mailadres verstuurd om de wijziging te bevestigen!" + almost_done_description: "Er is een e-mail naar uw nieuwe e-mailadres gestuurd om de wijziging te bevestigen!" associated_accounts: revoke_failed: "Intrekken van uw account bij %{provider_name} is mislukt." connected: "(verbonden)" activation: action: "Klik hier om uw account te activeren" - already_done: "Sorry, deze koppeling voor het bevestigen van uw account is niet meer geldig. Misschien is uw account al actief?" + already_done: "Sorry, deze link voor het bevestigen van uw account is niet meer geldig. Misschien is uw account al actief?" please_continue: "Uw account is bevestigd; u wordt nu doorgestuurd naar de startpagina." continue_button: "Doorgaan naar %{site_name}" welcome_to: "Welkom bij %{site_name}!" @@ -738,14 +737,14 @@ nl: short_description: "Niet relevant voor de discussie" spam: title: "Spam" - description: "Dit bericht is een advertentie of vandalisme. Het is niet relevant voor het huidige onderwerp." + description: "Dit bericht is een advertentie of vandalisme. Het is niet relevant voor het huidige topic." short_description: "Dit is een advertentie of vandalisme" email_title: '''%{title}'' is als spam gemarkeerd' email_body: "%{link}\n\n%{message}" inappropriate: title: "Ongepast" - description: 'Dit bericht bevat inhoud die een redelijk persoon als beledigend, kwetsend of een overtreding van onze gemeenschapsrichtlijnen zou beschouwen.' - short_description: 'Een overtreding van onze gemeenschapsrichtlijnen' + description: 'Dit bericht bevat inhoud die een redelijk persoon als beledigend, kwetsend of een overtreding van onze communityrichtlijnen zou beschouwen.' + short_description: 'Een overtreding van onze communityrichtlijnen' notify_user: title: "@%{username} een bericht sturen" description: "I wil rechtstreeks en persoonlijk met deze persoon over zijn of haar bericht praten." @@ -780,7 +779,7 @@ nl: invalid_origin_error: "De oorsprong van de authenticatieaanvraag komt niet overeen met de serveroorsprong." malformed_attestation_error: "Er is een fout opgetreden bij het decoderen van de attestgegevens." invalid_relying_party_id_error: "De Relying Party-ID van de authenticatieaanvraag komt niet overeen met de Relying Party-ID van de server." - user_verification_error: "Er is gebruikersverificatie vereist." + user_verification_error: "Gebruikersverificatie is vereist." unsupported_public_key_algorithm_error: "Het opgegeven algoritme van de openbare sleutel wordt niet ondersteund door de server." unsupported_attestation_format_error: "De attestation-indeling wordt niet ondersteund door de server." credential_id_in_use_error: "De opgegeven referentie-ID is al in gebruik." @@ -796,9 +795,9 @@ nl: short_description: "Dit is een advertentie" inappropriate: title: "Ongepast" - description: 'Dit topic bevat inhoud die een redelijk persoon als beledigend, kwetsend of een overtreding van onze gemeenschapsrichtlijnen zou beschouwen.' + description: 'Dit topic bevat inhoud die een redelijk persoon als beledigend, kwetsend of een overtreding van onze communityrichtlijnen zou beschouwen.' long_form: "heeft dit als ongepast gemarkeerd" - short_description: 'Een overtreding van onze gemeenschapsrichtlijnen' + short_description: 'Een overtreding van onze communityrichtlijnen' notify_moderators: title: "Iets anders" description: 'Dit topic vereist algemene aandacht van een staflid op basis van de richtlijnen, TOS, of om een andere reden dan hierboven vermeld.' @@ -807,8 +806,8 @@ nl: email_title: 'Het topic ''%{title}'' vereist aandacht van een moderator' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

    Uw bericht is gemarkeerd door de gemeenschap. Bekijk uw berichten.

    ' - user_must_edit: "

    Uw bericht is gemarkeerd door de gemeenschap en is tijdelijk verborgen.

    " + you_must_edit: '

    Uw bericht is gemarkeerd door de community. Bekijk uw berichten.

    ' + user_must_edit: "

    Uw bericht is gemarkeerd door de community en is tijdelijk verborgen.

    " ignored: hidden_content: "

    Verborgen inhoud

    " archetypes: @@ -817,8 +816,8 @@ nl: banner: title: "Bannertopic" message: - make: "Dit topic is nu een banner. De banner verschijnt bovenaan elke pagina, totdat de gebruiker deze verbergt." - remove: "Dit topic is geen banner meer. Het zal niet meer bovenaan elke pagina verschijnen." + make: "Dit topic is nu een banner. De banner wordt weergegeven bovenaan elke pagina, totdat de gebruiker deze sluit." + remove: "Dit topic is geen banner meer. Het wordt niet meer bovenaan elke pagina weergegeven." unsubscribed: title: "E-mailvoorkeuren bijgewerkt!" topic_description: "Gebruik de meldingsinstellingen onder of rechts van het topic om u opnieuw voor %{link} in te schrijven." @@ -850,7 +849,7 @@ nl: read: "lezen" read_write: "lezen/schrijven" description: '''%{application_name}'' vraagt de volgende toegang tot uw account:' - instructions: 'We hebben zojuist een nieuwe API-gebruikerssleutel voor u gegenereerd voor gebruik met ''%{application_name}''. Plak de volgende koppeling in uw toepassing:' + instructions: 'We hebben zojuist een nieuwe API-gebruikerssleutel voor u gegenereerd voor gebruik met ''%{application_name}''. Plak de volgende link in uw toepassing:' otp_description: 'Wilt u ''%{application_name}'' toegang tot deze website geven?' otp_confirmation: confirm_title: Doorgaan naar %{site_name} @@ -963,7 +962,7 @@ nl: title: "DAU/MAU" xaxis: "Day" yaxis: "DAU/MAU" - description: "Aantal leden dat de afgelopen dag was aangemeld, gedeeld door het aantal leden dat de afgelopen maand was aangemeld – geeft een % terug dat 'plakkerigheid' in de gemeenschap aangeeft. Het streven is >30%." + description: "Aantal leden dat de afgelopen dag was aangemeld, gedeeld door het aantal leden dat de afgelopen maand was aangemeld – geeft een % terug dat 'plakkerigheid' in de community aangeeft. Het streven is >30%." daily_engaged_users: title: "Dagelijkse actieve gebruikers" xaxis: "Dag" @@ -1045,12 +1044,12 @@ nl: title: "Systeem" xaxis: "Dag" yaxis: "Aantal berichten" - description: "Aantal persoonlijke berichten dat automatisch door het systeem is verstuurd." + description: "Aantal persoonlijke berichten dat automatisch door het systeem is verzonden." moderator_warning_private_messages: title: "Moderatorwaarschuwing" xaxis: "Dag" yaxis: "Aantal berichten" - description: "Aantal waarschuwingen dat door persoonlijke berichten van moderators is verstuurd." + description: "Aantal waarschuwingen dat door persoonlijke berichten van moderators is verzonden." notify_moderators_private_messages: title: "Moderators inlichten" xaxis: "Dag" @@ -1070,7 +1069,7 @@ nl: user: "Gebruiker" num_clicks: "Klikken" num_topics: "Topics" - description: "Gebruikers vermeld op aantallen klikken op koppelingen die ze hebben gedeeld." + description: "Gebruikers vermeld op volgorde van het aantal klikken op links die ze hebben gedeeld." top_traffic_sources: title: "Meeste verkeersbronnen" xaxis: "Domein" @@ -1224,13 +1223,13 @@ nl: failing_emails_warning: 'Er zijn %{num_failed_jobs} mislukte e-mailtaken. Controleer uw bestand app.yml en zorg ervoor dat de mailserverinstellingen juist zijn. Bekijk de mislukte taken in Sidekiq.' subfolder_ends_in_slash: "Uw submapconfiguratie is onjuist; de DISCOURSE_RELATIVE_URL_ROOT eindigt met een schuine streep." email_polling_errored_recently: - one: "E-mailpolling heeft de afgelopen 24 uur een fout gegenereerd. Bekijk de logboeken voor meer details." - other: "E-mailpolling heeft de afgelopen 24 uur %{count} fouten gegenereerd. Bekijk de logboeken voor meer details." + one: "E-mailpolling heeft de afgelopen 24 uur een fout gegenereerd. Zie de logs voor meer informatie." + other: "E-mailpolling heeft de afgelopen 24 uur %{count} fouten gegenereerd. Zie de logs voor meer informatie." missing_mailgun_api_key: "De server is geconfigureerd om e-mails via Mailgun te verzenden, maar u hebt geen API-sleutel opgegeven voor verificatie van de webhookberichten." bad_favicon_url: "De favicon wordt niet geladen. Controleer uw favicon-instelling in de Website-instellingen." poll_pop3_timeout: "Time-out voor verbinding met de POP3-server. Inkomende e-mail kon niet worden opgehaald. Controleer uw POP3-instellingen en serviceprovider." poll_pop3_auth_error: "Verbinding met de POP3-server is mislukt met een authenticatiefout. Controleer uw POP3-instellingen." - force_https_warning: "Uw website gebruikt SSL, maar `force_https` is nog niet ingeschakeld in uw website-instellingen." + force_https_warning: "Uw website gebruikt SSL, maar 'force_https' is nog niet ingeschakeld in uw website-instellingen." out_of_date_themes: "Er zijn updates voor de volgende thema's beschikbaar:" unreachable_themes: "We konden niet op updates controleren voor de volgende thema's:" site_settings: @@ -1246,9 +1245,9 @@ nl: min_first_post_length: "Minimaal toegestane lengte van eerste bericht (topictekst) in tekens" min_personal_message_post_length: "Minimaal toegestane berichtlengte in tekens voor berichten" max_post_length: "Maximaal toegestane lengte van een bericht in tekens" - topic_featured_link_enabled: "Plaatsing van een koppeling met topics toestaan" - show_topic_featured_link_in_digest: "Koppeling naar aanbevolen topics in de samenvattingsmail tonen" - min_topic_views_for_delete_confirm: "Minimale aantal weergaven dat een topic moet hebben om een bevestigingspop-up te laten verschijnen wanneer het wordt verwijderd" + topic_featured_link_enabled: "Sta het plaatsen van een link bij topics toe." + show_topic_featured_link_in_digest: "Link naar aanbevolen topic weergeven in de digest-e-mail." + min_topic_views_for_delete_confirm: "Minimale aantal weergaven dat een topic moet hebben voordat een bevestigingspop-up wordt weergegeven wanneer het wordt verwijderd" min_topic_title_length: "Minimaal toegestane lengte van een topictitel in tekens" max_topic_title_length: "Maximaal toegestane lengte van een topictitel in tekens" min_personal_message_title_length: "Minimaal toegestane titellengte voor een bericht in tekens" @@ -1284,9 +1283,9 @@ nl: max_image_height: "Maximale miniatuurhoogte van afbeeldingen in een bericht" responsive_post_image_sizes: "Grootte van Lightbox-voorbeeldafbeeldingen mag op hoge-DPI-schermen met de volgende pixelverhoudingen worden aangepast. Verwijder alle waarden om responsieve afbeeldingen uit te schakelen." fixed_category_positions: "Wanneer aangevinkt, kunt u categorieën in een vaste volgorde schikken. Wanneer niet aangevinkt, worden categorieën op activiteit vermeld." - fixed_category_positions_on_create: "Wanneer aangevinkt, wordt de categorievolgorde bij het maken van een topic aangehouden (vereist fixed_category_positions)." - add_rel_nofollow_to_user_content: '''rel nofollow'' toevoegen aan alle ingediende gebruikersinhoud, behalve voor interne koppelingen (inclusief bovenliggende domeinen). Als u dit wijzigt, moet u alle berichten opnieuw opbouwen met ''rake posts:rebake''' - exclude_rel_nofollow_domains: "Een lijst van domeinen waarvoor 'nofollow' niet aan koppelingen mag worden toegevoegd. example.com staat ook automatisch sub.example.com toe. Voeg minimaal het domein van deze website toe om webcrawlers alle inhoud te helpen vinden. Als andere onderdelen van uw website zich op andere domeinen bevinden, voeg die dan ook toe." + fixed_category_positions_on_create: "Wanneer aangevinkt, wordt de categorievolgorde aangehouden bij het maken van een topic (vereist fixed_category_positions)." + add_rel_nofollow_to_user_content: 'Voeg ''rel nofollow'' toe aan alle ingediende gebruikerscontent, behalve voor interne links (inclusief bovenliggende domeinen). Als u dit wijzigt, moet u alle berichten opnieuw opbouwen met ''rake posts:rebake''' + exclude_rel_nofollow_domains: "Een lijst van domeinen waarvoor 'nofollow' niet aan links mag worden toegevoegd. example.com staat ook automatisch sub.example.com toe. Voeg minimaal het domein van deze website toe om webcrawlers te helpen alle inhoud te vinden. Als onderdelen van uw website zich op andere domeinen bevinden, voeg die dan ook toe." post_excerpt_maxlength: "Maximale lengte van een fragment / samenvatting van een bericht." topic_excerpt_maxlength: "Maximale lengte van een fragment / samenvatting van een topic, gegenereerd vanuit het eerste bericht in een topic." show_pinned_excerpt_mobile: "Fragment tonen bij vastgemaakte topics in mobiele weergave." @@ -1299,8 +1298,8 @@ nl: facebook_app_access_token: "Een token, gegenereerd op basis van uw Facebook-app-ID en -geheim. Gebruikt om Instagram-oneboxes te genereren." logo: "De logoafbeelding links bovenaan op uw website. Gebruik een brede rechthoekige afbeelding met een hoogte van 120 en een hoogte-breedteverhouding groter dan 3:1. Bij leeg laten wordt de titeltekst van de website getoond." logo_small: "De kleine logoafbeelding links bovenaan op uw website, zichtbaar bij omlaag scrollen. Gebruik een vierkante afbeelding van 120 × 120. Bij leeg laten wordt een startpaginasymbool getoond." - digest_logo: "De alternatieve logoafbeelding bovenaan de e-mailsamenvatting van uw website. Gebruik een brede rechthoekige afbeelding. Gebruik geen SVG-afbeelding. Bij leeg laten wordt de afbeelding van de instelling `logo` gebruikt." - mobile_logo: "Het logo dat op de mobiele versie van uw website wordt gebruikt. Gebruik een brede rechthoekige afbeelding met een hoogte van 120 en een hoogte-breedteverhouding groter dan 3:1. Bij leeg laten wordt de afbeelding van de instelling `logo` gebruikt." + digest_logo: "De alternatieve logoafbeelding bovenaan de e-mailsamenvatting van uw website. Gebruik een brede rechthoekige afbeelding. Gebruik geen SVG-afbeelding. Bij leeg laten wordt de afbeelding van de instelling 'logo' gebruikt." + mobile_logo: "Het logo dat op de mobiele versie van uw website wordt gebruikt. Gebruik een brede rechthoekige afbeelding met een hoogte van 120 en een hoogte-breedteverhouding groter dan 3:1. Bij leeg laten wordt de afbeelding van de instelling 'logo' gebruikt." large_icon: "Afbeelding die als basis voor andere metagegevenspictogrammen wordt gebruikt. Moet idealiter groter zijn dan 512 x 512. Bij leeg laten wordt logo_small gebruikt." manifest_icon: "Afbeelding die als logo/splashafbeelding op Android wordt gebruikt. Wordt automatisch verkleind naar 512 x 512. Bij leeg laten wordt large_icon gebruikt." favicon: "Een favicon voor uw website, zie https://nl.wikipedia.org/wiki/Favicon. Om goed via een CDN te werken, moet dit een png zijn. Wordt verkleind naar 32x32. Bij leeg laten wordt large_icon gebruikt." @@ -1325,7 +1324,7 @@ nl: hide_post_sensitivity: "De waarschijnlijkheid dat een gemarkeerd bericht wordt verborgen" silence_new_user_sensitivity: "De waarschijnlijkheid dat een nieuwe gebruiker wordt gedempt op basis van spammarkeringen" auto_close_topic_sensitivity: "De waarschijnlijkheid dat een gemarkeerd topic automatisch wordt gesloten" - cooldown_minutes_after_hiding_posts: "Het aantal minuten dat een gebruiker moet wachten voordat deze een via de gemeenschap gemarkeerd bericht kan bewerken" + cooldown_minutes_after_hiding_posts: "Het aantal minuten dat een gebruiker moet wachten voordat deze een via de community gemarkeerd bericht kan bewerken" max_topics_in_first_day: "Het maximale aantal topics dat een gebruiker in de 24 uursperiode na het schrijven van een eerste bericht mag aanmaken" max_replies_in_first_day: "Het maximale aantal antwoorden dat een gebruiker in de 24 uursperiode na het schrijven van een eerste bericht mag aanmaken" tl2_additional_likes_per_day_multiplier: "Limiet van likes per dag voor TL2 (lid) verhogen door met dit getal te vermenigvuldigen" @@ -1338,8 +1337,8 @@ nl: flag_sockpuppets: "Als een nieuwe gebruiker op een topic antwoordt vanaf hetzelfde IP-adres als de gebruiker die het topic heeft gestart, beide berichten ervan markeren als potentiële spam." traditional_markdown_linebreaks: "Traditionele regeleinden gebruiken in Markdown, waarin 2 spaties aan het einde van een regel nodig zijn voor een regeleinde." enable_markdown_typographer: "Typografieregels gebruiken om leesbaarheid van tekst te verbeteren: rechte aanhalingstekens ' vervangen door gekrulde aanhalingstekens ’, (c) (tm) door symbolen, -- door emdash –, etc." - enable_markdown_linkify: "Tekst die eruitziet als een koppeling automatisch als een koppeling behandelen: www.example.com en https://example.com worden automatisch gekoppeld" - markdown_linkify_tlds: "Lijst van topleveldomeinen die automatisch als koppeling worden behandeld" + enable_markdown_linkify: "Behandel tekst die eruitziet automatisch als een link: www.example.com en https://example.com worden automatisch gelinkt" + markdown_linkify_tlds: "Lijst van topleveldomeinen die automatisch als link worden behandeld" markdown_typographer_quotation_marks: "Lijst van vervangingsparen voor dubbele en enkele aanhalingstekens" post_undo_action_window_mins: "Het aantal minuten dat gebruikers recente acties op een bericht ongedaan mogen maken (liken, markeren, etc)." must_approve_users: "Stafleden moeten alle nieuwe gebruikersaccounts goedkeuren voordat ze de website mogen bezoeken." @@ -1349,7 +1348,7 @@ nl: gtm_container_id: "Google Tag Manager-container-ID, bv. GTM-ABCDEF.
    Opmerking: scripts van derden die door GTM worden geladen, dienen mogelijk op de acceptatielijst te worden geplaatst in 'content security policy script src'." enable_escaped_fragments: "Terugvallen naar Google's Ajax-Crawling-API als geen webcrawler wordt gedetecteerd. Zie https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" cors_origins: "Toegestane domeinen voor cross-origin-aanvragen (CORS). Elk domein moet http:// of https:// bevatten. De omgevingsvariabele DISCOURSE_ENABLE_CORS moet op true zijn ingesteld om CORS in te schakelen." - use_admin_ip_allowlist: "Beheerders kunnen zich alleen aanmelden vanaf een IP-adres dat in de lijst Gecontroleerde IP-nummers is gedefinieerd (Beheer > Logboeken > Gecontroleerde IP-adressen)." + use_admin_ip_allowlist: "Beheerders kunnen zich alleen aanmelden vanaf een IP-adres dat is gedefinieerd in de lijst Gecontroleerde IP-nummers (Beheer > Logs > Gecontroleerde IP-adressen)." blocked_ip_blocks: "Een lijst van private IP-blokken die nooit door Discourse mogen worden verkend" allowed_internal_hosts: "Een lijst van interne hosts die Discourse veilig kan verkennen voor oneboxing en andere doeleinden" allowed_iframes: "Een lijst van 'iframe src'-domeinvoorvoegsels die Discourse veilig kan toestaan in berichten" @@ -1360,10 +1359,10 @@ nl: content_security_policy_collect_reports: "Rapportverzameling voor CSP-schendingen inschakelen via /csp_reports" content_security_policy_script_src: "Aanvullende scriptbronnen op de acceptatielijst. De huidige host en CDN zijn standaard inbegrepen. Zie Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Beheerdersaccounts die de website dit aantal dagen niet hebben bezocht, dienen hun e-mailadres voor het aanmelden opnieuw te valideren. Stel dit in op 0 om uit te schakelen." - top_menu: "Bepalen welke items in het hoofdnavigatiemenu verschijnen, en in welke volgorde. Voorbeeld: latest|new|unread|categories|top|read|posted|bookmarks" - post_menu: "Bepalen welke items in het berichtmenu verschijnen, en in welke volgorde. Voorbeeld: like|edit|flag|delete|share|bookmark|reply" + top_menu: "Bepaal welke items in het hoofdnavigatiemenu worden weergegeven en in welke volgorde. Voorbeeld: latest|new|unread|categories|top|read|posted|bookmarks" + post_menu: "Bepaal welke items in het berichtmenu worden weergegeven en in welke volgorde. Voorbeeld: like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "De menu-items die standaard in het berichtmenu moeten worden verborgen, totdat er op een uitvouw-ellipsis wordt geklikt." - share_links: "Bepalen welke items in het deelmenu verschijnen, en in welke volgorde." + share_links: "Bepaal welke items in de deeldialoog worden weergegeven en in welke volgorde." site_contact_username: "Een geldige gebruikersnaam van een staflid waarvandaan alle automatische berichten worden verzonden. Wanneer leeg gelaten, wordt de standaardaccount van het systeem gebruikt." site_contact_group_name: "Een geldige groepsnaam voor uitnodiging in alle automatische berichten." send_welcome_message: "Alle nieuwe gebruikers een welkomstbericht met een snelstartgids sturen." @@ -1403,14 +1402,14 @@ nl: password_unique_characters: "Minimale aantal unieke tekens dat een wachtwoord moet hebben." block_common_passwords: "Geen wachtwoorden toestaan die in de 10.000 meest gebruikte wachtwoorden voorkomen." discourse_connect_overrides_bio: "Overschrijft biografie in gebruikersprofiel en voorkomt dat gebruiker deze kan wijzigen" - enable_local_logins_via_email: "Gebruikers mogen vragen een koppeling voor aanmelding met één klik via e-mail te laten toesturen." + enable_local_logins_via_email: "Sta gebruikers toe te verzoeken een aanmeldlink met één klik te ontvangen via e-mail." allow_new_registrations: "Nieuwe gebruikersregistraties toestaan. Vink dit uit om te voorkomen dat iedereen een nieuwe account kan maken." enable_signup_cta: "Een melding tonen voor terugkerende anonieme gebruikers waarin wordt gevraagd zich voor een account te registreren" enable_google_oauth2_logins: "Google Oauth2-authenticatie inschakelen. Dit is de methode voor authenticatie die Google momenteel ondersteunt. Vereist sleutel en geheim. Zie Configuring Google login for Discourse." google_oauth2_client_id: "Client-ID van uw Google-toepassing." google_oauth2_client_secret: "Client-geheim van uw Google-toepassing." google_oauth2_prompt: "Een optionele door spaties gescheiden lijst van tekenreekswaarden die bepaalt of de autorisatieserver de gebruiker opnieuw om authenticatie en instemming vraagt. Zie https://developers.google.com/identity/protocols/OpenIDConnect#prompt voor de mogelijke waarden." - google_oauth2_hd: "Een optioneel door Google Apps gehost domein waartoe de aanmelding wordt beperkt. Zie https://developers.google.com/identity/protocols/OpenIDConnect#hd-param voor meer details." + google_oauth2_hd: "Een optioneel door Google Apps gehost domein waartoe de aanmelding wordt beperkt. Zie https://developers.google.com/identity/protocols/OpenIDConnect#hd-param voor meer informatie." enable_twitter_logins: "Twitter-authenticatie inschakelen, vereist twitter_consumer_key en twitter_consumer_secret. Zie Configuring Twitter login (and rich embeds) for Discourse." twitter_consumer_key: "Consumentsleutel voor Twitter-authenticatie, geregistreerd bij https://developer.twitter.com/apps" twitter_consumer_secret: "Consumentgeheim voor Twitter-authenticatie, geregistreerd bij https://developer.twitter.com/apps" @@ -1439,9 +1438,9 @@ nl: active_user_rate_limit_secs: "Hoe vaak we het 'last_seen_at'-veld bijwerken, in seconden." verbose_localization: "Uitgebreide lokalisatietips in de gebruikersinterface tonen" previous_visit_timeout_hours: "Hoe lang een bezoek duurt voordat we het als het 'vorige' bezoek beschouwen, in uren." - top_topics_formula_log_views_multiplier: "waarde van vermenigvuldiger (n) voor logboekweergaven in toptopicsformule: `log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_first_post_likes_multiplier: "waarde van vermenigvuldiger (n) voor likes van eerste berichten in toptopicsformule: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_least_likes_per_post_multiplier: "waarde van vermenigvuldiger (n) voor minste likes per bericht in toptopicsformule: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" + top_topics_formula_log_views_multiplier: "waarde van vermenigvuldiger (n) voor logweergaven in toptopicsformule: 'log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)'" + top_topics_formula_first_post_likes_multiplier: "waarde van vermenigvuldiger (n) voor likes van eerste berichten in toptopicsformule: 'log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)'" + top_topics_formula_least_likes_per_post_multiplier: "waarde van vermenigvuldiger (n) voor minste likes per bericht in toptopicsformule: 'log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)'" enable_safe_mode: "Gebruikers mogen de veilige modus betreden om plug-ins te debuggen." rate_limit_create_topic: "Na het maken van een topic moeten gebruikers (n) seconden wachten voordat ze een ander topic kunnen maken." rate_limit_create_post: "Na het plaatsen van een bericht moeten gebruikers (n) seconden wachten voor ze een ander bericht kunnen plaatsen." @@ -1456,7 +1455,7 @@ nl: max_topic_invitations_per_day: "Maximale aantal uitnodigingen voor een topic dat een gebruiker per dag kan versturen." max_logins_per_ip_per_hour: "Maximale aantal toegestane aanmeldingen per IP-adres per uur" max_logins_per_ip_per_minute: "Maximale aantal toegestane aanmeldingen per IP-adres per minuut" - invite_link_max_redemptions_limit: "Het maximaal toegestane aantal inwisselingen voor uitnodigingskoppelingen kan niet meer zijn dan deze waarde." + invite_link_max_redemptions_limit: "Het maximaal toegestane aantal verzilveringen van uitnodigingslinks mag niet hoger zijn dan deze waarde." alert_admins_if_errors_per_minute: "Aantal foutmeldingen per minuut waarbij de beheerder een melding krijgt. Een waarde van 0 schakelt deze functie uit. OPMERKING: vereist herstart." alert_admins_if_errors_per_hour: "Aantal foutmeldingen per uur waarbij de beheerder een melding krijgt. Een waarde van 0 schakelt deze functie uit. OPMERKING: vereist herstart." categories_topics: "Aantal topics dat in de /categories-pagina wordt getoond. Als dit op 0 is ingesteld, wordt automatisch naar een waarde gezocht die de twee kolommen symmetrisch houdt (categorieën en topics)." @@ -1500,20 +1499,20 @@ nl: tl2_requires_likes_received: "Hoeveel likes een gebruiker moet ontvangen voordat deze naar vertrouwensniveau 2 wordt gepromoveerd." tl2_requires_likes_given: "Hoeveel likes een gebruiker moet geven voordat deze naar vertrouwensniveau 2 wordt gepromoveerd." tl2_requires_topic_reply_count: "Op hoeveel topics een gebruiker moet antwoorden voordat deze naar vertrouwensniveau 2 wordt gepromoveerd." - tl3_time_period: "Tijdsperiode voor vereisten voor vertrouwensniveau 3 (in dagen)" + tl3_time_period: "Tijd voor vereisten voor vertrouwensniveau 3 (in dagen)" tl3_requires_days_visited: "Minimale aantal dagen dat een gebruiker de website in de afgelopen (tl3 time period) dagen moet hebben bezocht om voor promotie naar vertrouwensniveau 3 in aanmerking te komen. Stel dit hoger in dan tl3 time period om promoties naar tl3 uit te schakelen. (0 of hoger)" tl3_requires_topics_replied_to: "Minimale aantal topics waarop een gebruiker in de afgelopen (tl3 time period) dagen moet hebben geantwoord om voor promotie naar vertrouwensniveau 3 in aanmerking te komen. (0 of hoger)" tl3_requires_topics_viewed: "Het percentage in de afgelopen (tl3 time period) dagen gemaakte topics dat een gebruiker moet hebben bekeken om voor promotie naar vertrouwensniveau 3 in aanmerking te komen. (0 tot 100)" - tl3_requires_topics_viewed_cap: "Het maximaal vereiste aantal topics dat in de afgelopen (tl3 time period) dagen is bekeken." + tl3_requires_topics_viewed_cap: "Het maximaal vereiste aantal topics dat de afgelopen (tl3 time period) dagen is bekeken." tl3_requires_posts_read: "Het percentage in de afgelopen (tl3 time period) dagen gemaakte berichten dat een gebruiker moet hebben bekeken om voor promotie naar vertrouwensniveau 3 in aanmerking te komen. (0 tot 100)" - tl3_requires_posts_read_cap: "Het maximaal vereiste aantal berichten dat in de afgelopen (tl3 time period) dagen is bekeken." + tl3_requires_posts_read_cap: "Het maximaal vereiste aantal berichten dat de afgelopen (tl3 time period) dagen is bekeken." tl3_requires_topics_viewed_all_time: "Het minimale totale aantal topics dat een gebruiker moet hebben bekeken om voor vertrouwensniveau 3 in aanmerking te komen." tl3_requires_posts_read_all_time: "Het minimale totale aantal berichten dat een gebruiker moet hebben gelezen om voor vertrouwensniveau 3 in aanmerking te komen." tl3_requires_max_flagged: "Een gebruiker mag niet meer dan x berichten gemarkeerd door x verschillende gebruikers in de afgelopen (tl3 time period) dagen hebben gehad om voor promotie naar vertrouwensniveau 3 in aanmerking te komen, waarin x de waarde van de instelling is. (0 of hoger)" tl3_promotion_min_duration: "Het minimale aantal dagen dat een promotie naar vertrouwensniveau 3 duurt voordat een gebruiker naar vertrouwensniveau 2 kan worden gedegradeerd." tl3_requires_likes_given: "Het minimale aantal likes dat in de afgelopen (tl3 time period) dagen moet worden gegeven om voor promotie naar vertrouwensniveau 3 in aanmerking te komen." tl3_requires_likes_received: "Het minimale aantal likes dat in de afgelopen (tl3 time period) dagen moet worden ontvangen om voor promotie naar vertrouwensniveau 3 in aanmerking te komen." - tl3_links_no_follow: "rel=nofollow niet uit koppelingen verwijderen die door gebruikers met vertrouwensniveau 3 zijn geplaatst." + tl3_links_no_follow: "Verwijder rel=nofollow niet uit links die door gebruikers met vertrouwensniveau 3 zijn geplaatst." trusted_users_can_edit_others: "Gebruikers met hoge vertrouwensniveaus mogen inhoud van andere gebruikers bewerken" min_trust_to_create_topic: "Het minimale vertrouwensniveau dat nodig is om een topic te maken." allow_flagging_staff: "Wanneer ingeschakeld, kunnen gebruikers berichten van stafaccounts markeren." @@ -1521,19 +1520,19 @@ nl: min_trust_to_edit_post: "Het minimale vertrouwensniveau dat nodig is om berichten te bewerken." min_trust_to_allow_self_wiki: "Het minimale vertrouwensniveau dat nodig is om een gebruiker een eigen bericht naar een wiki te laten omzetten." min_trust_to_flag_posts: "Het minimale vertrouwensniveau dat nodig is om berichten te markeren" - min_trust_to_post_links: "Het minimale vertrouwensniveau dat nodig is om koppelingen in berichten op te nemen" + min_trust_to_post_links: "Het minimale vertrouwensniveau dat nodig is om links op te nemen in berichten" min_trust_to_post_embedded_media: "Het minimale vertrouwensniveau dat nodig is om media-items in een bericht in te bedden" min_trust_level_to_allow_profile_background: "Het minimale vertrouwensniveau dat nodig is om een profielachtergrond te uploaden" min_trust_level_to_allow_user_card_background: "Het minimale vertrouwensniveau dat nodig is om een gebruikerskaartachtergrond te uploaden" min_trust_level_to_allow_invite: "Het minimale vertrouwensniveau dat nodig is om gebruikers uit te nodigen" min_trust_level_to_allow_ignore: "Het minimale vertrouwensniveau dat nodig is om gebruikers te negeren" - allowed_link_domains: "Domeinen waarnaar gebruikers mogen verwijzen, zelfs als ze niet het juiste vertrouwensniveau hebben om koppelingen te plaatsen" - newuser_max_links: "Hoeveel koppelingen een nieuwe gebruiker aan een bericht kan toevoegen." + allowed_link_domains: "Domeinen waar gebruikers naartoe mogen linken, ook als ze niet het juiste vertrouwensniveau hebben om links te plaatsen" + newuser_max_links: "Hoeveel links een nieuwe gebruiker kan toevoegen aan een bericht." newuser_max_attachments: "Hoeveel bijlagen een nieuwe gebruiker aan een bericht kan toevoegen." newuser_max_mentions_per_post: "Maximale aantal @naam-vermeldingen dat een nieuwe gebruiker in een bericht kan gebruiken." newuser_max_replies_per_topic: "Maximale aantal antwoorden dat een nieuwe gebruiker in één topic kan plaatsen totdat iemand ze beantwoordt." max_mentions_per_post: "Maximale aantal @naam-vermeldingen dat iedereen in een bericht kan gebruiken." - max_users_notified_per_group_mention: "Maximale aantal gebruikers dat een melding kan ontvangen als een groep wordt genoemd (bij bereiken van drempel worden geen meldingen verstuurd)" + max_users_notified_per_group_mention: "Maximale aantal gebruikers dat een melding kan ontvangen als een groep wordt genoemd (bij bereiken van drempel worden geen meldingen gestuurd)" enable_mentions: "Gebruikers mogen andere gebruikers noemen." create_thumbnails: "Miniaturen maken voor lightbox-afbeeldingen die te groot zijn om in een bericht te passen." email_time_window_mins: "(n) minuten wachten met het verzenden van meldingen per e-mail, zodat gebruikers de kans hebben om hun berichten te bewerken en te voltooien." @@ -1555,7 +1554,7 @@ nl: max_image_size_kb: "De maximale grootte van te uploaden afbeeldingen in kB. Dit moet ook worden geconfigureerd in nginx (client_max_body_size) / apache of proxy. Afbeeldingen die groter zijn dan deze waarde en kleiner dan client_max_body_size worden bij het uploaden verkleind." max_attachment_size_kb: "De maximale grootte van te uploaden bijlagen in kB. Dit moet ook worden geconfigureerd in nginx (client_max_body_size) / apache of proxy." authorized_extensions: "Een lijst van toegestane bestandsextensies voor uploaden (gebruik '*' voor alle bestandstypen)" - authorized_extensions_for_staff: "Een lijst van toegestane bestandsextensies voor uploaden voor stafleden, naast de lijst gedefinieerd in de website-instelling `authorized_extensions` (gebruik '*' voor alle bestandstypen)" + authorized_extensions_for_staff: "Een lijst van toegestane bestandsextensies voor uploaden voor stafleden, naast de lijst gedefinieerd in de website-instelling 'authorized_extensions' (gebruik '*' voor alle bestandstypen)" theme_authorized_extensions: "Een lijst van toegestane bestandsextensies voor thema-uploads (gebruik '*' voor alle bestandstypen)" max_similar_results: "Hoeveel vergelijkbare topics om boven de editor te tonen bij het opstellen van een nieuw topic. Vergelijking is gebaseerd op titel en inhoud." max_image_megapixels: "Maximale aantal toegestane megapixels voor een afbeelding. Afbeeldingen met een groter aantal megapixels worden geweigerd." @@ -1577,9 +1576,9 @@ nl: faq_url: "Als u ergens anders een FAQ hebt gehost die u wilt gebruiken, geef hier dan de volledige URL op." tos_url: "Als u ergens anders een document met Algemene voorwaarden hebt gehost, geef hier dan de volledige URL op." privacy_policy_url: "Als u ergens anders een document met een Privacybeleid hebt gehost, geef hier dan de volledige URL op." - log_anonymizer_details: "Of de details van een gebruiker in het logboek worden behouden nadat ze zijn geanonimiseerd. Om aan de GDPR te voldoen, dient u dit uit te schakelen." - newuser_spam_host_threshold: "Hoe vaak een nieuwe gebruiker een koppeling naar dezelfde host kan plaatsen binnen de `newuser_spam_host_threshold` berichten ervan voordat deze als spam worden beschouwd." - allowed_spam_host_domains: "Een lijst met domeinen die niet op spam worden gecontroleerd. Nieuwe gebruikers kunnen onbeperkt koppelingen naar deze domeinen plaatsen." + log_anonymizer_details: "Of de gegevens van een gebruiker in de log worden behouden nadat ze zijn geanonimiseerd. Om aan de AVG te voldoen, moet dit worden uitgeschakeld." + newuser_spam_host_threshold: "Hoe vaak een nieuwe gebruiker een link naar dezelfde host kan plaatsen binnen de 'newuser_spam_host_threshold' berichten ervan voordat deze als spam wordt beschouwd." + allowed_spam_host_domains: "Een lijst met domeinen die niet op spam worden gecontroleerd. Nieuwe gebruikers kunnen onbeperkt links naar deze domeinen plaatsen." topic_view_duration_hours: "Elke N uur één keer per IP/Gebruiker een nieuw-topicweergave tellen." user_profile_view_duration_hours: "Elke N uur één keer per IP/Gebruiker een nieuw-gebruikersprofielweergave tellen." levenshtein_distance_spammer_emails: "Bij het vergelijken van spam-e-mails, het aantal verschillende tekens waarbij nog steeds een wazige overeenkomst kan bestaan." @@ -1592,7 +1591,7 @@ nl: min_first_post_typing_time: "Minimale tijd in milliseconden dat een gebruiker moet typen tijdens een eerste bericht. Als de drempelwaarde niet wordt bereikt, wordt het bericht automatisch in de wachtrij voor goedkeuring gezet. Stel dit in op 0 om uit te schakelen (niet aanbevolen)." auto_silence_fast_typers_on_first_post: "Automatisch gebruikers dempen die niet aan min_first_post_typing_time voldoen" auto_silence_fast_typers_max_trust_level: "Maximale vertrouwensniveau om snelle typers automatisch te dempen" - reviewable_claiming: "Dient beoordeelbare inhoud te worden opgeëist voordat er een handeling op kan worden uitgevoerd?" + reviewable_claiming: "Moet beoordeelbare inhoud worden geclaimd voordat er een actie op kan worden uitgevoerd?" reviewable_default_topics: "Beoordeelbare inhoud standaard gegroepeerd op topic tonen" reviewable_default_visibility: "Geen beoordeelbare items tonen, tenzij ze aan deze prioriteit voldoen" high_trust_flaggers_auto_hide_posts: "Berichten van nieuwe gebruikers worden automatisch verborgen nadat ze door een TL3+-gebruiker als spam zijn gemarkeerd" @@ -1606,9 +1605,9 @@ nl: strip_images_from_short_emails: "Afbeeldingen met grootte van minder dan 2800 bytes uit e-mails verwijderen" short_email_length: "Lengte van korte e-mail in bytes" display_name_on_email_from: "Volledige namen in van-veld van e-mails weergeven" - unsubscribe_via_email: "Gebruikers mogen zich uitschrijven van e-mails door een e-mail met 'unsubscribe' in het onderwerp of de tekst te sturen" - unsubscribe_via_email_footer: "Een mailto:-koppeling voor uitschrijven via e-mail in de voettekst van verstuurde e-mails toevoegen" - delete_email_logs_after_days: "E-maillogboeken na (N) dagen verwijderen. 0 voor oneindig behouden." + unsubscribe_via_email: "Sta gebruikers toe zich af te melden voor e-mails door een e-mail met 'afmelden' in het onderwerp of de tekst te sturen" + unsubscribe_via_email_footer: "Een mailto:-link voor afmelden via e-mail toevoegen in de voettekst van verzonden e-mails" + delete_email_logs_after_days: "E-maillogs verwijderen na (N) dagen. 0 voor oneindig bewaren." disallow_reply_by_email_after_days: "Antwoord via e-mail na (N) dagen niet toestaan. 0 voor oneindig behouden." max_emails_per_day_per_user: "Maximale aantal e-mails dat per dag naar gebruikers wordt verzonden. 0 om de limiet uit te schakelen" enable_staged_users: "Automatisch staged gebruikers aanmaken bij het verwerken van inkomende e-mails." @@ -1651,7 +1650,7 @@ nl: imap_polling_old_emails: "Het maximale aantal oude e-mails (verwerkt) dat telkens bij het pollen van een IMAP-postvak moet worden bijgewerkt (0 voor alle)." imap_polling_new_emails: "Het maximale aantal nieuwe e-mails (onverwerkt) dat telkens bij het pollen van een IMAP-postvak moet worden bijgewerkt." imap_batch_import_email: "Het minimale aantal nieuwe e-mails dat de importmodus activeert (schakelt berichtmeldingen uit)." - email_prefix: "Het [label] dat in het onderwerp van e-mails wordt gebruikt. Als niets is ingevuld, wordt 'title' gebruikt." + email_prefix: "Het [label] dat in het onderwerp van e-mails wordt gebruikt. Als niets is ingevuld, wordt 'titel' gebruikt." email_site_title: "De titel van de website die als de afzender van e-mails van de website wordt gebruikt. Standaard wordt 'titel' gebruikt als niets is ingesteld. Gebruik deze instelling als uw 'titel' tekens bevat die niet in tekenreeksen van e-mailafzenders zijn toegestaan." find_related_post_with_key: "Alleen de 'reply key' gebruiken om het beantwoorde bericht te vinden. WAARSCHUWING: uitschakelen hiervan staat imitatie van gebruikers op basis van e-mailadres toe." minimum_topics_similar: "Het aantal topics dat moet bestaan voordat er vergelijkbare topics worden voorgesteld bij het opstellen van nieuwe topics." @@ -1675,7 +1674,7 @@ nl: apply_custom_styles_to_digest: "Aangepaste e-mailsjabloon en CSS worden op e-mailsamenvattingen toegepast." email_accent_bg_color: "De te gebruiken accentkleur voor de achtergrond van bepaalde elementen in HTML-e-mails. Voer een kleurnaam ('red') of hex-waarde ('#FF0000') in." email_accent_fg_color: "De kleur van gerenderde tekst op de achtergrondkleur van HTML-e-mails. Voer een kleurnaam ('white') of hex-waarde ('#FFFFFF') in." - email_link_color: "De kleur van koppelingen in HTML-e-mails. Voer een kleurnaam ('blue') of hex-waarde ('#0000FF') in." + email_link_color: "De kleur van links in HTML-e-mails. Voer een kleurnaam ('blue') of hexwaarde ('#0000FF') in." detect_custom_avatars: "Wel of niet te verifiëren of gebruikers eigen profielfoto's hebben geüpload." max_daily_gravatar_crawls: "Maximale aantal keren dat Discourse op een dag bij Gravatar op aangepaste gravatars controleert" public_user_custom_fields: "Een lijst van aangepaste gebruikersvelden die met de API kan worden opgehaald." @@ -1683,16 +1682,16 @@ nl: enable_user_directory: "Een lijst van gebruikers aanbieden om door te bladeren" enable_group_directory: "Een lijst van groepen aanbieden om door te bladeren" enable_category_group_moderation: "Groepen mogen inhoud in bepaalde categorieën modereren" - group_in_subject: "%%{optional_pm} in e-mailonderwerp instellen op naam van eerste groep in PM; zie Customize subject format for standard emails" + group_in_subject: "Stel %%{optional_pm} in het e-mailonderwerp in op de naam van de eerste groep in PM; zie Onderwerpnotatie aanpassen voor standaard e-mails" allow_anonymous_posting: "Gebruikers mogen naar anonieme modus overschakelen" anonymous_posting_min_trust_level: "Het minimale vertrouwensniveau dat nodig is om anonieme berichtplaatsing in te schakelen" anonymous_account_duration_minutes: "Om anonimiteit te beschermen, elke N minuten voor iedere gebruiker een nieuwe anonieme account aanmaken. Voorbeeld: als dit is ingesteld op 600, wordt een nieuwe anonieme account aangemaakt als er 600 minuten zijn verstreken na het laatste bericht EN de gebruiker naar anon overschakelt." hide_user_profiles_from_public: "Gebruikerskaarten, gebruikersprofielen en gebruikerslijst voor anonieme gebruikers uitschakelen." allow_users_to_hide_profile: "Gebruikers mogen hun profiel en aanwezigheid verbergen" - allow_featured_topic_on_user_profiles: "Gebruikers mogen een koppeling naar een topic aanbevelen op hun gebruikerskaart en profiel." + allow_featured_topic_on_user_profiles: "Sta gebruikers toe een link naar een topic uit te lichten op hun gebruikerskaart en profiel." show_inactive_accounts: "Aangemelde gebruikers mogen profielen van inactieve accounts doorbladeren." hide_suspension_reasons: "Schorsingsredenen niet openbaar weergeven op gebruikersprofielen." - log_personal_messages_views: "PM-weergaven door Admin voor andere gebruikers/groepen in logboek opslaan." + log_personal_messages_views: "PB-weergaven door beheerder voor andere gebruikers/groepen loggen." ignored_users_count_message_threshold: "Moderators inlichten als een bepaalde gebruiker door dit aantal andere gebruikers wordt genegeerd." ignored_users_message_gap_days: "Wachttijd voordat moderators opnieuw worden ingelicht over een gebruiker die door veel andere wordt genegeerd." clean_up_inactive_users_after_days: "Aantal dagen voordat een inactieve gebruiker (vertrouwensniveau 0 zonder berichten) wordt verwijderd. Stel 0 in om opschonen uit te schakelen." @@ -1701,7 +1700,7 @@ nl: max_notifications_per_user: "Maximale aantal meldingen per gebruiker. Als dit aantal wordt overschreden, worden oude meldingen verwijderd. Wekelijks afgedwongen. Stel dit in op 0 om uit te schakelen." allowed_user_website_domains: "Website van gebruiker wordt op deze domeinen gecontroleerd. Door pipes gescheiden lijst." allow_profile_backgrounds: "Gebruikers mogen profielachtergronden uploaden." - sequential_replies_threshold: "Aantal berichten dat een gebruiker achter elkaar in één topic moet plaatsen voordat deze aan te veel opeenvolgende reacties wordt herinnerd." + sequential_replies_threshold: "Aantal berichten dat een gebruiker achter elkaar in één topic moet plaatsen voordat deze aan te veel opeenvolgende antwoorden wordt herinnerd." get_a_room_threshold: "Aantal berichten dat een gebruiker aan dezelfde persoon in hetzelfde topic moet richten voordat deze wordt gewaarschuwd." enable_mobile_theme: "Mobiele apparaten gebruiken een mobielvriendelijk thema, met de mogelijkheid om naar de volledige website over te schakelen. Schakel dit uit als u een eigen stylesheet wilt gebruiken dat volledig responsief is." dominating_topic_minimum_percent: "Het percentage berichten dat een gebruiker in een topic moet maken voordat deze aan het te veel domineren van een topic wordt herinnerd." @@ -1709,10 +1708,10 @@ nl: suppress_uncategorized_badge: "De badge voor ongecategoriseerde topics niet in topiclijsten tonen." header_dropdown_category_count: "Het aantal categorieën dat in het header-vervolgkeuzemenu kan worden weergegeven." permalink_normalizations: "De volgende reguliere expressie toepassen voordat permalinks worden verwerkt. Voorbeeld: /(topic.*)\\?.*/\\1 verwijdert querystrings uit topicroutes. Notatie is regex+strings, gebruik \\1 etc. voor deeluitdrukkingen." - global_notice: "Een algemene niet te verbergen DRINGEND, NOODGEVAL-bannermelding voor alle gebruikers weergeven. Laat leeg om deze te verbergen (HTML toegestaan)." + global_notice: "Toon een algemene niet te sluiten DRINGENDE NOOD-bannermelding voor alle gebruikers. Laat dit leeg om te verbergen (HTML toegestaan)." disable_system_edit_notifications: "Schakelt bewerkingsmeldingen van de systeemgebruiker uit als 'download_remote_images_to_local' actief is." notification_consolidation_threshold: "Aantal ontvangen meldingen van likes of lidmaatschapsaanvragen voordat de meldingen in één melding worden samengevoegd. Stel dit in op 0 om uit te schakelen." - likes_notification_consolidation_window_mins: "Tijdsduur in minuten waarin like-meldingen in één melding worden samengevoegd zodra de drempel is bereikt. De drempel kan worden geconfigureerd via `SiteSetting.notification_consolidation_threshold`." + likes_notification_consolidation_window_mins: "Tijdsduur in minuten waarin like-meldingen in één melding worden samengevoegd zodra de drempel is bereikt. De drempel kan worden geconfigureerd via 'SiteSetting.notification_consolidation_threshold'." automatically_unpin_topics: "Topics automatisch losmaken wanneer de gebruiker de onderkant bereikt." read_time_word_count: "Aantal woorden per minuut voor het berekenen van geschatte leestijd." topic_page_title_includes_category: "Topicpagina titeltag bevat de categorienaam." @@ -1738,7 +1737,7 @@ nl: embed_unlisted: "Geïmporteerde topics zijn onzichtbaar totdat een gebruiker antwoordt." embed_support_markdown: "Markdown-opmaak voor ingebedde berichten ondersteunen." allowed_embed_selectors: "Een door komma's gescheiden lijst van CSS-elementen die bij inbedding zijn toegestaan." - allowed_href_schemes: "Toegestane schema's in koppelingen, naast http en https." + allowed_href_schemes: "Toegestane schema's in links, naast http en https." embed_post_limit: "Maximale aantal in te bedden berichten." embed_username_required: "De gebruikersnaam voor het maken van topics is vereist." notify_about_flags_after: "Als er na dit aantal uren markeringen zijn die nog niet zijn afgehandeld, een privébericht naar stafleden sturen. Stel dit in op 0 om uit te schakelen." @@ -1758,7 +1757,7 @@ nl: notify_about_queued_posts_after: "Als er berichten zijn die meer dan dit aantal uren op beoordeling wachten, een melding naar alle moderators sturen. Stel dit in op 0 om deze meldingen uit te schakelen." auto_close_messages_post_count: "Maximale aantal toegestane berichten in een bericht voordat het automatisch wordt gesloten (0 voor uitschakelen)" auto_close_topics_post_count: "Maximale aantal toegestane berichten in een topic voordat het automatisch wordt gesloten (0 voor uitschakelen)" - auto_close_topics_create_linked_topic: "Een nieuw gekoppeld topic aanmaken wanneer een onderwerp automatisch wordt gesloten op basis van de instelling 'auto close topics post count'" + auto_close_topics_create_linked_topic: "Een nieuw gekoppeld topic aanmaken wanneer een topic automatisch wordt gesloten op basis van de instelling 'auto close topics post count'" code_formatting_style: "Codeknop in editor gebruikt standaard deze stijl voor codeopmaak" max_allowed_message_recipients: "Maximale aantal ontvangers in een bericht." watched_words_regular_expressions: "In de gaten gehouden woorden zijn reguliere expressies." @@ -1780,27 +1779,27 @@ nl: default_other_new_topic_duration_minutes: "Globale standaardvoorwaarde waarvoor een topic als nieuw wordt beschouwd." default_other_auto_track_topics_after_msecs: "Globale standaardtijd voordat een topic automatisch wordt gevolgd." default_other_notification_level_when_replying: "Globale standaard meldingsniveau wanneer de gebruiker op een topic antwoordt." - default_other_external_links_in_new_tab: "Externe koppelingen standaard in een nieuw tabblad openen." + default_other_external_links_in_new_tab: "Externe links standaard in een nieuw tabblad openen." default_other_enable_quoting: "Antwoord-met-citaat voor gemarkeerde tekst standaard inschakelen." default_other_enable_defer: "Topicnegeerfunctionaliteit standaard inschakelen." default_other_dynamic_favicon: "Aantal nieuwe / bijgewerkte topics standaard tonen op browserpictogram." default_other_skip_new_user_tips: "Onboarding-tips en -badges voor nieuwe gebruikers overslaan." default_other_like_notification_frequency: "Gebruikers standaard informeren bij likes" default_topics_automatic_unpin: "Topics standaard automatisch losmaken wanneer de gebruiker de onderkant bereikt." - default_categories_watching: "Lijst van categorieën die standaard in de gaten worden gehouden." + default_categories_watching: "Lijst van categorieën die standaard worden geobserveerd." default_categories_tracking: "Lijst van categorieën die standaard worden gevolgd." default_categories_muted: "Lijst van categorieën die standaard worden gedempt." - default_categories_watching_first_post: "Lijst van categorieën waarin het eerste bericht in elk nieuw topic standaard in de gaten wordt gehouden." + default_categories_watching_first_post: "Lijst van categorieën waarin het eerste bericht in elk nieuw topic standaard wordt geobserveerd." default_categories_normal: "Lijst van categorieën die niet standaard worden gedempt. Handig wanneer de website-instelling 'mute_all_categories_by_default' is ingeschakeld." - mute_all_categories_by_default: "Het standaard meldingsniveau van alle categorieën instellen op gedempt. Gebruikers moeten zich bij categorieën aanmelden om deze in de pagina's 'nieuwste' en 'categorieën' te laten verschijnen. Als u de standaardwaarden voor anonieme gebruikers wilt wijzigen, stel dan de instellingen voor 'default_categories_' in." - default_tags_watching: "Lijst van tags die standaard in de gaten worden gehouden." + mute_all_categories_by_default: "Stel het standaard meldingsniveau van alle categorieën in op gedempt. Gebruikers moeten zich aanmelden voor categorieën om deze op de pagina's 'Nieuwste' en 'Categorieën' te laten weergeven. Stel de instellingen voor 'default_categories_' in om de standaardwaarden voor anonieme gebruikers te wijzigen." + default_tags_watching: "Lijst van tags die standaard worden geobserveerd." default_tags_tracking: "Lijst van tags die standaard worden gevolgd." default_tags_muted: "Lijst van tags die standaard worden gedempt." - default_tags_watching_first_post: "Lijst van tags waarin het eerste bericht in elk nieuw topic standaard in de gaten wordt gehouden." + default_tags_watching_first_post: "Lijst van tags waarin het eerste bericht in elk nieuw topic standaard wordt geobserveerd." default_text_size: "Tekstgrootte die standaard is geselecteerd" default_title_count_mode: "Standaardmodus voor de paginatitelteller" retain_web_hook_events_period_days: "Aantal dagen voor het behouden van records van webhookgebeurtenissen." - retry_web_hook_events: "Mislukte webhookgebeurtenissen automatisch 4 keer opnieuw proberen. Tijdsgaten tussen de pogingen zijn 1, 5, 25 en 125 minuten." + retry_web_hook_events: "Mislukte webhookgebeurtenissen automatisch 4 keer opnieuw proberen. De intervallen tussen de pogingen zijn 1, 5, 25 en 125 minuten." revoke_api_keys_days: "Aantal dagen voordat een niet-gebruikte API-sleutel van een gebruiker automatisch wordt ingetrokken (0 voor nooit)" allow_user_api_keys: "Genereren van API-sleutels van gebruiker toestaan" allow_user_api_key_scopes: "Lijst van toegestane scopes voor API-sleutels van gebruiker" @@ -1827,7 +1826,7 @@ nl: shared_drafts_category: "Schakel de functie Gedeelde concepten in door een categorie voor topicconcepten aan te geven. Topics in deze categorie worden onderdrukt in topiclijsten voor stafgebruikers." shared_drafts_min_trust_level: "Gebruikers mogen gedeelde concepten zien en bewerken." push_notifications_prompt: "Prompt voor gebruikerstoestemming weergeven." - push_notifications_icon: "Het badgepictogram dat in de meldingshoek verschijnt. Een eenkleurige PNG van 96×96 met transparantie wordt aanbevolen." + push_notifications_icon: "Het badgepictogram dat in de meldingshoek wordt weergegeven. Een eenkleurige PNG van 96×96 met transparantie wordt aanbevolen." heading_font: "Te gebruiken lettertypen voor kopteksten op de website. Thema's kunnen worden overschreven via de aangepaste CSS-eigenschap '--heading-font-family'." short_title: "De korte titel wordt gebruikt op het startscherm van de gebruiker, de starter, of andere plaatsen waar ruimte beperkt kan zijn. Beperk de naam tot 12 tekens." dashboard_hidden_reports: "Toestaan dat de opgegeven rapporten worden verborgen op het dashboard." @@ -1853,24 +1852,24 @@ nl: invalid_json: "Ongeldige JSON." invalid_reply_by_email_address: "Waarde moet '%{reply_key}' bevatten en afwijken van de melding per e-mail." invalid_alternative_reply_by_email_addresses: "Alle waarden moeten '%{reply_key}' bevatten en afwijken van de melding per e-mail." - pop3_polling_host_is_empty: "U moet een 'pop3 polling host' instellen voordat u POP3-polling inschakelt." - pop3_polling_username_is_empty: "U moet een 'pop3 polling username' instellen voordat u POP3-polling inschakelt." - pop3_polling_password_is_empty: "U moet een 'pop3 polling password' instellen voordat u POP3-polling inschakelt." + pop3_polling_host_is_empty: "Je moet een 'pop3 polling host' instellen voordat je POP3-polling inschakelt." + pop3_polling_username_is_empty: "Je moet een 'pop3 polling username' instellen voordat je POP3-polling inschakelt." + pop3_polling_password_is_empty: "Je moet een 'pop3 polling password' instellen voordat je POP3-polling inschakelt." pop3_polling_authentication_failed: "POP3-authenticatie mislukt. Verifieer uw POP3-referenties." - reply_by_email_address_is_empty: "U moet een 'reply by email address' instellen voordat u antwoorden per e-mail inschakelt." - email_polling_disabled: "U moet handmatige of POP3-polling inschakelen voordat u antwoorden per e-mail inschakelt." - user_locale_not_enabled: "U moet eerst 'allow user locale' inschakelen voordat u deze instelling inschakelt." + reply_by_email_address_is_empty: "Je moet een 'reply by email address' instellen voordat je antwoorden via e-mail inschakelt." + email_polling_disabled: "Je moet handmatige of POP3-polling inschakelen voordat je antwoorden via e-mail inschakelt." + user_locale_not_enabled: "Je moet 'allow user locale' inschakelen voordat je deze instelling inschakelt." invalid_regex: "Regex is ongeldig of niet toegestaan." - email_editable_enabled: "U moet 'email editable' uitschakelen voordat u deze instelling inschakelt." - staged_users_disabled: "U moet eerst 'staged users' inschakelen voordat u deze instelling inschakelt." - reply_by_email_disabled: "U moet eerst 'reply by email' inschakelen voordat u deze instelling inschakelt." - enable_local_logins_disabled: "U moet eerst 'enable local logins' inschakelen voordat u deze instelling inschakelt." - min_username_length_exists: "U kunt de minimale gebruikersnaamlengte niet hoger instellen dan de kortste gebruikersnaam (%{username})." - min_username_length_range: "U kunt het minimum niet hoger instellen dan het maximum." - max_username_length_exists: "U kunt de maximale gebruikersnaamlengte niet lager instellen dan de langste gebruikersnaam (%{username})." - max_username_length_range: "U kunt het maximum niet lager instellen dan het minimum." + email_editable_enabled: "Je moet 'email editable' uitschakelen voordat je deze instelling inschakelt." + staged_users_disabled: "Je moet eerst 'staged users' inschakelen voordat je deze instelling inschakelt." + reply_by_email_disabled: "Je moet eerst 'reply by email' inschakelen voordat je deze instelling inschakelt." + enable_local_logins_disabled: "Je moet eerst 'enable local logins' inschakelen voordat je deze instelling inschakelt." + min_username_length_exists: "Je kunt de minimale gebruikersnaamlengte niet hoger instellen dan de kortste gebruikersnaam (%{username})." + min_username_length_range: "Je kunt het minimum niet hoger instellen dan het maximum." + max_username_length_exists: "Je kunt de maximale gebruikersnaamlengte niet lager instellen dan de langste gebruikersnaam (%{username})." + max_username_length_range: "Je kunt het maximum niet lager instellen dan het minimum." invalid_hex_value: "Kleurwaarden moeten 6-cijferige hexadecimale codes zijn." - empty_selectable_avatars: "U moet eerst minstens twee selecteerbare avatars uploaden voordat u deze instelling inschakelt." + empty_selectable_avatars: "Je moet eerst minimaal twee selecteerbare avatars uploaden voordat je deze instelling inschakelt." allowed_unicode_usernames: regex_invalid: "De reguliere expressie is ongeldig: %{error}" leading_trailing_slash: "De reguliere expressie mag niet met een schuine streep beginnen en eindigen." @@ -1893,19 +1892,19 @@ nl: discourse_connect: login_error: "Aanmeldingsfout" not_found: "Uw account kon niet worden gevonden. Neem contact op met de beheerder van de website." - account_not_approved: "Uw account wacht op goedkeuring. U ontvangt een e-mailmelding zodra deze is goedgekeurd." + account_not_approved: "Je account wacht op goedkeuring. Je ontvangt een e-mailmelding zodra het is goedgekeurd." unknown_error: "Er is een probleem met uw account. Neem contact op met de beheerder van de website." timeout_expired: "Time-out bij accountaanmelding, probeer u opnieuw aan te melden." no_email: "Er is geen e-mailadres opgegeven. Neem contact op met de beheerder van de website." - blank_id_error: "De `external_id` is vereist, maar was leeg" + blank_id_error: "De 'external_id' is vereist, maar was leeg" email_error: "Er kon geen account met het e-mailadres %{email} worden geregistreerd. Neem contact op met de beheerder van de website." original_poster: "Oorspronkelijk geplaatst door" most_recent_poster: "Meest recente schrijver" frequent_poster: "Frequente schrijver" poster_description_joiner: ", " redirected_to_top_reasons: - new_user: "Welkom bij onze gemeenschap! Dit zijn de meest populaire recente topics." - not_seen_in_a_month: "Welkom terug! We hebben u een tijdje niet gezien. Dit zijn de meest populaire topics sinds uw afwezigheid." + new_user: "Welkom bij onze community! Dit zijn de meest populaire recente topics." + not_seen_in_a_month: "Welkom terug! We hebben je een tijdje niet gezien. Dit zijn de meest populaire topics sinds je afwezigheid." merge_posts: edit_reason: one: "Een bericht is samengevoegd door %{username}" @@ -1977,7 +1976,7 @@ nl: one: "Dit topic is %{count} minuut na het laatste antwoord automatisch geopend." other: "Dit topic is %{count} minuten na het laatste antwoord automatisch geopend." autoclosed_disabled: "Dit topic is nu geopend. Nieuwe antwoorden zijn toegestaan." - autoclosed_disabled_lastpost: "Dit topic is nu geopend. Nieuwe reacties worden weer geaccepteerd." + autoclosed_disabled_lastpost: "Dit topic is nu geopend. Nieuwe antwoorden zijn toegestaan." auto_deleted_by_timer: "Automatisch verwijderd door timer." login: security_key_description: "Houd uw fysieke beveiligingssleutel gereed en klik op de onderstaande knop Authenticeren met beveiligingssleutel." @@ -1987,36 +1986,36 @@ nl: security_key_no_matching_credential_error: "Geen referenties gevonden in de opgegeven beveiligingssleutel." security_key_support_missing_error: "Uw huidige apparaat of browser ondersteunt geen gebruik van beveiligingssleutels. Gebruik een andere methode." security_key_invalid: "Er is een fout opgetreden bij het valideren van de beveiligingssleutel." - not_approved: "Uw account is nog niet goedgekeurd. U ontvangt een melding via e-mail zodra u zich kunt aanmelden." + not_approved: "Je account is nog niet goedgekeurd. Je ontvangt een melding via e-mail zodra je je kunt aanmelden." incorrect_username_email_or_password: "Onjuiste gebruikersnaam, e-mailadres of wachtwoord" incorrect_password: "Onjuist wachtwoord" - wait_approval: "Bedankt voor het registreren. We laten u weten wanneer uw account is goedgekeurd." + wait_approval: "Bedankt voor het registreren. We laten het je weten wanneer je account is goedgekeurd." active: "Uw account is geactiveerd en gereed voor gebruik." activate_email: "

    U bent er bijna! We hebben een activeringsmail naar %{email} gestuurd. Volg de instructies in het e-mailbericht om uw account te activeren.

    Als dit niet aankomt, contoleer dan uw spammap.

    " - not_activated: "U kunt zich nog niet aanmelden. We hebben u een activeringsmail gestuurd. Volg de instructies in het e-mailbericht om uw account te activeren." - not_allowed_from_ip_address: "U kunt zich niet als %{username} aanmelden vanaf dat IP-adres." - admin_not_allowed_from_ip_address: "U kunt zich niet als beheerder aanmelden vanaf dat IP-adres." - reset_not_allowed_from_ip_address: "U kunt geen wachtwoordherinitialisatie aanvragen vanaf dat IP-adres." - suspended: "U kunt zich tot %{date} niet aanmelden." + not_activated: "Je kunt je nog niet aanmelden. We hebben je een activerings-e-mail gestuurd. Volg de instructies in de e-mail om je account te activeren." + not_allowed_from_ip_address: "Je kunt zich niet als %{username} aanmelden vanaf dat IP-adres." + admin_not_allowed_from_ip_address: "Je kunt zich niet als beheerder aanmelden vanaf dat IP-adres." + reset_not_allowed_from_ip_address: "Je kunt geen wachtwoordherstel aanvragen vanaf dat IP-adres." + suspended: "Je kunt zich tot %{date} niet aanmelden." suspended_with_reason: "Account geschorst tot %{date}: %{reason}" errors: "%{errors}" not_available: "Niet beschikbaar. %{suggestion} proberen?" - something_already_taken: "Er is iets misgegaan; misschien is de gebruikersnaam of het e-mailadres al geregistreerd. Probeer de koppeling 'Wachtwoord vergeten'." + something_already_taken: "Er is iets misgegaan; mogelijk is de gebruikersnaam of het e-mailadres al geregistreerd. Probeer de link 'Wachtwoord vergeten'." omniauth_error: generic: "Sorry, er is een fout opgetreden bij het autoriseren van uw account. Probeer het opnieuw." - csrf_detected: "Time-out bij autorisatie, of u hebt van browser gewisseld. Probeer het opnieuw." + csrf_detected: "Time-out bij autorisatie, of je bent van browser gewisseld. Probeer het opnieuw." request_error: "Er is een fout opgetreden bij het begin van de autorisatie. Probeer het opnieuw." invalid_iat: "Kan autorisatietoken niet verifiëren vanwege verschillen met de serverklok. Probeer het opnieuw." omniauth_error_unknown: "Er is iets misgegaan bij het verwerken van uw aanmelding. Probeer het opnieuw." omniauth_confirm_title: "Aanmelden via %{provider}" omniauth_confirm_button: "Doorgaan" - authenticator_error_no_valid_email: "E-mailadressen die met %{account} zijn gekoppeld, zijn niet toegestaan. Mogelijk dient u uw account met een ander e-mailadres te configureren." + authenticator_error_no_valid_email: "E-mailadressen gekoppeld aan %{account} zijn niet toegestaan. Mogelijk moet je je account configureren met een ander e-mailadres." new_registrations_disabled: "Nieuwe accountregistraties zijn op dit moment niet toegestaan." password_too_long: "Wachtwoorden mogen maximaal 200 tekens lang zijn." email_too_long: "Het opgegeven e-mailadres is te lang. Postvaknamen mogen niet langer zijn dan 254 tekens, en domeinnamen niet langer dan 253 tekens." - wrong_invite_code: "De door u ingevoerde code is ongeldig." + wrong_invite_code: "De ingevoerde code is ongeldig." reserved_username: "Die gebruikersnaam is niet toegestaan." - missing_user_field: "U hebt niet alle gebruikersvelden ingevuld" + missing_user_field: "Je hebt niet alle gebruikersvelden ingevuld" auth_complete: "Authenticatie is voltooid." click_to_continue: "Klik hier om door te gaan." second_factor_title: "Tweefactorauthenticatie" @@ -2079,19 +2078,19 @@ nl: already_suspended: "Gebruiker is %{time_ago} al geschorst door %{staff}." unsubscribe_mailer: title: "Mailer Uitschrijven" - subject_template: "Bevestig dat u geen e-mailupdates van %{site_title} meer wilt ontvangen" + subject_template: "Bevestig dat je geen e-mailupdates van %{site_title} meer wilt ontvangen" text_body_template: | - Iemand (mogelijk u?) heeft gevraagd geen e-mailupdates van %{site_domain_name} meer naar dit adres te sturen. - Als u dit wilt bevestigen, klikt u op deze koppeling: + Iemand (mogelijk jij?) heeft gevraagd geen e-mailupdates van %{site_domain_name} meer naar dit adres te sturen. + Klik op deze koppeling om dat te bevestigen: %{confirm_unsubscribe_link} - Als u e-mailupdates wilt blijven ontvangen, kunt u dit e-mailbericht negeren. + Als je e-mailupdates wilt blijven ontvangen, kun je dit e-mailbericht negeren. invite_mailer: title: "Mailer Uitnodiging" - subject_template: "%{inviter_name} heeft u uitgenodigd in '%{topic_title}' op %{site_domain_name}" + subject_template: "%{inviter_name} heeft je uitgenodigd voor '%{topic_title}' op %{site_domain_name}" text_body_template: | - %{inviter_name} heeft u uitgenodigd voor een discussie + %{inviter_name} heeft je uitgenodigd voor een discussie > **%{topic_title}** > @@ -2101,12 +2100,12 @@ nl: > %{site_title} -- %{site_description} - Als u geïnteresseerd bent, klik dan op de onderstaande koppeling: + Als je geïnteresseerd bent, klik dan op de onderstaande link: %{invite_link} custom_invite_mailer: title: "Mailer Aangepaste uitnodiging" - subject_template: "%{inviter_name} heeft u uitgenodigd in '%{topic_title}' op %{site_domain_name}" + subject_template: "%{inviter_name} heeft je uitgenodigd voor '%{topic_title}' op %{site_domain_name}" invite_forum_mailer: title: "Mailer Uitnodiging voor forum" custom_invite_forum_mailer: @@ -2117,7 +2116,7 @@ nl: download_backup_mailer: title: "Mailer Back-up downloaden" no_token: | - Sorry, deze back-up-downloadkoppeling is al gebruikt of is verlopen. + Sorry, deze back-updownloadlink is al gebruikt of is verlopen. admin_confirmation_mailer: title: "Mailer Beheerdersbevestiging" subject_template: "[%{email_prefix}] Bevestig nieuwe beheerdersaccount" @@ -2130,10 +2129,10 @@ nl: new_version_mailer: title: "Mailer Nieuwe versie" flag_reasons: - off_topic: "Uw bericht is gemarkeerd als **off-topic**: de gemeenschap vindt dat het niet goed bij het topic past, zoals momenteel bepaald door de titel en het eerste bericht." - inappropriate: "Uw bericht is gemarkeerd als **ongepast**: de gemeenschap vindt het bericht beledigend, grof, of een schending van [onze gemeenschapsrichtlijnen](%{base_path}/guidelines)." - spam: "Uw bericht is gemarkeerd als **spam**: de gemeenschap vindt dat het bericht een advertentie is, dat wil zeggen overdreven promotioneel van aard in plaats van nuttig of relevant voor het topic, zoals verwacht." - notify_moderators: "Uw bericht is gemarkeerd voor **aandacht van een moderator**: de gemeenschap vindt dat iets in het bericht handmatige interventie door een staflid vereist." + off_topic: "Uw bericht is gemarkeerd als **off-topic**: de community vindt dat het niet goed bij het topic past, zoals momenteel bepaald door de titel en het eerste bericht." + inappropriate: "Uw bericht is gemarkeerd als **ongepast**: de community vindt het bericht beledigend, grof, of een schending van [onze communityrichtlijnen](%{base_path}/guidelines)." + spam: "Uw bericht is gemarkeerd als **spam**: de community vindt dat het bericht een advertentie is, dat wil zeggen overdreven promotioneel van aard in plaats van nuttig of relevant voor het topic, zoals verwacht." + notify_moderators: "Uw bericht is gemarkeerd voor **aandacht van een moderator**: de community vindt dat iets in het bericht handmatige interventie door een staflid vereist." flags_dispositions: agreed: "Bedankt voor het informeren. We zijn het ermee eens dat er een probleem is en gaan ernaar kijken." agreed_and_deleted: "Bedankt voor het informeren. We zijn het ermee eens dat er een probleem is en hebben het bericht verwijderd." @@ -2144,7 +2143,7 @@ nl: private_topic_title: "Topic #%{id}" post_hidden: title: "Bericht verborgen" - subject_template: "Bericht verborgen door gemeenschapsmarkeringen" + subject_template: "Bericht verborgen door communitymarkeringen" post_hidden_again: title: "Bericht opnieuw verborgen" queued_by_staff: @@ -2163,7 +2162,7 @@ nl: %{new_user_tips} - We geloven altijd in [beschaafd gemeenschappelijk gedrag](%{base_url}/guidelines). + We geloven altijd in [beschaafd communitygedrag](%{base_url}/guidelines). Geniet van uw verblijf! welcome_tl1_user: @@ -2179,13 +2178,13 @@ nl: subject_template: "Gefeliciteerd met de promotie van uw vertrouwensniveau!" backup_succeeded: title: "Back-up geslaagd" - subject_template: "Back-up succesvol voltooid" + subject_template: "Back-up voltooid" backup_failed: title: "Back-up mislukt" subject_template: "Back-up mislukt" restore_succeeded: title: "Back-up teruggezet" - subject_template: "Back-up succesvol voltooid" + subject_template: "Back-up voltooid" restore_failed: title: "Terugzetten mislukt" subject_template: "Terugzetten mislukt" @@ -2217,11 +2216,11 @@ nl: Uw met dit e-mailadres gekoppelde account is gedempt. email_reject_empty: text_body_template: | - Het spijt ons, maar het plaatsen van uw e-mailbericht naar %{destination} (getiteld %{former_title}) is niet gelukt. + Sorry, je e-mailbericht aan %{destination} (getiteld %{former_title}) is niet gelukt. - We konden geen antwoordtekst in uw e-mail vinden. + We konden geen antwoordtekst vinden in je e-mail. - Als u deze melding ziet en _wel_ een antwoord had bijgevoegd, kunt u het opnieuw proberen met een eenvoudigere opmaak. + Als je deze melding ziet en _wel_ een antwoord had bijgevoegd, kun je het opnieuw proberen met een eenvoudigere opmaak. email_reject_parsing: text_body_template: | Het spijt ons, maar het plaatsen van uw e-mailbericht naar %{destination} (met de titel %{former_title}) is niet gelukt. @@ -2235,13 +2234,13 @@ nl: Enkele mogelijke oorzaken zijn: complexe opmaak, bericht te groot, bericht te klein. Probeer het opnieuw, of plaats uw bericht via de website als dit aanhoudt. email_reject_invalid_post_specified: text_body_template: | - Het spijt ons, maar het plaatsen van uw e-mailbericht naar %{destination} (getiteld %{former_title}) is niet gelukt. + Sorry, je e-mailbericht aan %{destination} (getiteld %{former_title}) is niet gelukt. Reden: %{post_error} - Als u het probleem kunt oplossen, probeer het dan opnieuw. + Als je het probleem kunt oplossen, probeer het dan opnieuw. email_reject_post_too_short: title: "E-mail Weigering - Bericht te kort" email_reject_invalid_post_action: @@ -2264,7 +2263,7 @@ nl: subject_template: "Account tijdelijk geblokkeerd" user_automatically_silenced: title: "Gebruiker automatisch gedempt" - subject_template: "Nieuwe gebruiker %{username} gedempt wegens markeringen door gemeenschap" + subject_template: "Nieuwe gebruiker %{username} gedempt wegens markeringen door community" text_body_template: | Dit is een automatisch bericht. @@ -2272,18 +2271,18 @@ nl: [Beoordeel de markeringen](%{base_url}/admin/flags). Als %{username} ten onrechte is gedempt, klik dan op de knop voor opheffen van het dempen op [de beheerpagina van deze gebruiker](%{user_url}). - Deze drempel kan worden gewijzigd via de `silence_new_user`-website-instellingen. + Deze drempel kan worden gewijzigd via de 'silence_new_user'-website-instellingen. spam_post_blocked: title: "Spambericht geblokkeerd" - subject_template: "Berichten van nieuwe gebruiker %{username} geblokkeerd vanwege herhaalde koppelingen" + subject_template: "Berichten van nieuwe gebruiker %{username} geblokkeerd vanwege herhaalde links" unsilenced: subject_template: "Account niet meer geblokkeerd" text_body_template: | Hallo, - Dit is een automatisch bericht van %{site_name} om u te laten weten dat uw account na beoordeling door een staflid niet meer is geblokkeerd. + Dit is een automatisch bericht van %{site_name} om u je laten weten dat je account na beoordeling door een staflid niet langer is geblokkeerd. - U kunt nu weer nieuwe antwoorden en topics aanmaken. Bedankt voor uw geduld. + Je kunt nu weer nieuwe antwoorden plaatsen en topics maken. Bedankt voor je geduld. pending_users_reminder: subject_template: one: "%{count} gebruiker wacht op goedkeuring" @@ -2299,8 +2298,8 @@ nl: subject_pm: "[PM]" email_from: "%{user_name} via %{site_name}" user_notifications: - previous_discussion: "Vorige reacties" - in_reply_to: "in reactie op" + previous_discussion: "Eerdere antwoorden" + in_reply_to: "In antwoord op" unsubscribe: title: "Uitschrijven" description: "Niet geïnteresseerd in deze e-mails? Geen probleem! Klik hieronder om direct uitgeschreven te worden:" @@ -2469,7 +2468,7 @@ nl: popular_posts: "Populaire berichten" more_new: "Nieuw voor u" subject_template: "[%{email_prefix}] Samenvatting" - unsubscribe: "Deze samenvatting wordt vanaf %{site_link} als we u een tijd niet hebben gezien. Wijzig %{email_preferences_link}, of %{unsubscribe_link} om u uit te schrijven." + unsubscribe: "Deze samenvatting wordt verzonden van %{site_link} als we je een tijdje niet hebben gezien. Wijzig %{email_preferences_link}, of %{unsubscribe_link} om je af te melden." your_email_settings: "uw e-mailinstellingen" click_here: "klik hier" from: "%{site_name}" @@ -2478,21 +2477,21 @@ nl: title: "Wachtwoord vergeten" subject_template: "[%{email_prefix}] Wachtwoord opnieuw instellen" text_body_template: | - Iemand heeft gevraagd uw wachtwoord op [%{site_name}](%{base_url}) opnieuw in te stellen. + Iemand heeft gevraagd je wachtwoord op [%{site_name}](%{base_url}) te herstellen. - Als u dit niet was, kunt u deze e-mail gewoon negeren. + Als jij dat niet was, kun je deze e-mail negeren. - Klik op de volgende koppeling om een nieuw wachtwoord te kiezen: + Klik op de volgende link om een nieuw wachtwoord te kiezen: %{base_url}/u/password-reset/%{email_token} email_login: - title: "Aanmelden via koppeling" - subject_template: "[%{email_prefix}] Aanmelden via koppeling" + title: "Aanmelden via link" + subject_template: "[%{email_prefix}] Aanmelden via link" text_body_template: | - Dit is uw koppeling om u bij [%{site_name}](%{base_url}) aan te melden. + Dit is je link om je aan te melden bij [%{site_name}](%{base_url}). - Als u niet om deze koppeling hebt gevraagd, kunt u deze e-mail gewoon negeren. + Als je niet om deze link hebt gevraagd, kun je deze e-mail negeren. - Klik op de volgende koppeling om u aan te melden: + Klik op de volgende link om je aan te melden: %{base_url}/session/email-login/%{email_token} set_password: title: "Wachtwoord instellen" @@ -2501,11 +2500,11 @@ nl: title: "Beheerdersaanmelding" subject_template: "[%{email_prefix}] Aanmelding" text_body_template: | - Iemand heeft om aanmelding gevraagd bij uw account op [%{site_name}](%{base_url}). + Iemand heeft gevraagd om aanmelding op je account op [%{site_name}](%{base_url}). - Als u deze aanvraag niet hebt gedaan, kunt u deze e-mail gewoon negeren. + Als je hier niet om hebt gevraagd, kun je deze e-mail negeren. - Klik op de volgende koppeling om u aan te melden: + Klik op de volgende link om je aan te melden: %{base_url}/session/email-login/%{email_token} account_created: title: "Account gemaakt" @@ -2519,11 +2518,9 @@ nl: confirm_old_email: subject_template: "[%{email_prefix}] Bevestig uw huidige e-mailadres" text_body_template: | - Voordat we uw e-mailadres kunnen wijzigen, dient u te bevestigen dat u de huidige - e-mailaccount beheert. Nadat u deze stap hebt voltooid, laten we u het nieuwe - e-mailadres bevestigen. + Voordat we je e-mailadres kunnen wijzigen, moet je bevestigen dat je de controle hebt over het huidige e-mailaccount. Nadat je deze stap hebt voltooid, moet je het nieuwe e-mailadres bevestigen. - Bevestig uw huidige e-mailadres voor %{site_name} door op de volgende koppeling te klikken: + Bevestig je huidige e-mailadres voor %{site_name} door op de volgende link te klikken: %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email_add: @@ -2558,16 +2555,16 @@ nl: image: "afbeelding" upload: edit_reason: "lokale kopieën van afbeeldingen gedownload" - unauthorized: "Sorry, het bestand dat u probeert te uploaden is niet toegestaan (toegestane extensies: %{authorized_extensions})." + unauthorized: "Sorry, het bestand dat je probeert te uploaden is niet toegestaan (toegestane extensies: %{authorized_extensions})." pasted_image_filename: "Geplakte afbeelding" store_failure: "Het opslaan van upload #%{upload_id} voor gebruiker #%{user_id} is mislukt." - file_missing: "Sorry, u moet een bestand opgeven om te uploaden." - empty: "Sorry, maar het bestand dat u hebt opgegeven is leeg." + file_missing: "Sorry, je moet een bestand opgeven om te uploaden." + empty: "Sorry, het verstrekte bestand is leeg." png_to_jpg_conversion_failure_message: "Er is een fout opgetreden bij het converteren van PNG naar JPG." attachments: - too_large: "Sorry, het bestand dat u probeert te uploaden is te groot (maximumgrootte is %{max_size_kb}%KB)." + too_large: "Sorry, het bestand dat je probeert te uploaden is te groot (maximale grootte is %{max_size_kb} KB)." images: - too_large: "Sorry, de afbeelding die u probeert te uploaden is te groot (maximumgrootte is %{max_size_kb}%KB). Verklein de afbeelding en probeer het opnieuw." + too_large: "Sorry, de afbeelding die je probeert te uploaden is te groot (maximale grootte is %{max_size_kb} KB). Verklein de afbeelding en probeer het opnieuw." size_not_found: "Het is niet gelukt de afmetingen van de afbeelding te bepalen. Misschien is het bestand corrupt?" avatar: missing: "Sorry, we kunnen geen avatar vinden die met dat e-mailadres is gekoppeld. Kunt u proberen deze opnieuw te uploaden?" @@ -2643,7 +2640,7 @@ nl: Deze badge wordt toegekend als u voor het eerst een wiki-bericht bewerkt. basic_user: name: Basis - description: Alle essentiële gemeenschapsfuncties toegekend + description: Alle essentiële communityfuncties toegekend member: name: Lid description: Uitnodigingen, groepsberichten, meer likes toegekend @@ -2676,7 +2673,7 @@ nl: name: Goed topic description: Heeft 25 likes op een topic ontvangen long_description: | - Deze badge wordt toegekend wanneer je topic 25 likes krijgt. Je startte een levendig gesprek waar de gemeenschap zich omheen verzamelde. + Deze badge wordt toegekend wanneer je topic 25 likes krijgt. Je startte een levendig gesprek waar de community zich omheen verzamelde. great_topic: name: Geweldig topic description: Heeft 50 likes op een topic ontvangen @@ -2702,21 +2699,21 @@ nl: name: Campaigner description: 3 basisgebruikers uitgenodigd long_description: | - Deze badge wordt toegekend wanneer u 3 mensen hebt uitgenodigd die aansluitend genoeg tijd op deze website hebben doorgebracht om basisgebruiker te worden. Een levendige gemeenschap heeft een regelmatige inbreng van nieuwkomers nodig die regelmatig deelnemen en nieuwe geluiden aan de conversaties toevoegen. + Deze badge wordt toegekend wanneer u 3 mensen hebt uitgenodigd die aansluitend genoeg tijd op deze website hebben doorgebracht om basisgebruiker te worden. Een levendige community heeft een regelmatige inbreng van nieuwkomers nodig die regelmatig deelnemen en nieuwe geluiden aan de conversaties toevoegen. champion: name: Kampioen first_share: name: Eerste deelactie description: Heeft een bericht gedeeld first_link: - name: Eerste koppeling - description: Heeft een koppeling naar een ander topic toegevoegd + name: Eerste link + description: Heeft een link naar een ander topic toegevoegd first_quote: name: Eerste citaat description: Heeft een bericht geciteerd read_guidelines: name: Richtlijnen gelezen - description: Heeft de gemeenschapsrichtlijnen gelezen + description: Heeft de communityrichtlijnen gelezen long_description: | Deze badge wordt toegekend als u de richtlijnen hebt gelezen. Het opvolgen en delen van deze eenvoudige richtlijnen draagt bij aan een veilige, leuke en duurzame community voor iedereen. Bedenk altijd dat er een mens, net zoals u, aan de andere kant van het scherm zit. Wees aardig! reader: @@ -2725,14 +2722,14 @@ nl: long_description: | Deze badge wordt toegekend wanneer u voor het eerst een topic met meer dan 100 antwoorden hebt gelezen. Als u een gesprek zorgvuldig volgt, kan u de discussie volgen en de verschillende standpunten begrijpen; dit leidt tot interessantere gesprekken. Hoe meer u leest, hoe beter het gesprek wordt. Zoals we zeggen, lezen is fundamenteel! :slight_smile: popular_link: - name: Populaire koppeling - description: Heeft een externe koppeling met 50 klikken geplaatst + name: Populaire link + description: Heeft een externe klink met 50 klikken geplaatst hot_link: name: Zeer populaire koppeling - description: Heeft een externe koppeling met 300 klikken geplaatst + description: Heeft een externe klink met 300 klikken geplaatst famous_link: - name: Uiterst populaire koppeling - description: Heeft een externe koppeling met 1000 klikken geplaatst + name: Beroemde link + description: Heeft een externe klink met 1000 klikken geplaatst appreciated: name: Gewaardeerd description: Heeft 1 like op 20 berichten ontvangen @@ -2745,7 +2742,7 @@ nl: out_of_love: description: '%{max_likes_per_day} likes op een dag gebruikt' long_description: | - Deze badge wordt toegekend wanneer u alle %{max_likes_per_day} van uw dagelijkse likes gebruikt. Door eraan te denken even de tijd te nemen en de berichten waar u plezier aan beleeft en die u waardeert te liken, worden uw medegemeenschapsleden aangemoedigd in de toekomst zelfs nog geweldigere discussies aan te maken. + Deze badge wordt toegekend wanneer u alle %{max_likes_per_day} van uw dagelijkse likes gebruikt. Door eraan te denken even de tijd te nemen en de berichten waar u plezier aan beleeft en die u waardeert te liken, worden uw medecommunityleden aangemoedigd in de toekomst zelfs nog geweldigere discussies aan te maken. higher_love: description: 5 keer %{max_likes_per_day} likes op een dag gebruikt long_description: | @@ -2753,7 +2750,7 @@ nl: crazy_in_love: description: 20 keer %{max_likes_per_day} likes op een dag gebruikt long_description: | - Deze badge wordt toegekend wanneer u 20 dagen lang alle %{max_likes_per_day} van uw dagelijkse likes gebruikt. Wow! U bent een rolmodel voor het aanmoedigen van uw medegemeenschapsleden! + Deze badge wordt toegekend wanneer u 20 dagen lang alle %{max_likes_per_day} van uw dagelijkse likes gebruikt. Wow! U bent een rolmodel voor het aanmoedigen van uw medecommunityleden! thank_you: name: Bedankt description: Heeft 20 geliket en 10 likes gegeven @@ -2790,7 +2787,7 @@ nl: Deze badge wordt toegekend voor het bezoeken op 365 opeenvolgende dagen. Wauw, een heel jaar! badge_title_metadata: "%{display_name}-badge op %{site_title}" admin_login: - success: "E-mail verstuurd" + success: "E-mail verzonden" errors: unknown_email_address: "Onbekend e-mailadres." invalid_token: "Ongeldig token." @@ -2801,7 +2798,7 @@ nl: initial_topic_title: Performancerapportages van de website tags: title: "Tags" - restricted_tag_disallowed: 'U kunt de tag "%{tag}” niet toepassen.' + restricted_tag_disallowed: 'Je kunt de tag "%{tag}” niet toepassen.' minimum_required_tags: one: "U moet minstens %{count} tag selecteren." other: "U moet minstens %{count} tags selecteren." @@ -2823,7 +2820,7 @@ nl: title: "Bevestig uw e-mailadres" resend_email: title: "Activeringsmail opnieuw versturen" - message: "

    We hebben de activeringsmail opnieuw naar %{email} verstuurd" + message: "

    We hebben de activerings-e-mail opnieuw naar %{email} gestuurd" safe_mode: title: "Veilige modus starten" no_unofficial_plugins: "Niet-officiële plug-ins uitschakelen" @@ -2842,7 +2839,7 @@ nl: fields: login_required: placeholder: "Privé" - extra_description: "Alleen aangemelde gebruikers hebben toegang tot deze gemeenschap" + extra_description: "Alleen aangemelde gebruikers hebben toegang tot deze community" must_approve_users: placeholder: "Heeft goedkeuring nodig" ready: @@ -2895,7 +2892,7 @@ nl: label: "Geautomatiseerde berichten" invites: title: "Staf uitnodigen" - description: "U bent bijna klaar! Nodig wat mensen uit om te helpen met het seeden van uw discussies met interessante topics en berichten om uw gemeenschap op te zetten." + description: "U bent bijna klaar! Nodig wat mensen uit om te helpen met het seeden van uw discussies met interessante topics en berichten om uw community op te zetten." disabled: "Omdat sociale aanmeldingen zijn uitgeschakeld, is het niet mogelijk om uitnodigingen naar iedereen te versturen. Ga verder met de volgende stap." finished: title: "Uw Discourse is gereed!" @@ -2910,7 +2907,7 @@ nl: replied: '%{username} heeft op u geantwoord in ''%{topic}'' - %{site_title}' posted: '%{username} heeft een bericht geplaatst in ''%{topic}'' - %{site_title}' private_message: '%{username} heeft u een privébericht gestuurd in ''%{topic}'' - %{site_title}' - linked: '%{username} heeft een koppeling naar uw bericht geplaatst vanaf ''%{topic}'' - %{site_title}' + linked: '%{username} heeft een link naar je bericht geplaatst vanaf ''%{topic}'' - %{site_title}' watching_first_post: '%{username} heeft een nieuw topic gemaakt: ''%{topic}'' - %{site_title}' confirm_title: "Meldingen ingeschakeld - %{site_title}" confirm_body: "Gelukt! Meldingen zijn ingeschakeld." @@ -2936,8 +2933,8 @@ nl: low: "Laag" medium: "Gemiddeld" high: "Hoog" - must_claim: "U moet items opeisen voordat u er handelingen op kunt uitvoeren." - user_claimed: "Dit item is door een andere gebruiker opgeëist." + must_claim: "U moet items claimen voordat u er acties op kunt uitvoeren." + user_claimed: "Dit item is door een andere gebruiker geclaimd." missing_version: "U moet een versieparameter opgeven" conflict: "Een updateconflict heeft ervoor gezorgd dat u dit niet kon doen." reasons: @@ -2973,14 +2970,14 @@ nl: delete_and_ignore_replies: title: "Bericht + antwoorden verwijderen en negeren" description: "Bericht en alle antwoorden erop verwijderen; als dit het eerste bericht is, ook het topic verwijderen" - confirm: "Weet u zeker dat u de antwoorden op het bericht ook wilt verwijderen?" + confirm: "Weet je zeker dat je de antwoorden op het bericht ook wilt verwijderen?" delete_and_agree: title: "Bericht verwijderen en akkoord" description: "Bericht verwijderen; als dit het eerste bericht is, ook het topic verwijderen" delete_and_agree_replies: title: "Bericht + antwoorden verwijderen en akkoord" description: "Bericht en alle antwoorden erop verwijderen; als dit het eerste bericht is, ook het topic verwijderen" - confirm: "Weet u zeker dat u de antwoorden op het bericht ook wilt verwijderen?" + confirm: "Weet je zeker dat je de antwoorden op het bericht ook wilt verwijderen?" disagree_and_restore: title: "Niet akkoord en bericht terugzetten" description: "Het bericht terugzetten, zodat alle gebruikers het kunnen zien." diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index 942d0c98f6..60019ff0fe 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -260,6 +260,7 @@ pl_PL: invalid_access: "Nie masz uprawnień do przeglądania żądanego zasobu." requires_groups: "Zaproszenie nie zostało zapisane, ponieważ podany temat jest niedostępny. Dodaj jedną z następujących grup: %{groups}." domain_not_allowed: "Twój adres e-mail nie może zostać użyty do zrealizowania tego zaproszenia." + existing_user_success: "Zaproszenie zostało pomyślnie zrealizowane" bulk_invite: file_should_be_csv: "Wgrywany plik powinien być formatu csv." max_rows: "Wysłano %{max_bulk_invites} pierwszych zaproszeń. Sprobuj podzielić plik na mniejsze części." @@ -538,7 +539,7 @@ pl_PL: Możesz edytować swoje wcześniejsze odpowiedzi, aby dodać cytat, poprzez podświetlenie tekstu i wybranie przycisku cytuj odpowiedź który się pojawi. Każdemu łatwiej będzie przeczytać temat, który ma kilka pogłębionych odpowiedzi, zamiast wielu niewielkich, indywidualnych. - dominating_topic: Zamieściłeś tutaj ponad %{percent}% odpowiedzi, czy jest ktoś jeszcze, z kim chciałbyś się skontaktować? + dominating_topic: Zamieściłeś tutaj ponad %{percent}% odpowiedzi; czy możemy zasugerować, żebyś dał innym ludziom możliwość wypowiedzenia się? get_a_room: Odpowiedziałeś na @%{reply_username} %{count} razy. Czy wiesz, że możesz zamiast tego wysłać wiadomość osobistą? too_many_replies: | ### Osiągnąłeś/łaś limit odpowiedzi w tym temacie @@ -643,6 +644,7 @@ pl_PL: <<: *errors uncategorized_category_name: "Bez kategorii" general_category_name: "Ogólne" + general_category_description: "Utwórz tutaj tematy, które nie pasują do żadnej innej istniejącej kategorii." meta_category_name: "Dyskusje o serwisie" meta_category_description: "Dyskusje o tej stronie, jej organizacji, tym jak działa i jak możemy ją usprawnić." staff_category_name: "Zespół" @@ -858,7 +860,6 @@ pl_PL: many: "prawie %{count} lat temu" other: "prawie %{count} lat temu" password_reset: - no_token: "Przepraszamy, ten link do zmiany hasła jest zbyt stary. Wybierz przycisk \"Zaloguj\", a następnie \"Zapomniałem hasła\", by uzyskać nowy link." choose_new: "Wprowadź nowe hasło" choose: "Wprowadź hasło" update: "Aktualizuj hasło" @@ -1637,6 +1638,7 @@ pl_PL: post_menu: "Określ które elementy menu wpisu powinny być widoczne i w jakiej kolejności. Przykład like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "Elementy w menu, które w menu wpisu będą ukryte, chyba że kliknięty zostanie przycisk rozwijania." share_links: "Określ które elementy menu udostępniania powinny być widoczne i w jakiej kolejności. " + allow_username_in_share_links: "Zezwalaj na umieszczanie nazw użytkowników w linkach do udostępniania. Jest to przydatne, aby nagradzać odznaki na podstawie unikalnych odwiedzających." site_contact_username: "Nazwa użytkownika, spod której wysyłane będą automatyczne wiadomości. Jeśli pusta, użyte będzie domyślne konto System." site_contact_group_name: "Prawidłowa nazwa grupy, do której zostaną zaproszone wszystkie automatyczne wiadomości." send_welcome_message: "Wyślij wszystkim nowym użytkownikom powitalną wiadomość z krótkim przewodnikiem." @@ -1659,6 +1661,7 @@ pl_PL: enable_badges: "Włącz system odznak" max_favorite_badges: "Maksymalna liczba odznak, które użytkownik może wybrać" enable_whispers: "Pozwalaj administracji na prywatną rozmowę w tematach." + whispers_allowed_groups: "Zezwalaj na prywatną komunikację w ramach tematów członkom określonych grup." allow_index_in_robots_txt: "W pliku robots.txt określ, że ta witryna może być indeksowana przez wyszukiwarki internetowe. W wyjątkowych przypadkach możesz trwale zastąpić plik robots.txt." blocked_email_domains: "Lista domen poczty e-mail rozdzielonych pionową kreską, z których użytkownicy nie mogą rejestrować kont. Przykład: mailinator.com|trashmail.net" allowed_email_domains: "Lista rozdzielonych pionową kreską domen e-mail, z których użytkownicy MUSZĄ się rejestrować. UWAGA: Użytkownicy z domenami e-mail innymi niż wypisane tutaj nie będą mogli się zarejestrować!" @@ -1709,6 +1712,7 @@ pl_PL: google_oauth2_client_secret: "Client Secret twojej aplikacji w Google" google_oauth2_prompt: "Opcjonalna lista wartości łańcuchowych rozdzielanych spacjami, która określa, czy serwer autoryzacji monituje użytkownika o ponowne uwierzytelnienie i zgodę. Możliwe wartości można znaleźć na https://developers.google.com/identity/protocols/OpenIDConnect#prompt ." google_oauth2_hd: "Opcjonalna domena Google Apps Hosted, do której logowanie będzie ograniczone. Więcej informacji można znaleźć na https://developers.google.com/identity/protocols/OpenIDConnect#hd-param" + google_oauth2_hd_groups: "(eksperymentalne) Pobierz grupy dyskusyjne Google użytkowników w domenie hostowanej po uwierzytelnieniu. Pobrane grupy dyskusyjne Google mogą być używane do przyznawania automatycznego członkostwa w grupach Discourse (zobacz ustawienia grupy). Więcej informacji znajdziesz na https://meta.discourse.org/t/226850" enable_twitter_logins: "Włącz uwierzytelnianie przez Twittera, wymaga twitter_consumer_key i twitter_consumer_secret. Zobacz Konfigurowanie logowania przez Twittera (i bogatych embedów) dla Discourse." twitter_consumer_key: "Klucz klienta do uwierzytelnienia na Twitterze, zarejestrowany na https://developer.twitter.com/apps" twitter_consumer_secret: "Sekret klienta dotyczący uwierzytelniania na Twitterze, zarejestrowany na stronie https://developer.twitter.com/apps" @@ -3794,7 +3798,6 @@ pl_PL: png_to_jpg_conversion_failure_message: "Wystąpił błąd podczas konwersji z PNG na JPG." optimize_failure_message: "Wystąpił błąd podczas optymalizacji przesłanego obrazu." download_failure: "Pobieranie pliku od zewnętrznego dostawcy nie powiodło się." - size_mismatch_failure: "Rozmiar pliku przesłanego do S3 nie był zgodny z rozmiarem przewidzianym przez zewnętrzny serwer przesyłania. %{additional_detail}" create_multipart_failure: "Nie udało się utworzyć wieloczęściowego przesyłania w zewnętrznym sklepie." checksum_mismatch_failure: "Suma kontrolna przesłanego pliku nie zgadza się. Zawartość pliku mogła ulec zmianie podczas przesyłania. Proszę spróbuj ponownie." cannot_promote_failure: "Przesyłanie nie może zostać zakończone, być może zostało już zakończone lub wcześniej zakończone niepowodzeniem." diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index 5ec1cbd01f..f9072b7dc3 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -591,7 +591,6 @@ pt: one: "há quase %{count} ano atrás" other: "há quase %{count} anos atrás" password_reset: - no_token: "Desculpe, essa hiperligação para alterar a palavra-passe é muito antiga. Selecione o botão Iniciar Sessão e utilize 'Esqueci a minha palavra-passe' para obter uma nova hiperligação." choose_new: "Escolha uma nova palavra-passe" choose: "Escolha uma palavra-passe" update: "Atualizar Palavra-passe" diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index c9c99fddb2..8d4cb70964 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -71,7 +71,7 @@ pt_BR: file_too_big: "O arquivo não compactado é muito grande." unknown_file_type: "O arquivo que você enviou não parece ser um tema válido do Discourse." not_allowed_theme: "\"%{repo}\" não está na lista de temas permitidos (confira a configuração global \"allowed_theme_repos\")." - ssh_key_gone: "Você esperou muito tempo para instalar o tema e a chave SSH expirou. Por favor, tente novamente." + ssh_key_gone: "Você esperou muito tempo para instalar o tema e a chave SSH expirou. Tente novamente." errors: component_no_user_selectable: "Componentes do tema não podem ser selecionados pelo(a) usuário(a)" component_no_default: "Componentes do tema não podem ser o tema padrão" @@ -495,7 +495,6 @@ pt_BR: Você pode editar sua resposta anterior para adicionar uma citação selecionando um texto e clicando no botão responder citação que aparecerá. Fica mais fácil para todos(as) lerem tópicos com poucas respostas e muitas informações em vez de muitas respostas individuais e curtas. - dominating_topic: Você postou mais do que %{percent} das respostas aqui, deseja saber a opinião de outra pessoa? get_a_room: Você respondeu a %{reply_username} %{count} vezes, sabia que poderia ter enviado uma mensagem pessoal em vez de fazer isso? too_many_replies: | ### Você atingiu o limite de respostas para este tópico @@ -678,7 +677,7 @@ pt_BR: public_group_membership: "Você está entrando/saindo de grupos com frequência. Aguarde %{time_left} antes de tentar novamente." topics_per_day: "Você alcançou a quantidade máxima de novos tópicos permitidos por dia. Crie mais tópicos novos em %{time_left}." pms_per_day: "Você alcançou a quantidade máxima de mensagens novas permitidas por dia. Crie mais mensagens novas em %{time_left}." - create_like: "Uau! Você tem compartilhado muito amor! Você atingiu o número máximo de curtidas em um período de 24 horas, mas à medida que ganha níveis de confiança, você ganha mais curtidas diárias. Você poderá curtir as postagens novamente em %{time_left}." + create_like: "Uau! Você tem compartilhado muito amor! Você atingiu o número máximo de curtidas em um período de 24 horas. Mas, à medida que ganha níveis de confiança, você ganha mais curtidas diárias. Você poderá curtir as postagens novamente em %{time_left}." create_bookmark: "Você alcançou a quantidade máxima de favoritos por dia. Crie mais favoritos em %{time_left}." edit_post: "Você alcançou a quantidade máxima de edições por dia. Envie mais edições em %{time_left}." live_post_counts: "Você está pedindo contagens de postagens em tempo real muito rápido. Espere %{time_left} antes de tentar novamente." @@ -764,7 +763,6 @@ pt_BR: one: "há quase %{count} ano" other: "há quase %{count} anos" password_reset: - no_token: "Desculpe, este link de alteração de senha é muito antigo. Selecione o botão Entrar e use \"Esqueci minha senha\" para obter um novo link." choose_new: "Escolha uma nova senha" choose: "Escolha uma senha" update: "Atualizar senha" @@ -1335,7 +1333,7 @@ pt_BR: labels: user: Usuário(a) qtt_like: Curtidas recebidas - description: "Melhores 10 usuários(as) que receberram curtidas." + description: "Melhores dez usuários(as) que receberram curtidas." top_users_by_likes_received_from_inferior_trust_level: title: "Melhores usuários(as) por curtidas recebidas de usuário(a) com nível de confiança mais baixo" labels: @@ -1348,7 +1346,7 @@ pt_BR: labels: user: Usuário(a) qtt_like: Curtidas recebidas - description: "Melhores 10 usuários(as) que receberam curtidas de uma ampla variedade de pessoas." + description: "Melhores dez usuários(as) que receberam curtidas de uma ampla variedade de pessoas." dashboard: group_email_credentials_warning: 'Houve um problema nas credenciais do e-mail do grupo %{group_full_name}. Nenhum e-mail será enviado a partir da caixa de entrada do grupo até este problema ser resolvido. %{error}' rails_env_warning: "Seu servidor está rodando no modo %{env}." @@ -1424,7 +1422,7 @@ pt_BR: download_remote_images_threshold: "Espaço mínimo necessário em disco para baixar em modo local imagens remotas (em %)" disabled_image_download_domains: "Imagens remotas hospedadas nestes domínios nunca serão baixadas. Lista delimitada por barras verticais." block_hotlinked_media: "Impedir que os usuários introduzam mídia remota (hotlinked) em suas publicações. A mídia remota que não for baixada via 'download_remote_images_to_local' será substituída por um link de espaço reservado." - block_hotlinked_media_exceptions: "Uma lista de URLs base, isentos da configuração block_hotlinked_media. Inclua o protocolo (por exemplo, https://example.com)." + block_hotlinked_media_exceptions: "Uma lista de URLs de base, isentos da configuração block_hotlinked_media. Inclua o protocolo (por exemplo, https://example.com)." editing_grace_period: "Durante (n) segundos após a publicação, as edições não criarão uma nova versão no histórico da postagem." editing_grace_period_max_diff: "Quantidade máxima de alterações de caracteres permitidas no período de carência de edição. Se houver mais alterações, armazene outra revisão de postagem (nível de confiança 0 e 1)" editing_grace_period_max_diff_high_trust: "Quantidade máxima de alterações de caracteres permitidas no período de carência de edição. Se houver mais alterações, armazene outra revisão de postagem (nível de confiança 2 e maior)" @@ -1447,7 +1445,7 @@ pt_BR: show_pinned_excerpt_desktop: "Mostrar trecho de tópicos fixados na visualização para desktop." post_onebox_maxlength: "Tamanho máximo para uma postagem do Discourse no Onebox." blocked_onebox_domains: "Uma lista de domínios que nunca serão \"oneboxed\", por exemplo, wikipedia.org\n(símbolos coringas como \"*\" e \"?\" não são suportados)" - block_onebox_on_redirect: "Bloqueie o onebox para URLs que redirecionam." + block_onebox_on_redirect: "Bloqueie o Onebox para URLs que redirecionam." allowed_inline_onebox_domains: "Uma lista de domínios que serão colocados em miniatura no Onebox se forem vinculados sem um título" enable_inline_onebox_on_all_domains: "Ignore a configuração do site inline_onebox_domain_whitelist e permita inclusões de Onebox em todos os domínios." force_custom_user_agent_hosts: "Hosts para os quais usar um agente do(a) usuário(a) do Onebox em todos os pedidos. (Especialmente útil para hosts que limitam acesso do agente do(a) usuário(a).)" @@ -3687,7 +3685,6 @@ pt_BR: png_to_jpg_conversion_failure_message: "Ocorreu um erro ao converter PNG em JPG." optimize_failure_message: "Ocorreu um erro ao otimizar a imagem enviada." download_failure: "Falha ao baixar o arquivo do provedor externo." - size_mismatch_failure: "O tamanho do arquivo enviado para S3 não corresponde ao tamanho pretendido do stub do envio externo. %{additional_detail}" create_multipart_failure: "Falha ao criar envio com partes múltiplas no armazenamento externo." abort_multipart_failure: "Falha ao anular envio com partes múltiplas no armazenamento externo." complete_multipart_failure: "Falha ao concluir envio com partes múltiplas no armazenamento externo." @@ -3750,12 +3747,12 @@ pt_BR: dark_rose: "Rosa-escuro" wcag: "WCAG claro" wcag_theme_name: "WCAG claro" - dracula: "Dracula" - dracula_theme_name: "Dracula" - solarized_light: "Solarized Light" - solarized_light_theme_name: "Solarized Light" - solarized_dark: "Solarized Dark" - solarized_dark_theme_name: "Solarized Dark" + dracula: "Drácula" + dracula_theme_name: "Drácula" + solarized_light: "Cor clara solarizada" + solarized_light_theme_name: "Cor clara solarizada" + solarized_dark: "Cor escura solarizada" + solarized_dark_theme_name: "Cor escura solarizada" wcag_dark: "WCAG escuro" wcag_dark_theme_name: "WCAG escuro" default_theme_name: "Padrão" @@ -4410,11 +4407,11 @@ pt_BR: synonym: 'Sinônimos não são permitidos. Use "%{tag_name}".' has_synonyms: '"%{tag_name}" não pode ser usado porque tem sinônimos.' restricted_tags_cannot_be_used_in_category: - one: 'A etiqueta "%{tags}" não pode ser usada na categoria "%{category}". Por favor, remova-a.' - other: 'As seguintes etiquetas não podem ser usadas na categoria "%{category}": %{tags}. Por favor, remova-as.' + one: 'A etiqueta "%{tags}" não pode ser usada na categoria "%{category}". Remova.' + other: 'As seguintes etiquetas não podem ser usadas na categoria "%{category}": %{tags}. Remova.' category_does_not_allow_tags: - one: 'A categoria "%{category}" não permite o uso da etiqueta "%{tags}". Por favor, remova-a.' - other: 'A categoria "%{category}" não permite o uso das etiquetas "%{tags}". Por favor, remova-as.' + one: 'A categoria "%{category}" não permite o uso da etiqueta "%{tags}". Remova.' + other: 'A categoria "%{category}" não permite o uso das etiquetas "%{tags}". Remova.' required_tags_from_group: one: "Você deve incluir pelo menos %{count} etiqueta %{tag_group_name}. As etiquetas deste grupo são: %{tags}." other: "Você deve incluir pelo menos %{count} etiquetas %{tag_group_name}. As etiquetas deste grupo são: %{tags}." @@ -4468,7 +4465,7 @@ pt_BR: extra_description: "Somente usuários(as) que entraram com a conta podem acessar esta comunidade" invite_only: placeholder: "Apenas Convidados" - extra_description: "Usuários(as) devem ser convidados(as) por usuários(as) confiáveis ou pela equipe, caso contrário, poderão se cadastrar por conta própria" + extra_description: "Usuários(as) devem ser convidados(as) por usuários(as) confiáveis ou pela equipe. Caso contrário, poderão se cadastrar por conta própria" must_approve_users: placeholder: "Requerer aprovação" extra_description: "Os usuários devem ser aprovados pela equipe" @@ -4476,7 +4473,7 @@ pt_BR: title: "Seu Discourse está pronto!" description: "É isso! Você fez o básico para configurar sua comunidade. Agora você pode entrar, dar uma olhada, escrever um tópico de boas-vindas e enviar convites!

    Divirta-se!" styling: - title: "Aparência & Comportamento" + title: "Aparência e Comportamento" fields: color_scheme: label: "Esquema de cores" @@ -4506,7 +4503,7 @@ pt_BR: categories_boxes_with_topics: label: "Caixas de Categorias com Tópicos" subcategories_with_featured_topics: - label: "Sub-Categorias com tópicos em destaque" + label: "Subcategorias com tópicos em destaque" branding: title: "Personalizar logos" fields: diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index 086aeed9eb..a1658ae85d 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -452,7 +452,6 @@ ro: few: "cu aproape %{count} ani în urmă" other: "cu aproape %{count} de ani în urmă" password_reset: - no_token: "Ne pare rău, dar link-ul de schimbare a parolei este prea vechi. Selectează butonul Înregistrare și folosește 'Mi-am uitat parola' pentru a obține un nou link." choose_new: "Alege o parolă nouă" choose: "Alege o parolă" update: "Actualizează parola" diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index 70478903ff..b01b503e08 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -546,7 +546,6 @@ ru: Вместо нового ответа, можно начать редактировать свой предыдущий ответ и просто добавить в него новые цитаты и ответы на них. Для этого нужно выделить требуемый текст и нажать на появившуюся кнопку Ответить с цитированием. Для большинства людей намного проще читать темы, содержащие несколько ответов в одном сообщении, чем много отдельных коротких ответов. - dominating_topic: Вы опубликовали здесь более %{percent}% ответов, возможно, стоит услышать ещё чьё-то мнение? get_a_room: Вы ответили пользователю @%{reply_username} %{count} раза; знаете ли вы, что вместо этого ему можно отправить личное сообщение? too_many_replies: | ### Вы дали максимально возможное количество ответов в этой теме. @@ -867,7 +866,6 @@ ru: many: "почти %{count} лет назад" other: "почти %{count} лет назад" password_reset: - no_token: "К сожалению, ссылка на изменение пароля устарела. Нажмите на кнопку \"Войти\", а затем на \"Я забыл свой пароль\", чтобы создать новую ссылку для изменения пароля." choose_new: "Введите новый пароль" choose: "Введите пароль" update: "Обновить пароль" @@ -3859,7 +3857,6 @@ ru: png_to_jpg_conversion_failure_message: "Произошла ошибка при конвертации из PNG в JPG." optimize_failure_message: "При оптимизации загруженного изображения произошла ошибка." download_failure: "Не удалось загрузить файл от внешнего провайдера." - size_mismatch_failure: "Размер файла, загруженного в S3, не совпадает с предполагаемым размером файла. %{additional_detail}" create_multipart_failure: "Не удалось создать многокомпонентную загрузку во внешнем хранилище." abort_multipart_failure: "Не удалось прервать многокомпонентную загрузку во внешнем хранилище." complete_multipart_failure: "Не удалось выполнить многокомпонентную загрузку во внешнем хранилище." diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml index a47d22f741..4fcd1fdc6a 100644 --- a/config/locales/server.sk.yml +++ b/config/locales/server.sk.yml @@ -463,7 +463,6 @@ sk: many: "pred takmer %{count} rokmi" other: "pred takmer %{count} rokmi" password_reset: - no_token: "Ľutujeme, tento odkaz na zmenu hesla je už príliš starý. Stlačte tlačidlo Prihlásiť a použite \"Zabudol som heslo\" pre vytvorenie nového odkazu." choose_new: "Napíš nové heslo" choose: "Napíš heslo" update: "Aktualizujte heslo" diff --git a/config/locales/server.sl.yml b/config/locales/server.sl.yml index 2d2d5f618d..f922d398af 100644 --- a/config/locales/server.sl.yml +++ b/config/locales/server.sl.yml @@ -458,7 +458,6 @@ sl: few: "skoraj %{count} leta nazaj" other: "skoraj %{count} let nazaj" password_reset: - no_token: "Povezava za zamenjavo gesla je potekla. Izberite gumb Prijava in uporabite 'Pozabil sem geslo' da dobite novo povezavo.." choose_new: "Vnesite novo geslo" choose: "Vnesite geslo" update: "Spremenite geslo" diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml index aa8cb90b12..81f20578c0 100644 --- a/config/locales/server.sq.yml +++ b/config/locales/server.sq.yml @@ -300,7 +300,6 @@ sq: one: "pothuajse %{count} vit më parë" other: "pothuajse %{count} vite më parë" password_reset: - no_token: "Ndjesë, kjo lidhje për ndryshimin e fjalëkalimit është shumë e vjetër. Kliko butonin 'Identifikohu' dhe përdorni 'Kam harruar fjalëkalimin' për të marrë një lidhje të re." choose_new: "Zgjidhni një fjalëkalim të ri" choose: "Zgjidhni një fjalëkalim të ri" update: "Rifresko Fjalëkalimin" diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index 93c30bc340..05bf6a7acc 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -67,6 +67,7 @@ sv: modifier_values: "about.json-modifierare innehåller ogiltiga värden: %{errors}" git: "Fel vid kloning av git-lagringsplats, åtkomst nekas eller lagringsplatsen finns inte" git_ref_not_found: "Det gick inte att ta ut git-referens: %{ref}" + git_unsupported_scheme: "Det går inte att klona git repo: schemat stöds inte" unpack_failed: "Det gick inte att packa upp filen" file_too_big: "Den okomprimerade filen är för stor." unknown_file_type: "Filen som du laddade upp verkar inte vara ett giltigt Discourse-tema." @@ -248,6 +249,7 @@ sv: max_redemptions_allowed_one: "för e-postinbjudningar ska vara 1." redemption_count_less_than_max: "bör vara mindre än %{max_redemptions_allowed}." email_xor_domain: "E-post- och domänfält är inte tillåtna samtidigt" + existing_user_success: "Inbjudan har lösts in" bulk_invite: file_should_be_csv: "De uppladdade filerna skall vara i csv-format." max_rows: "De första %{max_bulk_invites} inbjudningarna har skickats ut. Prova att dela upp filen i mindre delar." @@ -495,7 +497,7 @@ sv: Du kan redigera ditt tidigare svar och lägga till ett citat genom att markera text och välja knappen citera som dyker upp. Det blir lättare för alla att läsa ämnen som har färre in-på-djupet-svar istället för flera små, individuella svar. - dominating_topic: Du har publicerat mer än %{percent} % av svaren här. Finns det någon annan du vill höra från? + dominating_topic: Du har skrivit mer än %{percent} % av svaren här. Får vi föreslå att du ger andra en möjlighet att komma till tals? get_a_room: Du har svarat @%{reply_username} %{count} gånger. Visste du att du istället kunde skicka ett personligt meddelande till henne/honom? too_many_replies: | ### Du har nått svarsgränsen för detta ämne @@ -764,7 +766,7 @@ sv: one: "nästan %{count} år sedan" other: "nästan %{count} år sedan" password_reset: - no_token: "Tyvärr har din lösenordslänk löpt ut. Klicka på inloggningsknappen och välj \"jag har glömt mitt lösenord\" för att få en ny länk." + no_token: 'Hoppsan! Länken du använde fungerar inte längre. Du kan logga in nu. Om du har glömt ditt lösenord kan du begära en länk för att återställa det.' choose_new: "Välj ett nytt lösenord" choose: "Välj ett lösenord" update: "Uppdatera lösenord" @@ -2049,6 +2051,7 @@ sv: disable_mailing_list_mode: "Tillåt inte att användare aktiverar utskicksläget för e-postlistor (förhindrar att meddelanden till e-postlistan skickas.)" default_email_previous_replies: "Som standard omfattas tidigare svar i e-postmeddelanden." default_email_in_reply_to: "Omfatta ett utdrag av svar på inlägg i e-post som standard." + default_hide_profile_and_presence: "Dölj användarens offentliga profil och närvarofunktioner som standard." default_other_new_topic_duration_minutes: "Generellt standardvillkor för när ett ämne anses nytt." default_other_auto_track_topics_after_msecs: "Generell standardtid innan ett ämne följs automatiskt." default_other_notification_level_when_replying: "Generell standardiserad bevakningssnivå när en användare svarar i ett ämne." @@ -3517,7 +3520,7 @@ sv: png_to_jpg_conversion_failure_message: "Ett fel uppstod vid konvertering från PNG till JPG." optimize_failure_message: "Det uppstod ett fel vid optimering av den uppladdade bilden." download_failure: "Det gick inte att hämta filen från den externa leverantören." - size_mismatch_failure: "Storleken på filen som uppladdats till S3 matchade inte avsedd storlek på extern uppladdnings-stub. %{additional_detail}" + size_mismatch_failure: "Storleken på filen som uppladdats till S3 matchade inte avsedd storlek på den externa uppladdningen. %{additional_detail}" create_multipart_failure: "Det gick inte att skapa flerdelad uppladdning i den externa butiken." abort_multipart_failure: "Det gick inte att avbryta flerdelad uppladdning i den externa butiken." complete_multipart_failure: "Det gick inte att slutföra flerdelad uppladdning i den externa butiken." @@ -4376,6 +4379,10 @@ sv: user_status: errors: ends_at_should_be_greater_than_set_at: "ends_at bör vara större än set_at." + webhooks: + payload_url: + blocked_or_internal: "Försändelse-URL:en kan inte användas eftersom den leder till en blockerad eller intern IP-adress." + unsafe: "Försändelse-URL:en kan inte användas eftersom den är osäker" activemodel: errors: <<: *errors diff --git a/config/locales/server.sw.yml b/config/locales/server.sw.yml index 7d4f0afb92..1a87e5de67 100644 --- a/config/locales/server.sw.yml +++ b/config/locales/server.sw.yml @@ -406,7 +406,6 @@ sw: one: "siku %{count} iliyopita" other: "siku %{count} zilizopita" password_reset: - no_token: "Samahani, kiungo hicho cha kuingia kupitia barua pepe ni cha mda sana. Bonyeza kitufe cha Kuingia na tumia 'Nimesahau nywila' ili upate kiungo kipya." choose_new: "Chagua nywila" choose: "Chagua nywila" update: "Sasisha Nywila" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index ecaa79b49b..77cd0b4edb 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -484,7 +484,6 @@ tr_TR: Herkesin benzersiz bir profil resmi olduğunda, tartışmaları takip etmek ve konuşmalarda ilginç insanlar bulmak daha kolay! sequential_replies: "### Aynı anda birkaç gönderiye yanıt vermeyi denediniz \nBunun yerine yerine, lütfen önceki gönderilerden alıntılar veya @name referansları içeren tek bir yanıt veriniz.\nMetni vurgulayıp görünen alıntı düğmesini seçerek önceki yanıtınızı düzenleyebilirsiniz. Böylece diğer kullanıcıların, yanıtlarınızı okuması daha kolay olacaktır.\n" - dominating_topic: Birden fazla gönderdiniz %{percent}Burada cevapların% else orada herkesin duymak isteriz edilir? get_a_room: '@%{reply_username} %{count} kez yanıt verdiniz, bunun yerine ona kişisel bir ileti gönderebileceğinizi biliyor muydunuz?' too_many_replies: | ### Bu konu için yanıt limitinizi doldurdunuz @@ -746,7 +745,6 @@ tr_TR: one: "neredeyse %{count} yıl önce" other: "neredeyse %{count} yıl önce" password_reset: - no_token: "Üzgünüz, bu parola değiştirme bağlantısı çok eski. Yeni bir bağlantı almak için lütfen 'Giriş Yap' tuşuna basın ve 'Parolamı unuttum'u seçin." choose_new: "Yeni bir parola seç" choose: "Parola seç" update: "Parolayı Güncelle" diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index 4aa4c51d26..116f0b74b2 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -539,7 +539,6 @@ uk: Замість нового відповіді, зараз можна почати редагувати свій попередній відповідь і просто додати в нього нові цитати і відповіді та них. Для цього потрібно виділити необхідний текст і натиснути на кнопку, що з'явилася відповісти цитуванням. Для більшості людей набагато простіше читати теми, у яких довгі відповіді і їх мало, ніж коли багато коротеньких відповідей. - dominating_topic: Ви розмістили тут більше %{percent}% відповідей, можливо є ще хтось, кого варто було би почути? get_a_room: Ви відповіли користувачу @%{reply_username} %{count} разів, ви знали, що можете надіслати їм особисте повідомлення? too_many_replies: "### Ви досягли межі відповідей в цю тему. \nНа жаль, нові користувачі тимчасово обмежені %{newuser_max_replies_per_topic} відповідей в цій темі. \nЗамість того, щоб додавати ще одну відповідь, будь ласка, подумайте про редагування попередніх відповідей або відвідайте інші теми.\n" reviving_old_topic: | @@ -852,7 +851,6 @@ uk: many: "майже %{count} років тому" other: "майже %{count} років тому" password_reset: - no_token: "На жаль, дане посилання на зміну пароля застаріло. Натисніть на кнопку \"Увійти\", а потім на \"Я забув свій пароль\", щоб згенерувати нове посилання для зміни пароля." choose_new: "Вибрати новий пароль" choose: "Вибрати пароль" update: "Оновити пароль" diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index 9fbc551756..c10603c329 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -480,7 +480,6 @@ ur: متن کو اُجاگر کر کہ ظاہر ہونے والے جواب اقتباس کریں بٹن منتخب کرنے سے ایک اقتباس شامل کرنے کیلئے آپ اپنے پچھلے جواب میں ترمیم کرسکتے ہیں۔ ہر ایک کیلئے اُن ٹاپکس کو پڑھ نا زیادہ آسان ہے جس میں بہت سے چھوٹے، انفرادی جوابات کے مقابلے میں تفصیلی لیکن کم جوابات ہوں۔ - dominating_topic: آپ نے یہاں %{percent}% سے زیادہ جوابات پوسٹ کیے ہیں، کیا کوئی اور ہے جس سے آپ سننا چاہیں گے؟ get_a_room: آپ نے @%{reply_username} %{count} بار جواب دیا ہے، کیا آپ جانتے ہیں کہ اس کے بجائے آپ انہیں ذاتی پیغام بھیج سکتے ہیں؟ too_many_replies: | ### آپ اس ٹاپک پر جوابات کے نمبر کی حد تک پہنچ گئے ہیں @@ -736,7 +735,6 @@ ur: one: "تقریباً %{count} سال قبل" other: "تقریباً %{count} سال قبل" password_reset: - no_token: "معذرت، وہ پاسورڈ تبدیل کرنے کا لِنک بہت پرانا ہے۔ لاگ ان کے بٹن کو منتخب کریں اور ایک نیا لِنک حاصل کرنے کیلئے 'میں اپنا پاسورڈ بھول گیا' کا استعمال کریں۔" choose_new: "نیا پاسورڈ منتخب کریں" choose: "پاسورڈ منتخب کریں" update: "پاسورڈ اَپ ڈیٹ کریں" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index 28c762e094..0ecd565086 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -602,7 +602,6 @@ vi: almost_x_years: other: "gần %{count} năm trước" password_reset: - no_token: "Xin lỗi, liên kết đổi mật khẩu đã cũ. Chọn \"Đăng nhập\" và sử dụng chức năng \"Quên mật khẩu\" để lấy liên kết mới." choose_new: "Chọn một mật khẩu mới" choose: "Chọn một mật khẩu" update: "Cập nhật mật khẩu" diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index 8339bc4365..6110f77b06 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -470,7 +470,6 @@ zh_CN: 您可以突出显示文本并选择出现的引用回复按钮,对您之前的回复进行编辑以添加引用。 这样,每个人都可以更轻松地阅读具有少量嵌套回复的话题,而不是查看大量的个别回复。 - dominating_topic: 您在这里发布了超过 %{percent}% 的回复,想听听其他人的发言吗? get_a_room: 您已回复 @%{reply_username} %{count} 次,您知道您可以向他们发送个人消息吗? too_many_replies: | ### 您已达到此话题的回复上限 @@ -713,7 +712,6 @@ zh_CN: almost_x_years: other: "将近 %{count} 年前" password_reset: - no_token: "抱歉,该密码更改链接太旧。选择“登录”按钮并使用“我忘记密码了”获得新链接。" choose_new: "选择一个新密码" choose: "选择一个密码" update: "更新密码" @@ -3620,7 +3618,6 @@ zh_CN: png_to_jpg_conversion_failure_message: "从 PNG 转换为 JPG 时出错。" optimize_failure_message: "优化上传的图片时出错。" download_failure: "从外部提供商下载文件失败。" - size_mismatch_failure: "上传到 S3 的文件大小与外部上传存根的预期大小不匹配。%{additional_detail}" create_multipart_failure: "无法在外部存储中创建分段上传。" abort_multipart_failure: "无法中止外部存储中的分段上传。" complete_multipart_failure: "无法在外部存储中完成分段上传。" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index 26826072b3..e17a08f4ab 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -335,6 +335,7 @@ zh_TW: 你可以編輯你先前的回應訊息,選擇字句後點選 引用 來回應。 對於大家而言,閱讀包含較多回覆的少量貼文,會比閱讀多篇瑣碎貼文更為輕鬆。 + dominating_topic: 您在這裡發表了超過 %{percent}% 的回覆;我們可以建議您給其他人一個加入討論的機會嗎? too_many_replies: | ### 你的回應數量已經達到本主題的回覆上限 @@ -539,7 +540,6 @@ zh_TW: almost_x_years: other: "約 %{count} 年前" password_reset: - no_token: "抱歉,密碼修改連結已過期。選擇登錄按鈕再使用“我忘記了密碼”獲得一個新連結。" choose_new: "選擇一個新密碼" choose: "選擇一個密碼" update: "更新密碼" @@ -2586,6 +2586,7 @@ zh_TW: file_missing: "抱歉,你必須選擇一個檔案上傳。" empty: "很抱歉,您提供的文件是空的" png_to_jpg_conversion_failure_message: "從PNG轉換為JPG時,發生了錯誤。" + size_mismatch_failure: "上傳到 S3 的檔案大小與外部上傳的預期大小不符。 %{additional_detail}" attachments: too_large: "抱歉,你試圖上傳的檔案太大了(最大限製為%{max_size_kb}%KB)。" images: @@ -2827,7 +2828,7 @@ zh_TW: long_description: | 該徽章授與給分享連結給 1000 個其他訪客的你。哇!你把一個有意思的討論推廣給了廣大的讀者們,並且幫助社群前進了一大步! first_like: - name: 首次贊 + name: 首次按讚 description: 已讚過了一個貼文 long_description: | 該徽章授予給第一次使用 :heart: 按鈕讚了貼文的成員。給貼文點贊是一個極好的讓社群成員知道他們的貼文有意思、有用、酷炫或者好玩的方法。分享愛! diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index 99f05f075f..fdb264d872 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -16,8 +16,12 @@ proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m ma # Increased from the default value to acommodate large cookies during oAuth2 flows # like in https://meta.discourse.org/t/x/74060 and large CSP and Link (preload) headers -proxy_buffer_size 16k; -proxy_buffers 4 16k; +proxy_buffer_size 32k; +proxy_buffers 4 32k; + +# Increased from the default value to allow for a large volume of cookies in request headers +# Discourse itself tries to minimise cookie size, but we cannot control other cookies set by other tools on the same domain. +large_client_header_buffers 4 32k; # If you are going to use Puma, use these: # diff --git a/config/routes.rb b/config/routes.rb index 27d7f27b6e..22e7389507 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -838,8 +838,8 @@ Discourse::Application.routes.draw do get 'embed/count' => 'embed#count' get 'embed/info' => 'embed#info' - get "new-topic" => "list#latest" - get "new-message" => "list#latest" + get "new-topic" => "new_topic#index" + get "new-message" => "new_topic#index" # Topic routes get "t/id_for/:slug" => "topics#id_for_slug" diff --git a/config/site_settings.yml b/config/site_settings.yml index 2145dbddd8..035c964f3e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -379,7 +379,7 @@ basic: enable_user_status: client: true default: false - enable_onboarding_popups: + enable_user_tips: client: true default: false @@ -2136,6 +2136,7 @@ backups: include_s3_uploads_in_backups: default: false hidden: true + client: true search: use_pg_headlines_for_excerpt: @@ -2532,6 +2533,8 @@ user_preferences: default: 2 default_email_in_reply_to: default: false + default_hide_profile_and_presence: + default: false default_other_new_topic_duration_minutes: enum: "NewTopicDurationSiteSetting" diff --git a/db/migrate/20221101140632_rename_onboarding_popups_site_setting.rb b/db/migrate/20221101140632_rename_onboarding_popups_site_setting.rb new file mode 100644 index 0000000000..da6fa2e5fc --- /dev/null +++ b/db/migrate/20221101140632_rename_onboarding_popups_site_setting.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RenameOnboardingPopupsSiteSetting < ActiveRecord::Migration[7.0] + def up + execute "UPDATE site_settings SET name = 'enable_user_tips' WHERE name = 'enable_onboarding_popups'" + end + + def down + execute "UPDATE site_settings SET name = 'enable_onboarding_popups' WHERE name = 'enable_user_tips'" + end +end diff --git a/db/migrate/20221101181505_hide_all_user_tips_for_existent_users.rb b/db/migrate/20221101181505_hide_all_user_tips_for_existent_users.rb new file mode 100644 index 0000000000..4336add8e9 --- /dev/null +++ b/db/migrate/20221101181505_hide_all_user_tips_for_existent_users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class HideAllUserTipsForExistentUsers < ActiveRecord::Migration[7.0] + def up + execute "UPDATE user_options SET seen_popups = '{1, 2}'" + end + + def down + execute "UPDATE user_options SET seen_popups = '{}'" + end +end diff --git a/db/migrate/20221103051248_remove_invalid_topic_allowed_users_from_invites.rb b/db/migrate/20221103051248_remove_invalid_topic_allowed_users_from_invites.rb new file mode 100644 index 0000000000..d161a9f16d --- /dev/null +++ b/db/migrate/20221103051248_remove_invalid_topic_allowed_users_from_invites.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class RemoveInvalidTopicAllowedUsersFromInvites < ActiveRecord::Migration[7.0] + def up + # We are getting all the topic_allowed_users records that + # match an invited user, which is created as part of the invite + # redemption flow. The original invite would _not_ have had a topic_invite + # record, and the user should have been added to the topic in the brief + # period between creation of the invited_users record and the update of + # that record. + # + # Having > 2 topic allowed users disqualifies messages sent only + # by the system or an admin to the user. + subquery_sql = <<~SQL + SELECT DISTINCT id + FROM ( + SELECT tau.id, tau.user_id, COUNT(*) OVER (PARTITION BY tau.user_id) + FROM topic_allowed_users tau + JOIN invited_users iu ON iu.user_id = tau.user_id + LEFT JOIN topic_invites ti ON ti.invite_id = iu.invite_id AND tau.topic_id = ti.topic_id + WHERE ti.id IS NULL + AND tau.created_at BETWEEN iu.created_at AND iu.updated_at + AND iu.redeemed_at > '2022-10-27' + ) AS matching_topic_allowed_users + WHERE matching_topic_allowed_users.count > 2 + SQL + + # Back up the records we are going to change in case we are too + # brutal, and for further inspection. + # + # TODO DROP this table (topic_allowed_users_backup_nov_2022) in a later migration. + DB.exec(<<~SQL) + CREATE TABLE topic_allowed_users_backup_nov_2022 + ( + id INT NOT NULL, + user_id INT NOT NULL, + topic_id INT NOT NULL + ); + INSERT INTO topic_allowed_users_backup_nov_2022(id, user_id, topic_id) + SELECT id, user_id, topic_id + FROM topic_allowed_users + WHERE id IN ( + #{subquery_sql} + ) + SQL + + # Delete the invalid topic allowed users that should not be there. + DB.query(<<~SQL) + DELETE + FROM topic_allowed_users + WHERE id IN ( + #{subquery_sql} + ) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20221108032233_drop_old_bookmark_columns_v2.rb b/db/post_migrate/20221108032233_drop_old_bookmark_columns_v2.rb new file mode 100644 index 0000000000..a0ad3fd3d1 --- /dev/null +++ b/db/post_migrate/20221108032233_drop_old_bookmark_columns_v2.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'migration/column_dropper' + +class DropOldBookmarkColumnsV2 < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { + bookmarks: %i{ + post_id + for_topic + } + } + + def up + DROPPED_COLUMNS.each do |table, columns| + Migration::ColumnDropper.execute_drop(table, columns) + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lefthook.yml b/lefthook.yml index c11d115f4a..be90ea676c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,6 +4,9 @@ skip_output: pre-commit: parallel: true + skip: + - merge + - rebase commands: rubocop: glob: "*.rb" diff --git a/lib/guardian.rb b/lib/guardian.rb index 0dbdd1dec3..83387fc7ba 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -359,8 +359,8 @@ class Guardian flair_icon.present? || flair_upload_id.present? end - def can_change_primary_group?(user) - user && is_staff? + def can_change_primary_group?(user, group) + user && can_edit_group?(group) end def can_change_trust_level?(user) @@ -445,23 +445,42 @@ class Guardian can_send_private_message?(group) end - def can_send_private_message?(target, notify_moderators: false) - is_user = target.is_a?(User) - is_group = target.is_a?(Group) + ## + # This should be used as a general, but not definitive, check for whether + # the user can send private messages _generally_, which is mostly useful + # for changing the UI. + # + # Please otherwise use can_send_private_message?(target, notify_moderators) + # to check if a single target can be messaged. + def can_send_private_messages?(notify_moderators: false) from_system = @user.is_system_user? from_bot = @user.bot? - (is_group || is_user) && # User is authenticated authenticated? && + # User can send PMs, this can be covered by trust levels as well via AUTO_GROUPS + (is_staff? || from_bot || from_system || \ + (@user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map)) || notify_moderators) + end + + ## + # This should be used as a final check for when a user is sending a message + # to a target user or group. + def can_send_private_message?(target, notify_moderators: false) + target_is_user = target.is_a?(User) + target_is_group = target.is_a?(Group) + from_system = @user.is_system_user? + + # Must be a valid target + (target_is_group || target_is_user) && + # User is authenticated and can send PMs, this can be covered by trust levels as well via AUTO_GROUPS + can_send_private_messages?(notify_moderators: notify_moderators) && # User disabled private message - (is_staff? || is_group || target.user_option.allow_private_messages) && - # User can send PMs, this can be covered by trust levels as well via AUTO_GROUPS - (is_staff? || from_bot || from_system || (@user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map)) || notify_moderators) && + (is_staff? || target_is_group || target.user_option.allow_private_messages) && # Can't send PMs to suspended users - (is_staff? || is_group || !target.suspended?) && + (is_staff? || target_is_group || !target.suspended?) && # Check group messageable level - (from_system || is_user || Group.messageable(@user).where(id: target.id).exists? || notify_moderators) && + (from_system || target_is_user || Group.messageable(@user).where(id: target.id).exists? || notify_moderators) && # Silenced users can only send PM to staff (!is_silenced? || target.staff?) end diff --git a/lib/guardian/tag_guardian.rb b/lib/guardian/tag_guardian.rb index 8d445fca9c..5a4be92ab4 100644 --- a/lib/guardian/tag_guardian.rb +++ b/lib/guardian/tag_guardian.rb @@ -15,8 +15,7 @@ module TagGuardian return false if @user.blank? return true if @user == Discourse.system_user - # TODO (martin) Change to pm_tags_allowed_for_groups_map - group_ids = SiteSetting.pm_tags_allowed_for_groups.to_s.split("|").map(&:to_i) + group_ids = SiteSetting.pm_tags_allowed_for_groups_map group_ids.include?(Group::AUTO_GROUPS[:everyone]) || @user.group_users.exists?(group_id: group_ids) end diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index 703c459f03..6eb4ffbd19 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -26,7 +26,6 @@ class Plugin::Metadata "discourse-categories-suppressed", "discourse-category-experts", "discourse-characters-required", - "discourse-chat", "discourse-chat-integration", "discourse-checklist", "discourse-code-review", @@ -60,11 +59,11 @@ class Plugin::Metadata "discourse-linkedin-auth", "discourse-microsoft-auth", "discourse-policy", + "discourse-post-voting", "discourse-presence", "discourse-prometheus", "discourse-prometheus-alert-receiver", "discourse-push-notifications", - "discourse-question-answer", "discourse-reactions", "discourse-restricted-replies", "discourse-rss-polling", @@ -82,15 +81,16 @@ class Plugin::Metadata "discourse-teambuild", "discourse-templates", "discourse-tooltips", + "discourse-topic-voting", "discourse-translator", "discourse-user-card-badges", "discourse-user-notes", "discourse-vk-auth", - "discourse-voting", "discourse-whos-online", "discourse-yearly-review", "discourse-zendesk-plugin", "docker_manager", + "chat", "lazy-yt", "poll", "styleguide", diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 859ca462ca..d1a08cef37 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -105,6 +105,15 @@ class S3Helper rescue Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound end + def delete_objects(keys) + s3_bucket.delete_objects({ + delete: { + objects: keys.map { |k| { key: k } }, + quiet: true, + }, + }) + end + def copy(source, destination, options: {}) if options[:apply_metadata_to_destination] options = options.except(:apply_metadata_to_destination).merge(metadata_directive: "REPLACE") diff --git a/lib/search.rb b/lib/search.rb index 2411574208..4e4df3d96d 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -446,10 +446,10 @@ class Search def post_action_type_filter(posts, post_action_type) posts.where("posts.id IN ( SELECT pa.post_id FROM post_actions pa - WHERE pa.user_id = #{@guardian.user.id} AND - pa.post_action_type_id = #{post_action_type} AND + WHERE pa.user_id = ? AND + pa.post_action_type_id = ? AND deleted_at IS NULL - )") + )", @guardian.user.id, post_action_type) end advanced_filter(/^in:(likes)$/i) do |posts, match| @@ -464,17 +464,17 @@ class Search # search based on a RegisteredBookmarkable's #search_query method. advanced_filter(/^in:(bookmarks)$/i) do |posts, match| if @guardian.user - posts.where(<<~SQL) + posts.where(<<~SQL, @guardian.user.id) posts.id IN ( SELECT bookmarkable_id FROM bookmarks - WHERE bookmarks.user_id = #{@guardian.user.id} AND bookmarks.bookmarkable_type = 'Post' + WHERE bookmarks.user_id = ? AND bookmarks.bookmarkable_type = 'Post' ) SQL end end advanced_filter(/^in:posted$/i) do |posts| - posts.where("posts.user_id = #{@guardian.user.id}") if @guardian.user + posts.where("posts.user_id = ?", @guardian.user.id) if @guardian.user end advanced_filter(/^in:(created|mine)$/i) do |posts| @@ -640,7 +640,7 @@ class Search advanced_filter(/^user:(.+)$/i) do |posts, match| user_id = User.where(staged: false).where('username_lower = ? OR id = ?', match.downcase, match.to_i).pluck_first(:id) if user_id - posts.where("posts.user_id = #{user_id}") + posts.where("posts.user_id = ?", user_id) else posts.where("1 = 0") end @@ -656,7 +656,7 @@ class Search end if user_id - posts.where("posts.user_id = #{user_id}") + posts.where("posts.user_id = ?", user_id) else posts.where("1 = 0") end @@ -1043,13 +1043,13 @@ class Search posts.where("topics.category_id in (?)", category_ids) elsif is_topic_search - posts.where("topics.id = #{@search_context.id}") + posts.where("topics.id = ?", @search_context.id) .order("posts.post_number #{@order == :latest ? "DESC" : ""}") elsif @search_context.is_a?(Tag) posts = posts .joins("LEFT JOIN topic_tags ON topic_tags.topic_id = topics.id") .joins("LEFT JOIN tags ON tags.id = topic_tags.tag_id") - posts.where("tags.id = #{@search_context.id}") + posts.where("tags.id = ?", @search_context.id) end else posts = categories_ignored(posts) unless @category_filter_matched @@ -1243,8 +1243,8 @@ class Search end if min_id > 0 - low_set = query.dup.where("post_search_data.post_id < #{min_id}") - high_set = query.where("post_search_data.post_id >= #{min_id}") + low_set = query.dup.where("post_search_data.post_id < ?", min_id) + high_set = query.where("post_search_data.post_id >= ?", min_id) return { default: wrap_rows(high_set), remaining: wrap_rows(low_set) } end diff --git a/lib/shrink_uploaded_image.rb b/lib/shrink_uploaded_image.rb index 3123468ef1..f6ef2354d4 100644 --- a/lib/shrink_uploaded_image.rb +++ b/lib/shrink_uploaded_image.rb @@ -83,7 +83,7 @@ class ShrinkUploadedImage if post.raw_changed? log "Updating post" - elsif post.downloaded_images.has_value?(original_upload.id) + elsif post.post_hotlinked_media.exists?(upload_id: original_upload.id) log "A hotlinked, unreferenced image" elsif post.raw.include?(upload.short_url) log "Already processed" @@ -161,13 +161,10 @@ class ShrinkUploadedImage ) end - if existing_upload && post.downloaded_images.present? - downloaded_images = post.downloaded_images.transform_values do |upload_id| - upload_id == original_upload.id ? upload.id : upload_id - end - - post.custom_fields[Post::DOWNLOADED_IMAGES] = downloaded_images - post.save_custom_fields + if existing_upload + post.post_hotlinked_media + .where(upload_id: original_upload.id) + .update_all(upload_id: upload.id) end post.rebake! diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index 1ee4e8e557..97ac5d076c 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -6,10 +6,10 @@ require 'stylesheet/compiler' module Stylesheet; end class Stylesheet::Manager + BASE_COMPILER_VERSION = 1 CACHE_PATH ||= 'tmp/stylesheet-cache' MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}" - MANIFEST_FULL_PATH ||= "#{MANIFEST_DIR}/stylesheet-manifest" THEME_REGEX ||= /_theme$/ COLOR_SCHEME_STYLESHEET ||= "color_definitions" @@ -105,34 +105,65 @@ class Stylesheet::Manager nil end - def self.last_file_updated - if Rails.env.production? - @last_file_updated ||= if File.exist?(MANIFEST_FULL_PATH) - File.readlines(MANIFEST_FULL_PATH, 'r')[0] + def self.fs_asset_cachebuster + if use_file_hash_for_cachebuster? + @cachebuster ||= if File.exist?(manifest_full_path) + File.readlines(manifest_full_path, 'r')[0] else - mtime = max_file_mtime + cachebuster = "#{BASE_COMPILER_VERSION}:#{fs_assets_hash}" FileUtils.mkdir_p(MANIFEST_DIR) - File.open(MANIFEST_FULL_PATH, "w") { |f| f.print(mtime) } - mtime + File.open(manifest_full_path, "w") { |f| f.print(cachebuster) } + cachebuster end else - max_file_mtime + "#{BASE_COMPILER_VERSION}:#{max_file_mtime}" end end - def self.max_file_mtime - globs = ["#{Rails.root}/app/assets/stylesheets/**/*.*css", - "#{Rails.root}/app/assets/images/**/*.*"] + def self.recalculate_fs_asset_cachebuster! + File.delete(manifest_full_path) if File.exist?(manifest_full_path) + @cachebuster = nil + fs_asset_cachebuster + end - Discourse.plugins.map { |plugin| File.dirname(plugin.path) }.each do |path| + def self.manifest_full_path + path = "#{MANIFEST_DIR}/stylesheet-manifest" + return path if !Rails.env.test? + "#{path}-test_#{ENV['TEST_ENV_NUMBER'].presence || '0'}" + end + private_class_method :manifest_full_path + + def self.use_file_hash_for_cachebuster? + Rails.env.production? + end + private_class_method :use_file_hash_for_cachebuster? + + def self.list_files + globs = [ + "#{Rails.root}/app/assets/stylesheets/**/*.*css", + "#{Rails.root}/app/assets/images/**/*.*" + ] + + Discourse.plugins.each do |plugin| + path = File.dirname(plugin.path) globs << "#{path}/plugin.rb" globs << "#{path}/assets/stylesheets/**/*.*css" end - globs.map do |pattern| - Dir.glob(pattern).map { |x| File.mtime(x) }.max - end.compact.max.to_i + globs.flat_map { |g| Dir.glob(g) }.compact end + private_class_method :list_files + + def self.max_file_mtime + list_files.map { |x| File.mtime(x) }.compact.max.to_i + end + private_class_method :max_file_mtime + + def self.fs_assets_hash + hashes = list_files.sort.map { |x| Digest::SHA1.hexdigest("#{x}: #{File.read(x)}") } + Digest::SHA1.hexdigest(hashes.join("|")) + end + private_class_method :fs_assets_hash def self.cache_fullpath path = "#{Rails.root}/#{CACHE_PATH}" diff --git a/lib/stylesheet/manager/builder.rb b/lib/stylesheet/manager/builder.rb index f4da9d14dc..25b1e078a8 100644 --- a/lib/stylesheet/manager/builder.rb +++ b/lib/stylesheet/manager/builder.rb @@ -13,7 +13,7 @@ class Stylesheet::Manager::Builder def compile(opts = {}) if !opts[:force] if File.exist?(stylesheet_fullpath) - unless StylesheetCache.where(target: qualified_target, digest: digest).exists? + if !StylesheetCache.where(target: qualified_target, digest: digest).exists? begin source_map = begin File.read(source_map_fullpath) @@ -229,7 +229,7 @@ class Stylesheet::Manager::Builder end def default_digest - Digest::SHA1.hexdigest "default-#{Stylesheet::Manager.last_file_updated}-#{plugins_digest}-#{current_hostname}" + Digest::SHA1.hexdigest "default-#{Stylesheet::Manager.fs_asset_cachebuster}-#{plugins_digest}-#{current_hostname}" end def color_scheme_digest @@ -248,9 +248,9 @@ class Stylesheet::Manager::Builder digest_string = "#{current_hostname}-" if cs || categories_updated > 0 theme_color_defs = resolve_baked_field(:common, :color_definitions) - digest_string += "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{categories_updated}-#{fonts}" + digest_string += "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{categories_updated}-#{fonts}" else - digest_string += "defaults-#{Stylesheet::Manager.last_file_updated}-#{fonts}" + digest_string += "defaults-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}" if cdn_url = GlobalSetting.cdn_url digest_string += "-#{cdn_url}" diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake index e69e192108..9082035725 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -64,6 +64,7 @@ task 'assets:precompile:css' => 'environment' do STDERR.puts "-------------" STDERR.puts "Compiling CSS for #{db} #{Time.zone.now}" begin + Stylesheet::Manager.recalculate_fs_asset_cachebuster! Stylesheet::Manager.precompile_css if db == "default" Stylesheet::Manager.precompile_theme_css rescue PG::UndefinedColumn, ActiveModel::MissingAttributeError, NoMethodError => e diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index 861b6d998a..7b1b02eeef 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -160,7 +160,8 @@ def insert_user_options auto_track_topics_after_msecs, notification_level_when_replying, like_notification_frequency, - skip_new_user_tips + skip_new_user_tips, + hide_profile_and_presence ) SELECT u.id , #{SiteSetting.default_email_mailing_list_mode} @@ -181,6 +182,7 @@ def insert_user_options , #{SiteSetting.default_other_notification_level_when_replying} , #{SiteSetting.default_other_like_notification_frequency} , #{SiteSetting.default_other_skip_new_user_tips} + , #{SiteSetting.default_hide_profile_and_presence} FROM users u LEFT JOIN user_options uo ON uo.user_id = u.id WHERE uo.user_id IS NULL diff --git a/lib/tasks/plugin.rake b/lib/tasks/plugin.rake index e9b6218615..ddff652498 100644 --- a/lib/tasks/plugin.rake +++ b/lib/tasks/plugin.rake @@ -174,7 +174,9 @@ def spec(plugin, parallel: false) params << "--seed #{ENV['RSPEC_SEED']}" if Integer(ENV['RSPEC_SEED'], exception: false) ruby = `which ruby`.strip - files = Dir.glob("./plugins/#{plugin}/spec/**/*_spec.rb").sort + # reject system specs as they are slow and need dedicated setup + files = + Dir.glob("./plugins/#{plugin}/spec/**/*_spec.rb").reject { |f| f.include?("spec/system/") }.sort if files.length > 0 cmd = parallel ? "bin/turbo_rspec" : "bin/rspec" sh "LOAD_PLUGINS=1 #{cmd} #{files.join(' ')} #{params.join(' ')}" diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake index e3225f8ada..0304e80caa 100644 --- a/lib/tasks/posts.rake +++ b/lib/tasks/posts.rake @@ -506,7 +506,7 @@ def recover_uploads_from_index(path) db = RailsMultisite::ConnectionManagement.current_db cdn_path = SiteSetting.cdn_path("/uploads/#{db}").sub(/https?:/, "") - Post.where("cooked LIKE '%#{cdn_path}%'").each do |post| + Post.where("cooked LIKE ?", "%#{cdn_path}%").each do |post| regex = Regexp.new("((https?:)?#{Regexp.escape(cdn_path)}[^,;\\]\\>\\t\\n\\s)\"\']+)") uploads = [] post.raw.scan(regex).each do |match| @@ -663,9 +663,10 @@ def correct_inline_uploads verbose = ENV["VERBOSE"] scope = Post.joins(:upload_references).distinct("posts.id") - .where(<<~SQL) - raw LIKE '%/uploads/#{RailsMultisite::ConnectionManagement.current_db}/original/%' - SQL + .where( + "raw LIKE ?", + "%/uploads/#{RailsMultisite::ConnectionManagement.current_db}/original/%", + ) affected_posts_count = scope.count fixed_count = 0 diff --git a/lib/tasks/s3.rake b/lib/tasks/s3.rake index b8aa43eee4..d2a21fed37 100644 --- a/lib/tasks/s3.rake +++ b/lib/tasks/s3.rake @@ -10,10 +10,18 @@ def gzip_s3_path(path) "#{path[0..-ext.length]}gz#{ext}" end +def existing_assets + @existing_assets ||= Set.new(helper.list("assets/").map(&:key)) +end + +def prefix_s3_path(path) + path = File.join(helper.s3_bucket_folder_path, path) if helper.s3_bucket_folder_path + path +end + def should_skip?(path) return false if ENV['FORCE_S3_UPLOADS'] - @existing_assets ||= Set.new(helper.list("assets/").map(&:key)) - @existing_assets.include?(path) + existing_assets.include?(prefix_s3_path(path)) end def upload(path, remote_path, content_type, content_encoding = nil) @@ -196,26 +204,37 @@ end task 's3:expire_missing_assets' => :environment do ensure_s3_configured! - count = 0 - keep = 0 + puts "Checking for stale S3 assets..." - in_manifest = asset_paths + assets_to_delete = existing_assets.dup - puts "Ensuring AWS assets are tagged correctly for removal" - helper.list('assets/').each do |f| - if !in_manifest.include?(f.key) - helper.tag_file(f.key, old: true) - count += 1 - else - # ensure we do not delete this by mistake - helper.tag_file(f.key, {}) - keep += 1 + # Check that all current assets are uploaded, and remove them from the to_delete list + asset_paths.each do |current_asset_path| + uploaded = assets_to_delete.delete?(prefix_s3_path(current_asset_path)) + if !uploaded + puts "A current asset does not exist on S3 (#{current_asset_path}). Aborting cleanup task." + exit 1 end end - puts "#{count} assets were flagged for removal in 10 days (#{keep} assets will be retained)" + if assets_to_delete.size > 0 + puts "Found #{assets_to_delete.size} assets to delete..." - puts "Ensuring AWS rule exists for purging old assets" - helper.update_lifecycle("delete_old_assets", 10, tag: { key: 'old', value: 'true' }) + assets_to_delete.each do |to_delete| + if !to_delete.start_with?(prefix_s3_path("assets/")) + # Sanity check, this should never happen + raise "Attempted to delete a non-/asset S3 path (#{to_delete}). Aborting" + end + end + assets_to_delete.each_slice(500) do |slice| + message = "Deleting #{slice.size} assets...\n" + message += slice.join("\n").indent(2) + puts message + helper.delete_objects(slice) + puts "... done" + end + else + puts "No stale assets found" + end end diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake index b461e54b8b..289823643f 100644 --- a/lib/tasks/themes.rake +++ b/lib/tasks/themes.rake @@ -52,6 +52,12 @@ task "themes:install" => :environment do |task, args| end end +desc "Install themes & theme components from an archive" +task "themes:install:archive" => :environment do |task, args| + filename = ENV["THEME_ARCHIVE"] + RemoteTheme.update_zipped_theme(filename, File.basename(filename)) +end + def update_themes Theme.includes(:remote_theme).where(enabled: true, auto_update: true).find_each do |theme| begin diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index bfc2806faa..702f684625 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -31,7 +31,7 @@ def gather_uploads puts "", "Gathering uploads for '#{current_db}'...", "" Upload.where("url ~ '^\/uploads\/'") - .where("url !~ '^\/uploads\/#{current_db}'") + .where("url !~ ?", "^\/uploads\/#{current_db}") .find_each do |upload| begin old_db = upload.url[/^\/uploads\/([^\/]+)\//, 1] @@ -1076,3 +1076,154 @@ task "uploads:fix_missing_s3" => :environment do end end end + +# Supported ENV arguments: +# +# VERBOSE=1 +# Shows debug information. +# +# INTERACTIVE=1 +# Shows debug information and pauses for input on issues. +# +# WORKER_ID/WORKER_COUNT +# When running the script on a single forum in multiple terminals. +# For example, if you want 4 concurrent scripts use WORKER_COUNT=4 +# and WORKER_ID from 0 to 3. +# +# START_ID +# Skip uploads with id lower than START_ID. +task "uploads:downsize" => :environment do + min_image_pixels = 500_000 # 0.5 megapixels + default_image_pixels = 1_000_000 # 1 megapixel + + max_image_pixels = [ + ARGV[0]&.to_i || default_image_pixels, + min_image_pixels + ].max + + ENV["VERBOSE"] = "1" if ENV["INTERACTIVE"] + + def log(*args) + puts(*args) if ENV["VERBOSE"] + end + + puts "", "Downsizing images to no more than #{max_image_pixels} pixels" + + dimensions_count = 0 + downsized_count = 0 + + scope = Upload + .by_users + .with_no_non_post_relations + .where("LOWER(extension) IN ('jpg', 'jpeg', 'gif', 'png')") + + scope = scope.where(<<-SQL, max_image_pixels) + COALESCE(width, 0) = 0 OR + COALESCE(height, 0) = 0 OR + COALESCE(thumbnail_width, 0) = 0 OR + COALESCE(thumbnail_height, 0) = 0 OR + width * height > ? + SQL + + if ENV["WORKER_ID"] && ENV["WORKER_COUNT"] + scope = scope.where("uploads.id % ? = ?", ENV["WORKER_COUNT"], ENV["WORKER_ID"]) + end + + if ENV["START_ID"] + scope = scope.where("uploads.id >= ?", ENV["START_ID"]) + end + + skipped = 0 + total_count = scope.count + puts "Uploads to process: #{total_count}" + + scope.find_each.with_index do |upload, index| + progress = (index * 100.0 / total_count).round(1) + + log "\n" + print "\r#{progress}% Fixed dimensions: #{dimensions_count} Downsized: #{downsized_count} Skipped: #{skipped} (upload id: #{upload.id})" + log "\n" + + path = if upload.local? + Discourse.store.path_for(upload) + else + (Discourse.store.download(upload, max_file_size_kb: 100.megabytes) rescue nil)&.path + end + + unless path + log "No image path" + skipped += 1 + next + end + + begin + w, h = FastImage.size(path, raise_on_failure: true) + rescue FastImage::UnknownImageType + log "Unknown image type" + skipped += 1 + next + rescue FastImage::SizeNotFound + log "Size not found" + skipped += 1 + next + end + + if !w || !h + log "Invalid image dimensions" + skipped += 1 + next + end + + ww, hh = ImageSizer.resize(w, h) + + if w == 0 || h == 0 || ww == 0 || hh == 0 + log "Invalid image dimensions" + skipped += 1 + next + end + + upload.attributes = { + width: w, + height: h, + thumbnail_width: ww, + thumbnail_height: hh, + filesize: File.size(path) + } + + if upload.changed? + log "Correcting the upload dimensions" + log "Before: #{upload.width_was}x#{upload.height_was} #{upload.thumbnail_width_was}x#{upload.thumbnail_height_was} (#{upload.filesize_was})" + log "After: #{w}x#{h} #{ww}x#{hh} (#{upload.filesize})" + + dimensions_count += 1 + + # Don't validate the size - max image size setting might have + # changed since the file was uploaded, so this could fail + upload.validate_file_size = false + upload.save! + end + + if w * h < max_image_pixels + log "Image size within allowed range" + skipped += 1 + next + end + + result = ShrinkUploadedImage.new( + upload: upload, + path: path, + max_pixels: max_image_pixels, + verbose: ENV["VERBOSE"], + interactive: ENV["INTERACTIVE"] + ).perform + + if result + downsized_count += 1 + else + skipped += 1 + end + end + + STDIN.beep + puts "", "Done", Time.zone.now +end diff --git a/lib/theme_store/git_importer.rb b/lib/theme_store/git_importer.rb index c386fea5ee..93878b4e21 100644 --- a/lib/theme_store/git_importer.rb +++ b/lib/theme_store/git_importer.rb @@ -117,36 +117,44 @@ class ThemeStore::GitImporter end def clone_http! + uris = [@uri] + begin - @uri = FinalDestination.resolve(@uri.to_s) + resolved_uri = FinalDestination.resolve(@uri.to_s) + if resolved_uri && resolved_uri != @uri + uris.unshift(resolved_uri) + end rescue - raise_import_error! + # If this fails, we can stil attempt to clone using the original URI end - @url = @uri.to_s + uris.each do |uri| + @uri = uri + @url = @uri.to_s - unless ["http", "https"].include?(@uri.scheme) - raise_import_error! + unless ["http", "https"].include?(@uri.scheme) + raise_import_error! + end + + addresses = FinalDestination::SSRFDetector.lookup_and_filter_ips(@uri.host) + + unless addresses.empty? + env = { "GIT_TERMINAL_PROMPT" => "0" } + + args = clone_args( + "http.followRedirects" => "false", + "http.curloptResolve" => "#{@uri.host}:#{@uri.port}:#{addresses.join(',')}", + ) + + begin + Discourse::Utils.execute_command(env, *args, timeout: COMMAND_TIMEOUT_SECONDS) + return + rescue RuntimeError + end + end end - addresses = FinalDestination::SSRFDetector.lookup_and_filter_ips(@uri.host) - - if addresses.empty? - raise_import_error! - end - - env = { "GIT_TERMINAL_PROMPT" => "0" } - - args = clone_args( - "http.followRedirects" => "false", - "http.curloptResolve" => "#{@uri.host}:#{@uri.port}:#{addresses.join(',')}", - ) - - begin - Discourse::Utils.execute_command(env, *args, timeout: COMMAND_TIMEOUT_SECONDS) - rescue RuntimeError - raise_import_error! - end + raise_import_error! end def clone_ssh! diff --git a/lib/topic_query.rb b/lib/topic_query.rb index ec75d18206..c5b5b84b94 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -311,9 +311,9 @@ class TopicQuery def list_group_topics(group) list = default_results.where(" topics.user_id IN ( - SELECT user_id FROM group_users gu WHERE gu.group_id = #{group.id.to_i} + SELECT user_id FROM group_users gu WHERE gu.group_id = ? ) - ") + ", group.id.to_i) create_list(:group_topics, {}, list) end diff --git a/lib/topic_query/private_message_lists.rb b/lib/topic_query/private_message_lists.rb index 395b54fad5..c8883169a9 100644 --- a/lib/topic_query/private_message_lists.rb +++ b/lib/topic_query/private_message_lists.rb @@ -118,20 +118,20 @@ class TopicQuery result = result.joins("INNER JOIN group_users gu ON gu.group_id = tag.group_id AND gu.user_id = #{user.id.to_i}") end elsif type == :user - result = result.where("topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = #{user.id.to_i})") + result = result.where("topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = ?)", user.id.to_i) elsif type == :all group_ids = group_with_messages_ids(user) result = if group_ids.present? - result.where(<<~SQL) + result.where(<<~SQL, user.id.to_i, group_ids) topics.id IN ( SELECT topic_id FROM topic_allowed_users - WHERE user_id = #{user.id.to_i} + WHERE user_id = ? UNION ALL SELECT topic_id FROM topic_allowed_groups - WHERE group_id IN (#{group_ids.join(",")}) + WHERE group_id IN (?) ) SQL else @@ -259,10 +259,10 @@ class TopicQuery end def have_posts_from_others(list, user) - list.where(<<~SQL) + list.where(<<~SQL, user.id.to_i) NOT ( topics.participant_count = 1 - AND topics.user_id = #{user.id.to_i} + AND topics.user_id = ? AND topics.moderator_posts_count = 0 ) SQL diff --git a/lib/topic_query_params.rb b/lib/topic_query_params.rb index 7081513c3f..5c0175a8ba 100644 --- a/lib/topic_query_params.rb +++ b/lib/topic_query_params.rb @@ -30,7 +30,6 @@ module TopicQueryParams def hide_welcome_topic? return false if !SiteSetting.bootstrap_mode_enabled - return false if @guardian.is_admin? Site.welcome_topic_exists_and_is_not_edited? end end diff --git a/lib/validators/upload_validator.rb b/lib/validators/upload_validator.rb index 7dd4f75781..5fb084c835 100644 --- a/lib/validators/upload_validator.rb +++ b/lib/validators/upload_validator.rb @@ -2,10 +2,7 @@ require "file_helper" -module Validators; end - class UploadValidator < ActiveModel::Validator - def validate(upload) # staff can upload any file in PM if (upload.for_private_message && SiteSetting.allow_staff_to_upload_any_file_in_pm) @@ -141,6 +138,8 @@ class UploadValidator < ActiveModel::Validator end def maximum_file_size(upload, type) + return if !upload.validate_file_size + max_size_kb = if upload.for_export SiteSetting.max_export_file_size_kb else @@ -157,5 +156,4 @@ class UploadValidator < ActiveModel::Validator upload.errors.add(:filesize, message) end end - end diff --git a/lib/version.rb b/lib/version.rb index d3777983db..922ef2e01c 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -10,7 +10,7 @@ module Discourse MAJOR = 2 MINOR = 9 TINY = 0 - PRE = 'beta11' + PRE = 'beta12' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/package.json b/package.json index 5db51f607f..67554baeb0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@highlightjs/cdn-assets": "^11.6.0", "@json-editor/json-editor": "^2.6.1", "ace-builds": "1.4.13", - "bootbox": "3.2.0", "chart.js": "3.5.1", "chartjs-plugin-datalabels": "^2.0.0", "diffhtml": "^1.0.0-beta.20", @@ -29,10 +28,10 @@ "workbox-sw": "^4.3.1" }, "devDependencies": { - "@arkweid/lefthook": "^0.7.7", "@mixer/parallel-prettier": "^2.0.1", "chrome-launcher": "^0.15.0", "chrome-remote-interface": "^0.31.2", + "lefthook": "^1.2.0", "puppeteer-core": "^13.7.0" }, "scripts": { diff --git a/plugins/chat/README.md b/plugins/chat/README.md new file mode 100644 index 0000000000..fc0b204240 --- /dev/null +++ b/plugins/chat/README.md @@ -0,0 +1,54 @@ +:warning: This plugin is still in active development and may change frequently + +## Documentation + +The Discourse Chat plugin adds chat functionality to your Discourse so it can natively support both long-form and short-form communication needs of your online community. + +For documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881) + +## Plugin API + +### registerChatComposerButton + +#### Usage + +```javascript +api.registerChatComposerButton({ id: "foo", ... }); +``` + +#### Options + +Every option accepts a `value` or a `function`, when passing a function `this` will be the `chat-composer` component instance. Example of an option using a function: + +```javascript +api.registerChatComposerButton({ + id: "foo", + displayed() { + return this.site.mobileView && this.canAttachUploads; + }, +}); +``` + +##### Required + +- `id` unique, used to identify your button, eg: "gifs" +- `action` callback when the button is pressed, can be an action name or an anonymous function, eg: "onFooClicked" or `() => { console.log("clicked") }` + +A button requires at least an icon or a label: + +- `icon`, eg: "times" +- `label`, text displayed on the button, a translatable key, eg: "foo.bar" +- `translatedLabel`, text displayed on the button, a string, eg: "Add gifs" + +##### Optional + +- `position`, can be "inline" or "dropdown", defaults to "inline" +- `title`, title attribute of the button, a translatable key, eg: "foo.bar" +- `translatedTitle`, title attribute of the button, a string, eg: "Add gifs" +- `ariaLabel`, aria-label attribute of the button, a translatable key, eg: "foo.bar" +- `translatedAriaLabel`, aria-label attribute of the button, a string, eg: "Add gifs" +- `classNames`, additional names to add to the button’s class attribute, eg: ["foo", "bar"] +- `displayed`, hide/or show the button, expects a boolean +- `disabled`, sets the disabled attribute on the button, expects a boolean +- `priority`, an integer defining the order of the buttons, higher comes first, eg: `700` +- `dependentKeys`, list of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]` diff --git a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb new file mode 100644 index 0000000000..24bcd25abd --- /dev/null +++ b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Chat::AdminIncomingChatWebhooksController < Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def index + render_serialized( + { + chat_channels: ChatChannel.public_channels, + incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all, + }, + AdminChatIndexSerializer, + root: false, + ) + end + + def create + params.require(%i[name chat_channel_id]) + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel) + if webhook.save + render_serialized(webhook, IncomingChatWebhookSerializer, root: false) + else + render_json_error(webhook) + end + end + + def update + params.require(%i[incoming_chat_webhook_id name chat_channel_id]) + + webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) + raise Discourse::NotFound unless webhook + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + if webhook.update( + name: params[:name], + description: params[:description], + emoji: params[:emoji], + username: params[:username], + chat_channel: chat_channel, + ) + render json: success_json + else + render_json_error(webhook) + end + end + + def destroy + params.require(:incoming_chat_webhook_id) + + webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) + webhook.destroy if webhook + render json: success_json + end +end diff --git a/plugins/chat/app/controllers/api/category_chatables_controller.rb b/plugins/chat/app/controllers/api/category_chatables_controller.rb new file mode 100644 index 0000000000..50fe11edc7 --- /dev/null +++ b/plugins/chat/app/controllers/api/category_chatables_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Chat::Api::CategoryChatablesController < ApplicationController + def permissions + category = Category.find(params[:id]) + + if category.read_restricted? + permissions = + Group + .joins(:category_groups) + .where(category_groups: { category_id: category.id }) + .joins("LEFT OUTER JOIN group_users ON groups.id = group_users.group_id") + .group("groups.id", "groups.name") + .pluck("groups.name", "COUNT(group_users.user_id)") + + group_names = permissions.map { |p| "@#{p[0]}" } + members_count = permissions.sum { |p| p[1].to_i } + + permissions_result = { + allowed_groups: group_names, + members_count: members_count, + private: true, + } + else + everyone_group = Group.find(Group::AUTO_GROUPS[:everyone]) + + permissions_result = { allowed_groups: ["@#{everyone_group.name}"], private: false } + end + + render json: permissions_result + end +end diff --git a/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb b/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb new file mode 100644 index 0000000000..727811c9ca --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Chat::Api::ChatChannelMembershipsController < Chat::Api::ChatChannelsController + def index + channel = find_chat_channel + + offset = (params[:offset] || 0).to_i + limit = (params[:limit] || 50).to_i.clamp(1, 50) + + memberships = + ChatChannelMembershipsQuery.call( + channel, + offset: offset, + limit: limit, + username: params[:username], + ) + + render_serialized(memberships, UserChatChannelMembershipSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb b/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb new file mode 100644 index 0000000000..57c0055424 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] + +class Chat::Api::ChatChannelNotificationsSettingsController < Chat::Api::ChatChannelsController + def update + settings_params = params.permit(MEMBERSHIP_EDITABLE_PARAMS) + membership = find_membership + membership.update!(settings_params.to_h) + render_serialized(membership, UserChatChannelMembershipSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/chat_channels_controller.rb b/plugins/chat/app/controllers/api/chat_channels_controller.rb new file mode 100644 index 0000000000..b073936cf4 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channels_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +CHAT_CHANNEL_EDITABLE_PARAMS = %i[name description] +CATEGORY_CHAT_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users] + +class Chat::Api::ChatChannelsController < Chat::Api + def index + options = { status: params[:status] ? ChatChannel.statuses[params[:status]] : nil }.merge( + params.permit(:filter, :limit, :offset), + ).symbolize_keys! + + memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options) + + serialized_channels = + channels.map do |channel| + ChatChannelSerializer.new( + channel, + scope: Guardian.new(current_user), + membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, + ) + end + render json: serialized_channels, root: false + end + + def update + guardian.ensure_can_edit_chat_channel! + + chat_channel = find_chat_channel + + if chat_channel.direct_message_channel? + raise Discourse::InvalidParameters.new( + I18n.t("chat.errors.cant_update_direct_message_channel"), + ) + end + + params_to_edit = editable_params(params, chat_channel) + params_to_edit.each { |k, v| params_to_edit[k] = nil if params_to_edit[k].blank? } + + if ActiveRecord::Type::Boolean.new.deserialize(params_to_edit[:auto_join_users]) + auto_join_limiter(chat_channel).performed! + end + + chat_channel.update!(params_to_edit) + + ChatPublisher.publish_chat_channel_edit(chat_channel, current_user) + + if chat_channel.category_channel? && chat_channel.auto_join_users + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + end + + render_serialized( + chat_channel, + ChatChannelSerializer, + root: false, + membership: chat_channel.membership_for(current_user), + ) + end + + private + + def find_chat_channel + chat_channel = ChatChannel.find(params.require(:chat_channel_id)) + guardian.ensure_can_see_chat_channel!(chat_channel) + chat_channel + end + + def find_membership + chat_channel = find_chat_channel + membership = Chat::ChatChannelMembershipManager.new(chat_channel).find_for_user(current_user) + raise Discourse::NotFound if membership.blank? + membership + end + + def auto_join_limiter(chat_channel) + RateLimiter.new( + current_user, + "auto_join_users_channel_#{chat_channel.id}", + 1, + 3.minutes, + apply_limit_to_staff: true, + ) + end + + def editable_params(params, chat_channel) + permitted_params = CHAT_CHANNEL_EDITABLE_PARAMS + + permitted_params += CATEGORY_CHAT_CHANNEL_EDITABLE_PARAMS if chat_channel.category_channel? + + params.permit(*permitted_params) + end +end diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb new file mode 100644 index 0000000000..eaf3db9be5 --- /dev/null +++ b/plugins/chat/app/controllers/api_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Chat::Api < Chat::ChatBaseController + before_action :ensure_logged_in + before_action :ensure_can_chat + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat!(current_user) + end +end diff --git a/plugins/chat/app/controllers/chat_base_controller.rb b/plugins/chat/app/controllers/chat_base_controller.rb new file mode 100644 index 0000000000..14cc69f271 --- /dev/null +++ b/plugins/chat/app/controllers/chat_base_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Chat::ChatBaseController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_can_chat + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat!(current_user) + end + + def set_channel_and_chatable_with_access_check(chat_channel_id: nil) + params.require(:chat_channel_id) if chat_channel_id.blank? + id_or_name = chat_channel_id || params[:chat_channel_id] + @chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian) + @chatable = @chat_channel.chatable + end +end diff --git a/plugins/chat/app/controllers/chat_channels_controller.rb b/plugins/chat/app/controllers/chat_channels_controller.rb new file mode 100644 index 0000000000..cecc5b2f1f --- /dev/null +++ b/plugins/chat/app/controllers/chat_channels_controller.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +class Chat::ChatChannelsController < Chat::ChatBaseController + before_action :set_channel_and_chatable_with_access_check, except: %i[index create search] + + def index + structured = Chat::ChatChannelFetcher.structured(guardian) + render_serialized(structured, ChatChannelIndexSerializer, root: false) + end + + def show + render_serialized( + @chat_channel, + ChatChannelSerializer, + membership: @chat_channel.membership_for(current_user), + root: false, + ) + end + + def follow + membership = @chat_channel.add(current_user) + + render_serialized(@chat_channel, ChatChannelSerializer, membership: membership, root: false) + end + + def unfollow + membership = @chat_channel.remove(current_user) + + render_serialized(@chat_channel, ChatChannelSerializer, membership: membership, root: false) + end + + def create + params.require(%i[id name]) + guardian.ensure_can_create_chat_channel! + if params[:name].length > SiteSetting.max_topic_title_length + raise Discourse::InvalidParameters.new(:name) + end + + exists = + ChatChannel.exists?(chatable_type: "Category", chatable_id: params[:id], name: params[:name]) + if exists + raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category")) + end + + chatable = Category.find_by(id: params[:id]) + raise Discourse::NotFound unless chatable + + auto_join_users = ActiveRecord::Type::Boolean.new.deserialize(params[:auto_join_users]) || false + + chat_channel = + chatable.create_chat_channel!( + name: params[:name], + description: params[:description], + user_count: 1, + auto_join_users: auto_join_users, + ) + chat_channel.user_chat_channel_memberships.create!(user: current_user, following: true) + + if chat_channel.auto_join_users + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + end + + render_serialized( + chat_channel, + ChatChannelSerializer, + membership: chat_channel.membership_for(current_user), + ) + end + + def edit + guardian.ensure_can_edit_chat_channel! + if (params[:name]&.length || 0) > SiteSetting.max_topic_title_length + raise Discourse::InvalidParameters.new(:name) + end + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound unless chat_channel + + chat_channel.name = params[:name] if params[:name] + chat_channel.description = params[:description] if params[:description] + chat_channel.save! + + ChatPublisher.publish_chat_channel_edit(chat_channel, current_user) + render_serialized( + chat_channel, + ChatChannelSerializer, + membership: chat_channel.membership_for(current_user), + ) + end + + def search + params.require(:filter) + filter = params[:filter]&.downcase + memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + public_channels = + Chat::ChatChannelFetcher.secured_public_channels( + guardian, + memberships, + filter: filter, + status: :open, + ) + + users = User.joins(:user_option).where.not(id: current_user.id) + if !Chat.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) + users = + users + .joins(:groups) + .where(groups: { id: Chat.allowed_group_ids }) + .or(users.joins(:groups).staff) + end + + users = users.where(user_option: { chat_enabled: true }) + like_filter = "%#{filter}%" + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + users = users.where("users.username_lower ILIKE ?", like_filter) + else + users = + users.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + like_filter, + like_filter, + ) + end + + users = users.limit(25).uniq + + direct_message_channels = + ( + if users.count > 0 + ChatChannel + .includes(chatable: :users) + .joins(direct_message: :direct_message_users) + .group(1) + .having( + "ARRAY[?] <@ ARRAY_AGG(user_id) AND ARRAY[?] && ARRAY_AGG(user_id)", + [current_user.id], + users.map(&:id), + ) + else + [] + end + ) + + user_ids_with_channel = [] + direct_message_channels.each do |dm_channel| + user_ids = dm_channel.chatable.users.map(&:id) + user_ids_with_channel.concat(user_ids) if user_ids.count < 3 + end + + users_without_channel = users.filter { |u| !user_ids_with_channel.include?(u.id) } + + if current_user.username.downcase.start_with?(filter) + # We filtered out the current user for the query earlier, but check to see + # if they should be included, and add. + users_without_channel << current_user + end + + render_serialized( + { + public_channels: public_channels, + direct_message_channels: direct_message_channels, + users: users_without_channel, + memberships: memberships, + }, + ChatChannelSearchSerializer, + root: false, + ) + end + + def archive + params.require(:type) + + if params[:type] == "newTopic" ? params[:title].blank? : params[:topic_id].blank? + raise Discourse::InvalidParameters + end + + if !guardian.can_change_channel_status?(@chat_channel, :read_only) + raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived")) + end + + Chat::ChatChannelArchiveService.begin_archive_process( + chat_channel: @chat_channel, + acting_user: current_user, + topic_params: { + topic_id: params[:topic_id], + topic_title: params[:title], + category_id: params[:category_id], + tags: params[:tags], + }, + ) + + render json: success_json + end + + def retry_archive + guardian.ensure_can_change_channel_status!(@chat_channel, :archived) + + archive = @chat_channel.chat_channel_archive + raise Discourse::NotFound if archive.blank? + raise Discourse::InvalidAccess if !archive.failed? + + Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: @chat_channel) + + render json: success_json + end + + def change_status + params.require(:status) + + # we only want to use this endpoint for open/closed status changes, + # the others are more "special" and are handled by the archive endpoint + if !ChatChannel.statuses.keys.include?(params[:status]) || params[:status] == "read_only" || + params[:status] == "archive" + raise Discourse::InvalidParameters + end + + guardian.ensure_can_change_channel_status!(@chat_channel, params[:status].to_sym) + @chat_channel.public_send("#{params[:status]}!", current_user) + + render json: success_json + end + + def destroy + params.require(:channel_name_confirmation) + + guardian.ensure_can_delete_chat_channel! + + if @chat_channel.title(current_user).downcase != params[:channel_name_confirmation].downcase + raise Discourse::InvalidParameters.new(:channel_name_confirmation) + end + + begin + ChatChannel.transaction do + @chat_channel.trash!(current_user) + StaffActionLogger.new(current_user).log_custom( + "chat_channel_delete", + { + chat_channel_id: @chat_channel.id, + chat_channel_name: @chat_channel.title(current_user), + }, + ) + end + rescue ActiveRecord::Rollback + return render_json_error(I18n.t("chat.errors.delete_channel_failed")) + end + + Jobs.enqueue(:chat_channel_delete, { chat_channel_id: @chat_channel.id }) + render json: success_json + end +end diff --git a/plugins/chat/app/controllers/chat_controller.rb b/plugins/chat/app/controllers/chat_controller.rb new file mode 100644 index 0000000000..3f78f15b8e --- /dev/null +++ b/plugins/chat/app/controllers/chat_controller.rb @@ -0,0 +1,501 @@ +# frozen_string_literal: true + +class Chat::ChatController < Chat::ChatBaseController + PAST_MESSAGE_LIMIT = 20 + FUTURE_MESSAGE_LIMIT = 40 + PAST = "past" + FUTURE = "future" + CHAT_DIRECTIONS = [PAST, FUTURE] + + # Other endpoints use set_channel_and_chatable_with_access_check, but + # these endpoints require a standalone find because they need to be + # able to get deleted channels and recover them. + before_action :find_chatable, only: %i[enable_chat disable_chat] + before_action :find_chat_message, + only: %i[delete restore lookup_message edit_message rebake message_link] + before_action :set_channel_and_chatable_with_access_check, + except: %i[ + respond + enable_chat + disable_chat + message_link + lookup_message + set_user_chat_status + dismiss_retention_reminder + flag + ] + + def respond + render + end + + def enable_chat + chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) + + guardian.ensure_can_see_chat_channel!(chat_channel) if chat_channel + + if chat_channel && chat_channel.trashed? + chat_channel.recover! + elsif chat_channel + return render_json_error I18n.t("chat.already_enabled") + else + chat_channel = @chatable.chat_channel + guardian.ensure_can_see_chat_channel!(chat_channel) + end + + success = chat_channel.save + if success && chat_channel.chatable_has_custom_fields? + @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true + @chatable.save! + end + + if success + membership = Chat::ChatChannelMembershipManager.new(channel).follow(user) + render_serialized(chat_channel, ChatChannelSerializer, membership: membership) + else + render_json_error(chat_channel) + end + + Chat::ChatChannelMembershipManager.new(channel).follow(user) + end + + def disable_chat + chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) + guardian.ensure_can_see_chat_channel!(chat_channel) + return render json: success_json if chat_channel.trashed? + chat_channel.trash!(current_user) + + success = chat_channel.save + if success + if chat_channel.chatable_has_custom_fields? + @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) + @chatable.save! + end + + render json: success_json + else + render_json_error(chat_channel) + end + end + + def create_message + raise Discourse::InvalidAccess if current_user.silenced? + + Chat::ChatMessageRateLimiter.run!(current_user) + + @user_chat_channel_membership = + Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::InvalidAccess unless @user_chat_channel_membership + + reply_to_msg_id = params[:in_reply_to_id] + if reply_to_msg_id + rm = ChatMessage.find(reply_to_msg_id) + raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id + end + + content = params[:message] + + chat_message_creator = + Chat::ChatMessageCreator.create( + chat_channel: @chat_channel, + user: current_user, + in_reply_to_id: reply_to_msg_id, + content: content, + staged_id: params[:staged_id], + upload_ids: params[:upload_ids], + ) + + return render_json_error(chat_message_creator.error) if chat_message_creator.failed? + + @chat_channel.touch(:last_message_sent_at) + @user_chat_channel_membership.update(last_read_message_id: chat_message_creator.chat_message.id) + + if @chat_channel.direct_message_channel? + # If any of the channel users is ignoring, muting, or preventing DMs from + # the current user then we shold not auto-follow the channel once again or + # publish the new channel. + user_ids_allowing_communication = + UserCommScreener.new( + acting_user: current_user, + target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), + ).allowing_actor_communication + + if user_ids_allowing_communication.any? + @chat_channel + .user_chat_channel_memberships + .where(user_id: user_ids_allowing_communication) + .update_all(following: true) + ChatPublisher.publish_new_channel( + @chat_channel, + @chat_channel.chatable.users.where(id: user_ids_allowing_communication), + ) + end + end + + ChatPublisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + chat_message_creator.chat_message.id, + ) + render json: success_json + end + + def edit_message + chat_message_updater = + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: @message, + new_content: params[:new_message], + upload_ids: params[:upload_ids] || [], + ) + + return render_json_error(chat_message_updater.error) if chat_message_updater.failed? + + render json: success_json + end + + def update_user_last_read + membership = + Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::NotFound if membership.nil? + + if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id + raise Discourse::InvalidParameters.new(:message_id) + end + + unless ChatMessage.with_deleted.exists?( + chat_channel_id: @chat_channel.id, + id: params[:message_id], + ) + raise Discourse::NotFound + end + + membership.update!(last_read_message_id: params[:message_id]) + + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: current_user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", params[:message_id].to_i) + .where("chat_messages.chat_channel_id = ?", @chat_channel.id) + .update_all(read: true) + + ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id]) + + render json: success_json + end + + def messages + page_size = params[:page_size]&.to_i || 1000 + direction = params[:direction].to_s + message_id = params[:message_id] + if page_size > 50 || + ( + message_id.blank? ^ direction.blank? && + (direction.present? && !CHAT_DIRECTIONS.include?(direction)) + ) + raise Discourse::InvalidParameters + end + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + if message_id.present? + condition = direction == PAST ? "<" : ">" + messages = messages.where("id #{condition} ?", message_id.to_i) + end + + # NOTE: This order is reversed when we return the ChatView below if the direction + # is not FUTURE. + order = direction == FUTURE ? "ASC" : "DESC" + messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a + + can_load_more_past = nil + can_load_more_future = nil + + if direction == FUTURE + can_load_more_future = messages.size == page_size + elsif direction == PAST + can_load_more_past = messages.size == page_size + else + # When direction is blank, we'll return the latest messages. + can_load_more_future = false + can_load_more_past = messages.size == page_size + end + + chat_view = + ChatView.new( + chat_channel: @chat_channel, + chat_messages: direction == FUTURE ? messages : messages.reverse, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, ChatViewSerializer, root: false) + end + + def react + params.require(%i[message_id emoji react_action]) + guardian.ensure_can_react! + + Chat::ChatMessageReactor.new(current_user, @chat_channel).react!( + message_id: params[:message_id], + react_action: params[:react_action].to_sym, + emoji: params[:emoji], + ) + + render json: success_json + end + + def delete + guardian.ensure_can_delete_chat!(@message, @chatable) + + updated = @message.trash!(current_user) + if updated + ChatPublisher.publish_delete!(@chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + end + + def restore + chat_channel = @message.chat_channel + guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) + updated = @message.recover! + if updated + ChatPublisher.publish_restore!(chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + end + + def rebake + guardian.ensure_can_rebake_chat_message!(@message) + @message.rebake!(invalidate_oneboxes: true) + render json: success_json + end + + def message_link + return render_404 if @message.blank? || @message.deleted_at.present? + return render_404 if @message.chat_channel.blank? + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + render json: + success_json.merge( + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(current_user), + ) + end + + def lookup_message + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + past_messages = + messages + .where("created_at < ?", @message.created_at) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + + future_messages = + messages + .where("created_at > ?", @message.created_at) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + + can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT + messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) + chat_view = + ChatView.new( + chat_channel: @chat_channel, + chat_messages: messages, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, ChatViewSerializer, root: false) + end + + def set_user_chat_status + params.require(:chat_enabled) + + current_user.user_option.update(chat_enabled: params[:chat_enabled]) + render json: { chat_enabled: current_user.user_option.chat_enabled } + end + + def invite_users + params.require(:user_ids) + + users = + User + .includes(:groups) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .not_suspended + .where(id: params[:user_ids]) + users.each do |user| + guardian = Guardian.new(user) + if guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel) + data = { + message: "chat.invitation_notification", + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(user), + chat_channel_slug: @chat_channel.slug, + invited_by_username: current_user.username, + } + data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] + user.notifications.create( + notification_type: Notification.types[:chat_invitation], + high_priority: true, + data: data.to_json, + ) + end + end + + render json: success_json + end + + def dismiss_retention_reminder + params.require(:chatable_type) + guardian.ensure_can_chat!(current_user) + unless ChatChannel.chatable_types.include?(params[:chatable_type]) + raise Discourse::InvalidParameters + end + + field = + ( + if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type]) + :dismissed_channel_retention_reminder + else + :dismissed_dm_retention_reminder + end + ) + current_user.user_option.update(field => true) + render json: success_json + end + + def quote_messages + params.require(:message_ids) + + message_ids = params[:message_ids].map(&:to_i) + markdown = + ChatTranscriptService.new( + @chat_channel, + current_user, + messages_or_ids: message_ids, + ).generate_markdown + render json: success_json.merge(markdown: markdown) + end + + def move_messages_to_channel + params.require(:message_ids) + params.require(:destination_channel_id) + + raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(@chat_channel) + destination_channel = + Chat::ChatChannelFetcher.find_with_access_check(params[:destination_channel_id], guardian) + + begin + message_ids = params[:message_ids].map(&:to_i) + moved_messages = + Chat::MessageMover.new( + acting_user: current_user, + source_channel: @chat_channel, + message_ids: message_ids, + ).move_to_channel(destination_channel) + rescue Chat::MessageMover::NoMessagesFound, Chat::MessageMover::InvalidChannel => err + return render_json_error(err.message) + end + + render json: + success_json.merge( + destination_channel_id: destination_channel.id, + destination_channel_title: destination_channel.title(current_user), + first_moved_message_id: moved_messages.first.id, + ) + end + + def flag + RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! + + permitted_params = + params.permit( + %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], + ) + + chat_message = + ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) + + flag_type_id = permitted_params[:flag_type_id].to_i + + if !ReviewableScore.types.values.include?(flag_type_id) + raise Discourse::InvalidParameters.new(:flag_type_id) + end + + set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) + + result = + Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) + + if result[:success] + render json: success_json + else + render_json_error(result[:errors]) + end + end + + def set_draft + if params[:data].present? + ChatDraft.find_or_initialize_by(user: current_user, chat_channel_id: @chat_channel.id).update( + data: params[:data], + ) + else + ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all + end + + render json: success_json + end + + private + + def preloaded_chat_message_query + query = + ChatMessage + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(:user) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + + query = query.includes(user: :user_status) if SiteSetting.enable_user_status + + query + end + + def find_chatable + @chatable = Category.find_by(id: params[:chatable_id]) + guardian.ensure_can_moderate_chat!(@chatable) + end + + def find_chat_message + @message = preloaded_chat_message_query.with_deleted + @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id] + @message = @message.find_by(id: params[:message_id]) + raise Discourse::NotFound unless @message + end +end diff --git a/plugins/chat/app/controllers/direct_messages_controller.rb b/plugins/chat/app/controllers/direct_messages_controller.rb new file mode 100644 index 0000000000..8464b705e0 --- /dev/null +++ b/plugins/chat/app/controllers/direct_messages_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Chat::DirectMessagesController < Chat::ChatBaseController + # NOTE: For V1 of chat channel archiving and deleting we are not doing + # anything for DM channels, their behaviour will stay as is. + def create + guardian.ensure_can_chat!(current_user) + users = users_from_usernames(current_user, params) + + begin + chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) + render_serialized( + chat_channel, + ChatChannelSerializer, + root: "chat_channel", + membership: chat_channel.membership_for(current_user), + ) + rescue Chat::DirectMessageChannelCreator::NotAllowed => err + render_json_error(err.message) + end + end + + def index + guardian.ensure_can_chat!(current_user) + users = users_from_usernames(current_user, params) + + direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq) + if direct_message + chat_channel = ChatChannel.find_by(chatable: direct_message) + render_serialized( + chat_channel, + ChatChannelSerializer, + root: "chat_channel", + membership: chat_channel.membership_for(current_user), + ) + else + render body: nil, status: 404 + end + end + + private + + def users_from_usernames(current_user, params) + params.require(:usernames) + + usernames = + (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) + + users = [current_user] + other_usernames = usernames - [current_user.username] + users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? + users + end +end diff --git a/plugins/chat/app/controllers/emojis_controller.rb b/plugins/chat/app/controllers/emojis_controller.rb new file mode 100644 index 0000000000..8d895e2bd7 --- /dev/null +++ b/plugins/chat/app/controllers/emojis_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::EmojisController < Chat::ChatBaseController + def index + emojis = Emoji.all.group_by(&:group) + render json: MultiJson.dump(emojis) + end +end diff --git a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb new file mode 100644 index 0000000000..1cf4963621 --- /dev/null +++ b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class Chat::IncomingChatWebhooksController < ApplicationController + WEBHOOK_MAX_MESSAGE_LENGTH = 2000 + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 + + skip_before_action :verify_authenticity_token, :redirect_to_login_if_required + + before_action :validate_payload + + def create_message + debug_payload + + process_webhook_payload(text: params[:text], key: params[:key]) + end + + # See https://api.slack.com/reference/messaging/payload for the + # slack message payload format. For now we only support the + # text param, which we preprocess lightly to remove the slack-isms + # in the formatting. + def create_message_slack_compatible + debug_payload + + # See note in validate_payload on why this is needed + attachments = + if params[:payload].present? + payload = params[:payload] + if String === payload + payload = JSON.parse(payload) + payload.deep_symbolize_keys! + end + payload[:attachments] + else + params[:attachments] + end + + if params[:text].present? + text = Chat::SlackCompatibility.process_text(params[:text]) + else + text = Chat::SlackCompatibility.process_legacy_attachments(attachments) + end + + process_webhook_payload(text: text, key: params[:key]) + rescue JSON::ParserError + raise Discourse::InvalidParameters + end + + private + + def process_webhook_payload(text:, key:) + validate_message_length(text) + webhook = find_and_rate_limit_webhook(key) + + chat_message_creator = + Chat::ChatMessageCreator.create( + chat_channel: webhook.chat_channel, + user: Discourse.system_user, + content: text, + incoming_chat_webhook: webhook, + ) + if chat_message_creator.failed? + render_json_error(chat_message_creator.error) + else + render json: success_json + end + end + + def find_and_rate_limit_webhook(key) + webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key) + raise Discourse::NotFound unless webhook + + # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. + RateLimiter.new( + nil, + "incoming_chat_webhook_#{webhook.id}", + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, + 1.minute, + ).performed! + webhook + end + + def validate_message_length(message) + return if message.length <= WEBHOOK_MAX_MESSAGE_LENGTH + raise Discourse::InvalidParameters.new( + "Body cannot be over #{WEBHOOK_MAX_MESSAGE_LENGTH} characters", + ) + end + + def validate_payload + params.require([:key]) + + # TODO (martin) It is not clear whether the :payload key is actually + # present in the webhooks sent from OpsGenie, so once it is confirmed + # in production what we are actually getting then we can remove this. + if !params[:text] && !params[:payload] && !params[:attachments] + raise Discourse::InvalidParameters + end + end + + def debug_payload + return if !SiteSetting.chat_debug_webhook_payloads + Rails.logger.warn( + "Debugging chat webhook payload: " + + JSON.dump( + { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, + ), + ) + end +end diff --git a/plugins/chat/app/core_ext/plugin_instance.rb b/plugins/chat/app/core_ext/plugin_instance.rb new file mode 100644 index 0000000000..9e38199f2e --- /dev/null +++ b/plugins/chat/app/core_ext/plugin_instance.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) + +class Plugin::Instance + def chat + ChatPluginApiExtensions + end + + module ChatPluginApiExtensions + def self.enable_markdown_feature(name) + DiscoursePluginRegistry.chat_markdown_features << name + end + end +end diff --git a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb new file mode 100644 index 0000000000..a4a11270de --- /dev/null +++ b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Jobs + class AutoJoinChannelBatch < ::Jobs::Base + def execute(args) + return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? + start_user_id = args[:starts_at].to_i + end_user_id = args[:ends_at].to_i + + return "End is higher than start" if end_user_id < start_user_id + + channel = + ChatChannel.find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel + + category = channel.chatable + return if !category + + query_args = { + chat_channel_id: channel.id, + start: start_user_id, + end: end_user_id, + suspended_until: Time.zone.now, + last_seen_at: 3.months.ago, + channel_category: channel.chatable_id, + mode: UserChatChannelMembership.join_modes[:automatic], + } + + new_member_ids = DB.query_single(create_memberships_query(category), query_args) + + # Only do this if we are running auto-join for a single user, if we + # are doing it for many then we should do it after all batches are + # complete for the channel in Jobs::AutoManageChannelMemberships + if start_user_id == end_user_id + Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count + end + + ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) + end + + private + + def create_memberships_query(category) + query = <<~SQL + INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) + SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode + FROM users + INNER JOIN user_options uo ON uo.user_id = users.id + LEFT OUTER JOIN user_chat_channel_memberships uccm ON + uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id + SQL + + query += <<~SQL if category.read_restricted? + INNER JOIN group_users gu ON gu.user_id = users.id + LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id + SQL + + query += <<~SQL + WHERE (users.id >= :start AND users.id <= :end) AND + users.staged IS FALSE AND users.active AND + NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND + (suspended_till IS NULL OR suspended_till <= :suspended_until) AND + (last_seen_at > :last_seen_at) AND + uo.chat_enabled AND + uccm.id IS NULL + SQL + + query += <<~SQL if category.read_restricted? + AND cg.category_id = :channel_category + SQL + + query += "RETURNING user_chat_channel_memberships.user_id" + end + end +end diff --git a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb new file mode 100644 index 0000000000..6d579bc88e --- /dev/null +++ b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Jobs + class AutoManageChannelMemberships < ::Jobs::Base + def execute(args) + channel = + ChatChannel.includes(:chatable).find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel&.chatable + + processed = + UserChatChannelMembership.where( + chat_channel: channel, + following: true, + join_mode: UserChatChannelMembership.join_modes[:automatic], + ).count + + auto_join_query(channel).find_in_batches do |batch| + break if processed >= SiteSetting.max_chat_auto_joined_users + + starts_at = batch.first.query_user_id + ends_at = batch.last.query_user_id + + Jobs.enqueue( + :auto_join_channel_batch, + chat_channel_id: channel.id, + starts_at: starts_at, + ends_at: ends_at, + ) + + processed += batch.size + end + + # The Jobs::AutoJoinChannelBatch job will only do this recalculation + # if it's operating on one user, so we need to make sure we do it for + # the channel here once this job is complete. + Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count + end + + private + + def auto_join_query(channel) + category = channel.chatable + + users = + User + .real + .activated + .not_suspended + .not_staged + .distinct + .select(:id, "users.id AS query_user_id") + .where("last_seen_at > ?", 3.months.ago) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .joins(<<~SQL) + LEFT OUTER JOIN user_chat_channel_memberships uccm + ON uccm.chat_channel_id = #{channel.id} AND + uccm.user_id = users.id + SQL + .where("uccm.id IS NULL") + + if category.read_restricted? + users = + users + .joins(:group_users) + .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") + .where("cg.category_id = ?", channel.chatable_id) + end + + users + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_archive.rb b/plugins/chat/app/jobs/regular/chat_channel_archive.rb new file mode 100644 index 0000000000..c5eb878d33 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_channel_archive.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Jobs + class ChatChannelArchive < ::Jobs::Base + sidekiq_options retry: false + + def execute(args = {}) + channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id]) + + # this should not really happen, but better to do this than throw an error + if channel_archive.blank? + Rails.logger.warn( + "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", + ) + return + end + + return if channel_archive.complete? + + DistributedMutex.synchronize( + "archive_chat_channel_#{channel_archive.chat_channel_id}", + validity: 20.minutes, + ) { Chat::ChatChannelArchiveService.new(channel_archive).execute } + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_delete.rb b/plugins/chat/app/jobs/regular/chat_channel_delete.rb new file mode 100644 index 0000000000..ac89be4db9 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_channel_delete.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Jobs + class ChatChannelDelete < ::Jobs::Base + def execute(args = {}) + chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id]) + + # this should not really happen, but better to do this than throw an error + if chat_channel.blank? + Rails.logger.warn( + "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", + ) + return + end + + DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do + Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") + ChatMessage.transaction do + webhooks = IncomingChatWebhook.where(chat_channel: chat_channel) + ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all + webhooks.delete_all + end + + Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") + ChatDraft.where(chat_channel: chat_channel).delete_all + UserChatChannelMembership.where(chat_channel: chat_channel).delete_all + + Rails.logger.debug( + "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", + ) + ChatMessage.transaction do + chat_messages = ChatMessage.where(chat_channel: chat_channel) + message_ids = chat_messages.select(:id) + ChatMention.where(chat_message_id: message_ids).delete_all + ChatMessageRevision.where(chat_message_id: message_ids).delete_all + ChatMessageReaction.where(chat_message_id: message_ids).delete_all + + # if the uploads are not used anywhere else they will be deleted + # by the CleanUpUploads job in core + ChatUpload.where(chat_message_id: message_ids).delete_all + + # only the messages and the channel are Trashable, everything else gets + # permanently destroyed + chat_messages.update_all( + deleted_by_id: chat_channel.deleted_by_id, + deleted_at: Time.zone.now, + ) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb new file mode 100644 index 0000000000..d6fa48e332 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Jobs + class ChatNotifyMentioned < ::Jobs::Base + def execute(args = {}) + @chat_message = + ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + if @chat_message.nil? || + @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? + return + end + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @already_notified_user_ids = args[:already_notified_user_ids] || [] + user_ids_to_notify = args[:to_notify_ids_map] || {} + user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } + end + + private + + def get_memberships(user_ids) + query = + UserChatChannelMembership.includes(:user).where( + user_id: (user_ids - @already_notified_user_ids), + chat_channel_id: @chat_message.chat_channel_id, + ) + query = query.where(following: true) if @chat_channel.public_channel? + query + end + + def build_data_for(membership, identifier_type:) + data = { + chat_message_id: @chat_message.id, + chat_channel_id: @chat_channel.id, + mentioned_by_username: @creator.username, + is_direct_message_channel: @chat_channel.direct_message_channel?, + } + + if !@is_direct_message_channel + data[:chat_channel_title] = @chat_channel.title(membership.user) + data[:chat_channel_slug] = @chat_channel.slug + end + + return data if identifier_type == :direct_mentions + + case identifier_type + when :here_mentions + data[:identifier] = "here" + when :global_mentions + data[:identifier] = "all" + else + data[:identifier] = identifier_type if identifier_type + data[:is_group_mention] = true + end + + data + end + + def build_payload_for(membership, identifier_type:) + payload = { + notification_type: Notification.types[:chat_mention], + username: @creator.username, + tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + post_url: "#{@chat_channel.relative_url}?messageId=#{@chat_message.id}", + } + + translation_prefix = + ( + if @chat_channel.direct_message_channel? + "discourse_push_notifications.popup.direct_message_chat_mention" + else + "discourse_push_notifications.popup.chat_mention" + end + ) + + translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" + identifier_text = + case identifier_type + when :here_mentions + "@here" + when :global_mentions + "@all" + when :direct_mentions + "" + else + "@#{identifier_type}" + end + + payload[:translated_title] = I18n.t( + "#{translation_prefix}.#{translation_suffix}", + username: @creator.username, + identifier: identifier_text, + channel: @chat_channel.title(membership.user), + ) + + payload + end + + def create_notification!(membership, notification_data) + is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) + + notification = + Notification.create!( + notification_type: Notification.types[:chat_mention], + user_id: membership.user_id, + high_priority: true, + data: notification_data.to_json, + read: is_read, + ) + ChatMention.create!( + notification: notification, + user: membership.user, + chat_message: @chat_message, + ) + end + + def send_notifications(membership, notification_data, os_payload) + create_notification!(membership, notification_data) + + if !membership.desktop_notifications_never? && !membership.muted? + MessageBus.publish( + "/chat/notification-alert/#{membership.user_id}", + os_payload, + user_ids: [membership.user_id], + ) + end + + if !membership.mobile_notifications_never? && !membership.muted? + PostAlerter.push_notification(membership.user, os_payload) + end + end + + def process_mentions(user_ids, mention_type) + memberships = get_memberships(user_ids) + + memberships.each do |membership| + notification_data = build_data_for(membership, identifier_type: mention_type) + payload = build_payload_for(membership, identifier_type: mention_type) + + send_notifications(membership, notification_data, payload) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_notify_watching.rb b/plugins/chat/app/jobs/regular/chat_notify_watching.rb new file mode 100644 index 0000000000..e9b8805e88 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_notify_watching.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Jobs + class ChatNotifyWatching < ::Jobs::Base + def execute(args = {}) + @chat_message = + ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id]) + return if @chat_message.nil? + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @is_direct_message_channel = @chat_channel.direct_message_channel? + + always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + members = + UserChatChannelMembership + .includes(user: :groups) + .joins(user: :user_option) + .where(user_option: { chat_enabled: true }) + .where.not(user_id: args[:except_user_ids]) + .where(chat_channel_id: @chat_channel.id) + .where(following: true) + .where( + "desktop_notification_level = ? OR mobile_notification_level = ?", + always_notification_level, + always_notification_level, + ) + .merge(User.not_suspended) + + if @is_direct_message_channel + UserCommScreener + .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) + .allowing_actor_communication + .each do |user_id| + send_notifications(members.find { |member| member.user_id == user_id }) + end + else + members.each { |member| send_notifications(member) } + end + end + + def send_notifications(membership) + user = membership.user + guardian = Guardian.new(user) + return unless guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel) + return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) + return if online_user_ids.include?(user.id) + + translation_key = + ( + if @is_direct_message_channel + "discourse_push_notifications.popup.new_direct_chat_message" + else + "discourse_push_notifications.popup.new_chat_message" + end + ) + + translation_args = { username: @creator.username } + translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel + + payload = { + username: @creator.username, + notification_type: Notification.types[:chat_message], + post_url: @chat_channel.relative_url, + translated_title: I18n.t(translation_key, translation_args), + tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + } + + if membership.desktop_notifications_always? && !membership.muted? + MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) + end + + if membership.mobile_notifications_always? && !membership.muted? + PostAlerter.push_notification(user, payload) + end + end + + def online_user_ids + @online_user_ids ||= PresenceChannel.new("/chat/online").user_ids + end + end +end diff --git a/plugins/chat/app/jobs/regular/process_chat_message.rb b/plugins/chat/app/jobs/regular/process_chat_message.rb new file mode 100644 index 0000000000..612978bb23 --- /dev/null +++ b/plugins/chat/app/jobs/regular/process_chat_message.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Jobs + class ProcessChatMessage < ::Jobs::Base + def execute(args = {}) + DistributedMutex.synchronize( + "process_chat_message_#{args[:chat_message_id]}", + validity: 10.minutes, + ) do + chat_message = ChatMessage.find_by(id: args[:chat_message_id]) + return if !chat_message + processor = Chat::ChatMessageProcessor.new(chat_message) + processor.run! + + if args[:is_dirty] || processor.dirty? + chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION) + ChatPublisher.publish_processed!(chat_message) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/update_channel_user_count.rb new file mode 100644 index 0000000000..0790a52e16 --- /dev/null +++ b/plugins/chat/app/jobs/regular/update_channel_user_count.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Jobs + class UpdateChannelUserCount < Jobs::Base + def execute(args = {}) + channel = ChatChannel.find_by(id: args[:chat_channel_id]) + return if channel.blank? + return if !channel.user_count_stale + + channel.update!( + user_count: ChatChannelMembershipsQuery.count(channel), + user_count_stale: false, + ) + + ChatPublisher.publish_chat_channel_metadata(channel) + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/auto_join_users.rb new file mode 100644 index 0000000000..061a3dce8d --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/auto_join_users.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + class AutoJoinUsers < ::Jobs::Scheduled + every 1.hour + + def execute(_args) + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb new file mode 100644 index 0000000000..0799915528 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Jobs + class DeleteOldChatMessages < ::Jobs::Scheduled + daily at: 0.hours + + def execute(args = {}) + delete_public_channel_messages + delete_dm_channel_messages + end + + def delete_public_channel_messages + return unless valid_day_value?(:chat_channel_retention_days) + + ChatMessage + .in_public_channel + .with_deleted + .created_before(SiteSetting.chat_channel_retention_days.days.ago) + .in_batches(of: 200) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read_message_id(destroyed_ids) + delete_flags(destroyed_ids) + end + end + + def delete_dm_channel_messages + return unless valid_day_value?(:chat_dm_retention_days) + + ChatMessage + .in_dm_channel + .with_deleted + .created_before(SiteSetting.chat_dm_retention_days.days.ago) + .in_batches(of: 200) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read_message_id(destroyed_ids) + end + end + + def valid_day_value?(setting_name) + (SiteSetting.public_send(setting_name) || 0).positive? + end + + def reset_last_read_message_id(ids) + UserChatChannelMembership.where(last_read_message_id: ids).update_all( + last_read_message_id: nil, + ) + end + + def delete_flags(message_ids) + ReviewableChatMessage.where(target_id: message_ids).destroy_all + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb new file mode 100644 index 0000000000..470c6aa215 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Jobs + class EmailChatNotifications < ::Jobs::Scheduled + every 5.minutes + + def execute(args = {}) + return unless SiteSetting.chat_enabled + + Chat::ChatMailer.send_unread_mentions_summary + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb new file mode 100644 index 0000000000..968982819f --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Jobs + class UpdateUserCountsForChatChannels < ::Jobs::Scheduled + every 1.hour + + # FIXME: This could become huge as the amount of channels grows, we + # need a different approach here. Perhaps we should only bother for + # channels updated or with new messages in the past N days? Perhaps + # we could update all the counts in a single query as well? + def execute(args = {}) + ChatChannel + .where(status: %i[open closed]) + .find_each { |chat_channel| set_user_count(chat_channel) } + end + + def set_user_count(chat_channel) + current_count = chat_channel.user_count || 0 + new_count = ChatChannelMembershipsQuery.count(chat_channel) + return if current_count == new_count + + chat_channel.update(user_count: new_count, user_count_stale: false) + ChatPublisher.publish_chat_channel_metadata(chat_channel) + end + end +end diff --git a/plugins/chat/app/models/category_channel.rb b/plugins/chat/app/models/category_channel.rb new file mode 100644 index 0000000000..e95c3d5cff --- /dev/null +++ b/plugins/chat/app/models/category_channel.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class CategoryChannel < ChatChannel + alias_attribute :category, :chatable + + delegate :read_restricted?, to: :category + delegate :url, to: :chatable, prefix: true + + %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| + define_method(name) { true } + end + + def allowed_group_ids + return if !read_restricted? + + staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values + category.secure_group_ids.to_a.concat(staff_groups) + end + + def title(_ = nil) + name.presence || category.name + end + + def generate_auto_slug + return if self.slug.present? + self.slug = Slug.for(self.title.strip, "") + self.slug = "" if duplicate_slug? + end + + def ensure_slug_ok + # if we don't unescape it first we strip the % from the encoded version + slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug + self.slug = Slug.for(slug, "", method: :encoded) + + if self.slug.blank? + errors.add(:slug, :invalid) + elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only? + errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")) + elsif duplicate_slug? + errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use")) + end + end +end diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb new file mode 100644 index 0000000000..c2cbca6fd3 --- /dev/null +++ b/plugins/chat/app/models/chat_channel.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +class ChatChannel < ActiveRecord::Base + include Trashable + + belongs_to :chatable, polymorphic: true + belongs_to :direct_message, + -> { where(chat_channels: { chatable_type: "DirectMessage" }) }, + foreign_key: "chatable_id" + + has_many :chat_messages + has_many :user_chat_channel_memberships + + has_one :chat_channel_archive + + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + + validates :name, + length: { + maximum: Proc.new { SiteSetting.max_topic_title_length }, + }, + presence: true, + allow_nil: true + validate :ensure_slug_ok + before_validation :generate_auto_slug + + scope :public_channels, + -> { + where(chatable_type: public_channel_chatable_types).where( + "categories.id IS NOT NULL", + ).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + } + + delegate :empty?, to: :chat_messages, prefix: true + + class << self + def public_channel_chatable_types + ["Category"] + end + + def chatable_types + public_channel_chatable_types << "DirectMessage" + end + end + + statuses.keys.each do |status| + define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } + end + + %i[ + category_channel? + direct_message_channel? + public_channel? + chatable_has_custom_fields? + read_restricted? + ].each { |name| define_method(name) { false } } + + %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } + + def membership_for(user) + user_chat_channel_memberships.find_by(user: user) + end + + def add(user) + Chat::ChatChannelMembershipManager.new(self).follow(user) + end + + def remove(user) + Chat::ChatChannelMembershipManager.new(self).unfollow(user) + end + + def status_name + I18n.t("chat.channel.statuses.#{self.status}") + end + + def url + "#{Discourse.base_url}#{relative_url}" + end + + def relative_url + "/chat/channel/#{self.id}/#{self.slug || "-"}" + end + + private + + def change_status(acting_user, target_status) + return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) + self.update!(status: target_status) + log_channel_status_change(acting_user: acting_user) + end + + def log_channel_status_change(acting_user:) + DiscourseEvent.trigger( + :chat_channel_status_change, + channel: self, + old_status: status_previously_was, + new_status: status, + ) + + StaffActionLogger.new(acting_user).log_custom( + "chat_channel_status_change", + { + chat_channel_id: self.id, + chat_channel_name: self.name, + previous_value: status_previously_was, + new_value: status, + }, + ) + + ChatPublisher.publish_channel_status(self) + end + + def duplicate_slug? + ChatChannel.where(slug: self.slug).where.not(id: self.id).any? + end +end + +# == Schema Information +# +# Table name: chat_channels +# +# id :bigint not null, primary key +# chatable_id :integer not null +# deleted_at :datetime +# deleted_by_id :integer +# featured_in_category_id :integer +# delete_after_seconds :integer +# chatable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# description :text +# status :integer default("open"), not null +# user_count :integer default(0), not null +# last_message_sent_at :datetime not null +# auto_join_users :boolean default(FALSE), not null +# user_count_stale :boolean default(FALSE), not null +# slug :string +# type :string +# +# Indexes +# +# index_chat_channels_on_chatable_id (chatable_id) +# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) +# index_chat_channels_on_slug (slug) +# index_chat_channels_on_status (status) +# diff --git a/plugins/chat/app/models/chat_channel_archive.rb b/plugins/chat/app/models/chat_channel_archive.rb new file mode 100644 index 0000000000..e84cdb35e3 --- /dev/null +++ b/plugins/chat/app/models/chat_channel_archive.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ChatChannelArchive < ActiveRecord::Base + belongs_to :chat_channel + belongs_to :archived_by, class_name: "User" + + belongs_to :destination_topic, class_name: "Topic" + + def complete? + self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? + end + + def failed? + !complete? && self.archive_error.present? + end +end + +# == Schema Information +# +# Table name: chat_channel_archives +# +# id :bigint not null, primary key +# chat_channel_id :bigint not null +# archived_by_id :integer not null +# destination_topic_id :integer +# destination_topic_title :string +# destination_category_id :integer +# destination_tags :string is an Array +# total_messages :integer not null +# archived_messages :integer default(0), not null +# archive_error :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_channel_archives_on_chat_channel_id (chat_channel_id) +# diff --git a/plugins/chat/app/models/chat_draft.rb b/plugins/chat/app/models/chat_draft.rb new file mode 100644 index 0000000000..1d3781fa82 --- /dev/null +++ b/plugins/chat/app/models/chat_draft.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ChatDraft < ActiveRecord::Base + belongs_to :user + belongs_to :chat_channel +end + +# == Schema Information +# +# Table name: chat_drafts +# +# id :bigint not null, primary key +# user_id :integer not null +# chat_channel_id :integer not null +# data :text not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/chat_mention.rb b/plugins/chat/app/models/chat_mention.rb new file mode 100644 index 0000000000..e334acae47 --- /dev/null +++ b/plugins/chat/app/models/chat_mention.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ChatMention < ActiveRecord::Base + belongs_to :user + belongs_to :chat_message + belongs_to :notification +end + +# == Schema Information +# +# Table name: chat_mentions +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# user_id :integer not null +# notification_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_mentions_index (chat_message_id,user_id,notification_id) UNIQUE +# diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb new file mode 100644 index 0000000000..9b4159b3ef --- /dev/null +++ b/plugins/chat/app/models/chat_message.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +class ChatMessage < ActiveRecord::Base + include Trashable + attribute :has_oneboxes, default: false + + BAKED_VERSION = 2 + + belongs_to :chat_channel + belongs_to :user + belongs_to :in_reply_to, class_name: "ChatMessage" + belongs_to :last_editor, class_name: "User" + has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify + has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy + has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy + has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :chat_uploads, dependent: :destroy + has_many :uploads, through: :chat_uploads + has_one :chat_webhook_event, dependent: :destroy + has_one :chat_mention, dependent: :destroy + + scope :in_public_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: ChatChannel.public_channel_chatable_types, + }, + ) + } + + scope :in_dm_channel, + -> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessage" }) } + + scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } + + before_save { self.last_editor_id ||= self.user_id } + + def validate_message(has_uploads:) + WatchedWordsValidator.new(attributes: [:message]).validate(self) + Chat::DuplicateMessageValidator.new(self).validate + + if !has_uploads && message_too_short? + self.errors.add( + :base, + I18n.t( + "chat.errors.minimum_length_not_met", + minimum: SiteSetting.chat_minimum_message_length, + ), + ) + end + end + + def attach_uploads(uploads) + return if uploads.blank? + + now = Time.now + record_attrs = + uploads.map do |upload| + { upload_id: upload.id, chat_message_id: self.id, created_at: now, updated_at: now } + end + ChatUpload.insert_all!(record_attrs) + end + + def excerpt + # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes + return message if UrlHelper.relaxed_parse(message).is_a?(URI) + + # upload-only messages are better represented as the filename + return uploads.first.original_filename if cooked.blank? && uploads.present? + + # this may return blank for some complex things like quotes, that is acceptable + PrettyText.excerpt(cooked, 50, {}) + end + + def cooked_for_excerpt + (cooked.blank? && uploads.present?) ? "

    #{uploads.first.original_filename}

    " : cooked + end + + def push_notification_excerpt + Emoji.gsub_emoji_to_unicode(message).truncate(400) + end + + def to_markdown + markdown = [] + + if self.message.present? + msg = self.message + + self.chat_uploads.any? ? markdown << msg + "\n" : markdown << msg + end + + self + .chat_uploads + .order(:created_at) + .each { |chat_upload| markdown << UploadMarkdown.new(chat_upload.upload).to_markdown } + + markdown.reject(&:empty?).join("\n") + end + + def cook + self.cooked = self.class.cook(self.message) + self.cooked_version = BAKED_VERSION + end + + def rebake!(invalidate_oneboxes: false, priority: nil) + previous_cooked = self.cooked + new_cooked = self.class.cook(message, invalidate_oneboxes: invalidate_oneboxes) + update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) + args = { chat_message_id: self.id } + args[:queue] = priority.to_s if priority && priority != :normal + args[:is_dirty] = true if previous_cooked != new_cooked + + Jobs.enqueue(:process_chat_message, args) + end + + def self.uncooked + where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) + end + + MARKDOWN_FEATURES = %w[ + anchor + bbcode-block + bbcode-inline + code + category-hashtag + censored + chat-transcript + discourse-local-dates + emoji + emojiShortcuts + inlineEmoji + html-img + mentions + unicodeUsernames + onebox + quotes + spoiler-alert + table + text-post-process + upload-protocol + watched-words + ] + + MARKDOWN_IT_RULES = %w[ + autolink + list + backticks + newline + code + fence + image + table + linkify + link + strikethrough + blockquote + emphasis + ] + + def self.cook(message, opts = {}) + cooked = + PrettyText.cook( + message, + features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, + markdown_it_rules: MARKDOWN_IT_RULES, + force_quote_link: true, + ) + + result = + Oneboxer.apply(cooked) do |url| + if opts[:invalidate_oneboxes] + Oneboxer.invalidate(url) + InlineOneboxer.invalidate(url) + end + onebox = Oneboxer.cached_onebox(url) + onebox + end + + cooked = result.to_html if result.changed? + cooked + end + + def full_url + "#{Discourse.base_url}#{url}" + end + + def url + "/chat/message/#{self.id}" + end + + private + + def message_too_short? + message.length < SiteSetting.chat_minimum_message_length + end +end + +# == Schema Information +# +# Table name: chat_messages +# +# id :bigint not null, primary key +# chat_channel_id :integer not null +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# deleted_by_id :integer +# in_reply_to_id :integer +# message :text +# cooked :text +# cooked_version :integer +# last_editor_id :integer not null +# +# Indexes +# +# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) +# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) +# index_chat_messages_on_last_editor_id (last_editor_id) +# diff --git a/plugins/chat/app/models/chat_message_reaction.rb b/plugins/chat/app/models/chat_message_reaction.rb new file mode 100644 index 0000000000..f101b2ec35 --- /dev/null +++ b/plugins/chat/app/models/chat_message_reaction.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ChatMessageReaction < ActiveRecord::Base + belongs_to :chat_message + belongs_to :user +end + +# == Schema Information +# +# Table name: chat_message_reactions +# +# id :bigint not null, primary key +# chat_message_id :integer +# user_id :integer +# emoji :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_message_reactions_index (chat_message_id,user_id,emoji) UNIQUE +# diff --git a/plugins/chat/app/models/chat_message_revision.rb b/plugins/chat/app/models/chat_message_revision.rb new file mode 100644 index 0000000000..e13cf507e1 --- /dev/null +++ b/plugins/chat/app/models/chat_message_revision.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ChatMessageRevision < ActiveRecord::Base + belongs_to :chat_message + belongs_to :user +end + +# == Schema Information +# +# Table name: chat_message_revisions +# +# id :bigint not null, primary key +# chat_message_id :integer +# old_message :text not null +# new_message :text not null +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_chat_message_revisions_on_chat_message_id (chat_message_id) +# index_chat_message_revisions_on_user_id (user_id) +# diff --git a/plugins/chat/app/models/chat_upload.rb b/plugins/chat/app/models/chat_upload.rb new file mode 100644 index 0000000000..3382328bfd --- /dev/null +++ b/plugins/chat/app/models/chat_upload.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ChatUpload < ActiveRecord::Base + belongs_to :chat_message + belongs_to :upload +end + +# == Schema Information +# +# Table name: chat_uploads +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# upload_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_uploads_on_chat_message_id_and_upload_id (chat_message_id,upload_id) UNIQUE +# diff --git a/plugins/chat/app/models/chat_view.rb b/plugins/chat/app/models/chat_view.rb new file mode 100644 index 0000000000..9df0df18dd --- /dev/null +++ b/plugins/chat/app/models/chat_view.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class ChatView + attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future + + def initialize( + chat_channel:, + chat_messages:, + user:, + can_load_more_past: nil, + can_load_more_future: nil + ) + @chat_channel = chat_channel + @chat_messages = chat_messages + @user = user + @can_load_more_past = can_load_more_past + @can_load_more_future = can_load_more_future + end + + def reviewable_ids + return @reviewable_ids if defined?(@reviewable_ids) + + @reviewable_ids = @user.staff? ? get_reviewable_ids : nil + end + + def user_flag_statuses + return @user_flag_statuses if defined?(@user_flag_statuses) + + @user_flag_statuses = get_user_flag_statuses + end + + private + + def get_reviewable_ids + sql = <<~SQL + SELECT + target_id, + MAX(r.id) reviewable_id + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + r.target_id IN (:message_ids) AND + r.target_type = 'ChatMessage' AND + s.status = :pending + GROUP BY + target_id + SQL + + ids = {} + + DB + .query( + sql, + pending: ReviewableScore.statuses[:pending], + message_ids: @chat_messages.map(&:id), + ) + .each { |row| ids[row.target_id] = row.reviewable_id } + + ids + end + + def get_user_flag_statuses + sql = <<~SQL + SELECT + target_id, + s.status + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + s.user_id = :user_id AND + r.target_id IN (:message_ids) AND + r.target_type = 'ChatMessage' + SQL + + statuses = {} + + DB + .query(sql, message_ids: @chat_messages.map(&:id), user_id: @user.id) + .each { |row| statuses[row.target_id] = row.status } + + statuses + end +end diff --git a/plugins/chat/app/models/chat_webhook_event.rb b/plugins/chat/app/models/chat_webhook_event.rb new file mode 100644 index 0000000000..acda4ffd9b --- /dev/null +++ b/plugins/chat/app/models/chat_webhook_event.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ChatWebhookEvent < ActiveRecord::Base + belongs_to :chat_message + belongs_to :incoming_chat_webhook + + delegate :username, to: :incoming_chat_webhook + delegate :emoji, to: :incoming_chat_webhook +end + +# == Schema Information +# +# Table name: chat_webhook_events +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# incoming_chat_webhook_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_webhook_events_index (chat_message_id,incoming_chat_webhook_id) UNIQUE +# diff --git a/plugins/chat/app/models/concerns/chatable.rb b/plugins/chat/app/models/concerns/chatable.rb new file mode 100644 index 0000000000..2128a7cf4e --- /dev/null +++ b/plugins/chat/app/models/concerns/chatable.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chatable + extend ActiveSupport::Concern + + def chat_channel + channel_class.new(chatable: self) + end + + def create_chat_channel!(**args) + channel_class.create!(args.merge(chatable: self)) + end + + private + + def channel_class + "#{self.class}Channel".safe_constantize || raise("Unknown chatable #{self}") + end +end diff --git a/plugins/chat/app/models/deleted_chat_user.rb b/plugins/chat/app/models/deleted_chat_user.rb new file mode 100644 index 0000000000..3d6222a4a9 --- /dev/null +++ b/plugins/chat/app/models/deleted_chat_user.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DeletedChatUser < User + def username + I18n.t("chat.deleted_chat_username") + end + + def avatar_template + "/plugins/chat/images/deleted-chat-user-avatar.png" + end + + def bot? + false + end +end diff --git a/plugins/chat/app/models/direct_message.rb b/plugins/chat/app/models/direct_message.rb new file mode 100644 index 0000000000..40ad99c472 --- /dev/null +++ b/plugins/chat/app/models/direct_message.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class DirectMessage < ActiveRecord::Base + self.table_name = "direct_message_channels" + + include Chatable + + has_many :direct_message_users, foreign_key: :direct_message_channel_id + has_many :users, through: :direct_message_users + + def self.for_user_ids(user_ids) + joins(:users) + .group("direct_message_channels.id") + .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) + &.first + end + + def user_can_access?(user) + users.include?(user) + end + + def chat_channel_title_for_user(chat_channel, acting_user) + users = + (direct_message_users.map(&:user) - [acting_user]).map { |user| user || DeletedChatUser.new } + + # direct message to self + if users.empty? + return I18n.t("chat.channel.dm_title.single_user", user: "@#{acting_user.username}") + end + + # all users deleted + return chat_channel.id if !users.first + + usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } + if usernames_formatted.size > 5 + return( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + users: usernames_formatted[0..4].join(", "), + leftover: usernames_formatted.length - 5, + ) + ) + end + + I18n.t("chat.channel.dm_title.multi_user", users: usernames_formatted.join(", ")) + end +end + +# == Schema Information +# +# Table name: direct_message_channels +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/direct_message_channel.rb b/plugins/chat/app/models/direct_message_channel.rb new file mode 100644 index 0000000000..9d116643d7 --- /dev/null +++ b/plugins/chat/app/models/direct_message_channel.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class DirectMessageChannel < ChatChannel + alias_attribute :direct_message, :chatable + + def direct_message_channel? + true + end + + def allowed_user_ids + direct_message.user_ids + end + + def read_restricted? + true + end + + def title(user) + direct_message.chat_channel_title_for_user(self, user) + end + + def ensure_slug_ok + true + end + + def generate_auto_slug + self.slug = nil + end +end diff --git a/plugins/chat/app/models/direct_message_user.rb b/plugins/chat/app/models/direct_message_user.rb new file mode 100644 index 0000000000..f8cfc6664f --- /dev/null +++ b/plugins/chat/app/models/direct_message_user.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DirectMessageUser < ActiveRecord::Base + belongs_to :direct_message, foreign_key: :direct_message_channel_id + belongs_to :user +end + +# == Schema Information +# +# Table name: direct_message_users +# +# id :bigint not null, primary key +# direct_message_channel_id :integer not null +# user_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# direct_message_users_index (direct_message_channel_id,user_id) UNIQUE +# diff --git a/plugins/chat/app/models/incoming_chat_webhook.rb b/plugins/chat/app/models/incoming_chat_webhook.rb new file mode 100644 index 0000000000..e71b539a03 --- /dev/null +++ b/plugins/chat/app/models/incoming_chat_webhook.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class IncomingChatWebhook < ActiveRecord::Base + belongs_to :chat_channel + has_many :chat_webhook_events + + before_create { self.key = SecureRandom.hex(12) } + + def url + "#{Discourse.base_url}/chat/hooks/#{key}.json" + end +end + +# == Schema Information +# +# Table name: incoming_chat_webhooks +# +# id :bigint not null, primary key +# name :string not null +# key :string not null +# chat_channel_id :integer not null +# username :string +# description :string +# emoji :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_incoming_chat_webhooks_on_key_and_chat_channel_id (key,chat_channel_id) +# diff --git a/plugins/chat/app/models/reviewable_chat_message.rb b/plugins/chat/app/models/reviewable_chat_message.rb new file mode 100644 index 0000000000..46bc6dd071 --- /dev/null +++ b/plugins/chat/app/models/reviewable_chat_message.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_dependency "reviewable" + +class ReviewableChatMessage < Reviewable + def self.action_aliases + { + agree_and_keep_hidden: :agree_and_delete, + agree_and_silence: :agree_and_delete, + agree_and_suspend: :agree_and_delete, + delete_and_agree: :agree_and_delete, + disagree_and_restore: :disagree, + } + end + + def self.score_to_silence_user + sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) + end + + def chat_message + @chat_message ||= (target || ChatMessage.with_deleted.find_by(id: target_id)) + end + + def chat_message_creator + @chat_message_creator ||= chat_message.user + end + + def flagged_by_user_ids + @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) + end + + def post + nil + end + + def build_actions(actions, guardian, args) + return unless pending? + return if chat_message.blank? + + agree = + actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") + + if chat_message.deleted_at? + build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) + build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree_and_restore, icon: "thumbs-down") + else + build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) + build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree, icon: "thumbs-down") + end + + if guardian.can_suspend?(chat_message_creator) + build_action( + actions, + :agree_and_suspend, + icon: "ban", + bundle: agree, + client_action: "suspend", + ) + build_action( + actions, + :agree_and_silence, + icon: "microphone-slash", + bundle: agree, + client_action: "silence", + ) + end + + build_action(actions, :ignore, icon: "external-link-alt") + + build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at? + end + + def perform_agree_and_keep_message(performed_by, args) + agree + end + + def perform_agree_and_restore(performed_by, args) + agree { chat_message.recover! } + end + + def perform_agree_and_delete(performed_by, args) + agree { chat_message.trash!(performed_by) } + end + + def perform_disagree_and_restore(performed_by, args) + disagree { chat_message.recover! } + end + + def perform_disagree(performed_by, args) + disagree + end + + def perform_ignore(performed_by, args) + ignore + end + + def perform_delete_and_ignore(performed_by, args) + ignore { chat_message.trash!(performed_by) } + end + + private + + def agree + yield if block_given? + create_result(:success, :approved) do |result| + result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def disagree + yield if block_given? + create_result(:success, :rejected) do |result| + result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def ignore + yield if block_given? + create_result(:success, :ignored) do |result| + result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } + end + end + + def build_action( + actions, + id, + icon:, + button_class: nil, + bundle: nil, + client_action: nil, + confirm: false + ) + actions.add(id, bundle: bundle) do |action| + prefix = "reviewables.actions.#{id}" + action.icon = icon + action.button_class = button_class + action.label = "chat.#{prefix}.title" + action.description = "chat.#{prefix}.description" + action.client_action = client_action + action.confirm_message = "#{prefix}.confirm" if confirm + end + end +end diff --git a/plugins/chat/app/models/user_chat_channel_membership.rb b/plugins/chat/app/models/user_chat_channel_membership.rb new file mode 100644 index 0000000000..643dcdb1a6 --- /dev/null +++ b/plugins/chat/app/models/user_chat_channel_membership.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class UserChatChannelMembership < ActiveRecord::Base + NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } + + belongs_to :user + belongs_to :chat_channel + belongs_to :last_read_message, class_name: "ChatMessage", optional: true + + enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications + enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications + enum :join_mode, { manual: 0, automatic: 1 } + + attribute :unread_count, default: 0 + attribute :unread_mentions, default: 0 +end + +# == Schema Information +# +# Table name: user_chat_channel_memberships +# +# id :bigint not null, primary key +# user_id :integer not null +# chat_channel_id :integer not null +# last_read_message_id :integer +# following :boolean default(FALSE), not null +# muted :boolean default(FALSE), not null +# desktop_notification_level :integer default("mention"), not null +# mobile_notification_level :integer default("mention"), not null +# created_at :datetime not null +# updated_at :datetime not null +# last_unread_mention_when_emailed_id :integer +# join_mode :integer default("manual"), not null +# +# Indexes +# +# user_chat_channel_memberships_index (user_id,chat_channel_id,desktop_notification_level,mobile_notification_level,following) +# user_chat_channel_unique_memberships (user_id,chat_channel_id) UNIQUE +# diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb new file mode 100644 index 0000000000..a257e0c069 --- /dev/null +++ b/plugins/chat/app/queries/chat_channel_memberships_query.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ChatChannelMembershipsQuery + def self.call(channel, limit: 50, offset: 0, username: nil, count_only: false) + query = + UserChatChannelMembership + .joins(:user) + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .where(chat_channel: channel, following: true) + + return query.count if count_only + + if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids + query = + query.where( + "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", + channel.allowed_group_ids, + ) + end + + if username.present? + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.where("users.username_lower ILIKE ?", "%#{username}%") + else + query = + query.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + "%#{username}%", + "%#{username}%", + ) + end + end + + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.order("users.username_lower ASC") + else + query = query.order("users.name ASC, users.username_lower ASC") + end + + query.offset(offset).limit(limit) + end + + def self.count(channel) + call(channel, count_only: true) + end +end diff --git a/plugins/chat/app/serializers/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/admin_chat_index_serializer.rb new file mode 100644 index 0000000000..c8af0dc2f1 --- /dev/null +++ b/plugins/chat/app/serializers/admin_chat_index_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AdminChatIndexSerializer < ApplicationSerializer + has_many :chat_channels, serializer: ChatChannelSerializer, embed: :objects + has_many :incoming_chat_webhooks, serializer: IncomingChatWebhookSerializer, embed: :objects + + def chat_channels + object[:chat_channels] + end + + def incoming_chat_webhooks + object[:incoming_chat_webhooks] + end +end diff --git a/plugins/chat/app/serializers/chat_channel_index_serializer.rb b/plugins/chat/app/serializers/chat_channel_index_serializer.rb new file mode 100644 index 0000000000..59c555a90f --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_index_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChatChannelIndexSerializer < StructuredChannelSerializer + attributes :global_presence_channel_state + + def global_presence_channel_state + PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) + end +end diff --git a/plugins/chat/app/serializers/chat_channel_search_serializer.rb b/plugins/chat/app/serializers/chat_channel_search_serializer.rb new file mode 100644 index 0000000000..cf5bc083cc --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_search_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChatChannelSearchSerializer < StructuredChannelSerializer + has_many :users, serializer: BasicUserSerializer, embed: :objects + + def users + object[:users] + end +end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb new file mode 100644 index 0000000000..6a194047f9 --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class ChatChannelSerializer < ApplicationSerializer + attributes :id, + :auto_join_users, + :chatable, + :chatable_id, + :chatable_type, + :chatable_url, + :description, + :title, + :slug, + :last_message_sent_at, + :status, + :archive_failed, + :archive_completed, + :archived_messages, + :total_messages, + :archive_topic_id, + :memberships_count, + :current_user_membership + + def initialize(object, opts) + super(object, opts) + @current_user_membership = opts[:membership] + end + + def include_description? + object.description.present? + end + + def memberships_count + object.user_count + end + + def chatable_url + object.chatable_url + end + + def title + object.name || object.title(scope.user) + end + + def chatable + case object.chatable_type + when "Category" + BasicCategorySerializer.new(object.chatable, root: false).as_json + when "DirectMessage" + DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json + when "Site" + nil + end + end + + def archive + object.chat_channel_archive + end + + def include_archive_status? + scope.is_staff? && object.archived? && archive.present? + end + + def archive_completed + archive.complete? + end + + def archive_failed + archive.failed? + end + + def archived_messages + archive.archived_messages + end + + def total_messages + archive.total_messages + end + + def archive_topic_id + archive.destination_topic_id + end + + def include_auto_join_users? + scope.can_edit_chat_channel? + end + + def current_user_membership + return if !@current_user_membership + @current_user_membership.chat_channel = object + UserChatChannelMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + alias_method :include_archive_topic_id?, :include_archive_status? + alias_method :include_total_messages?, :include_archive_status? + alias_method :include_archived_messages?, :include_archive_status? + alias_method :include_archive_failed?, :include_archive_status? + alias_method :include_archive_completed?, :include_archive_status? +end diff --git a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb new file mode 100644 index 0000000000..25cb08c8fd --- /dev/null +++ b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ChatInReplyToSerializer < ApplicationSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects + + attributes :id, :cooked, :excerpt + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def user + object.user || DeletedChatUser.new + end +end diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb new file mode 100644 index 0000000000..0bcbd64c3d --- /dev/null +++ b/plugins/chat/app/serializers/chat_message_serializer.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +class ChatMessageSerializer < ApplicationSerializer + attributes :id, + :message, + :cooked, + :created_at, + :excerpt, + :deleted_at, + :deleted_by_id, + :reviewable_id, + :user_flag_status, + :edited, + :reactions, + :bookmark, + :available_flags + + has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects + has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects + has_one :in_reply_to, serializer: ChatInReplyToSerializer, embed: :objects + has_many :uploads, serializer: UploadSerializer, embed: :objects + + def user + object.user || DeletedChatUser.new + end + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def reactions + reactions_hash = {} + object + .reactions + .group_by(&:emoji) + .each do |emoji, reactions| + users = reactions[0..6].map(&:user).filter { |user| user.id != scope&.user&.id }[0..5] + + next unless Emoji.exists?(emoji) + + reactions_hash[emoji] = { + count: reactions.count, + users: + ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, + reacted: users_reactions.include?(emoji), + } + end + reactions_hash + end + + def include_reactions? + object.reactions.any? + end + + def users_reactions + @users_reactions ||= + object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) + end + + def users_bookmark + @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } + end + + def include_bookmark? + users_bookmark.present? + end + + def bookmark + { + id: users_bookmark.id, + reminder_at: users_bookmark.reminder_at, + name: users_bookmark.name, + auto_delete_preference: users_bookmark.auto_delete_preference, + bookmarkable_id: users_bookmark.bookmarkable_id, + bookmarkable_type: users_bookmark.bookmarkable_type, + } + end + + def edited + true + end + + def include_edited? + object.revisions.any? + end + + def deleted_at + object.user ? object.deleted_at : Time.zone.now + end + + def deleted_by_id + object.user ? object.deleted_by_id : Discourse.system_user.id + end + + def include_deleted_at? + object.user ? !object.deleted_at.nil? : true + end + + def include_deleted_by_id? + object.user ? !object.deleted_at.nil? : true + end + + def include_in_reply_to? + object.in_reply_to_id.presence + end + + def reviewable_id + return @reviewable_id if defined?(@reviewable_id) + return @reviewable_id = nil unless @options && @options[:reviewable_ids] + + @reviewable_id = @options[:reviewable_ids][object.id] + end + + def include_reviewable_id? + reviewable_id.present? + end + + def user_flag_status + return @user_flag_status if defined?(@user_flag_status) + return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) + + @user_flag_status = @options[:user_flag_statuses][object.id] + end + + def include_user_flag_status? + user_flag_status.present? + end + + def available_flags + return [] if !scope.can_flag_chat_message?(object) + return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] + + channel = @options.dig(:chat_channel) || object.chat_channel + + PostActionType.flag_types.map do |sym, id| + next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) + + if sym == :notify_user && + ( + scope.current_user == user || user.bot? || + !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) + ) + next + end + + sym + end + end +end diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb new file mode 100644 index 0000000000..54b78b8401 --- /dev/null +++ b/plugins/chat/app/serializers/chat_view_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ChatViewSerializer < ApplicationSerializer + attributes :meta, :chat_messages + + def chat_messages + ActiveModel::ArraySerializer.new( + object.chat_messages, + each_serializer: ChatMessageSerializer, + reviewable_ids: object.reviewable_ids, + user_flag_statuses: object.user_flag_statuses, + chat_channel: object.chat_channel, + scope: scope, + ) + end + + def meta + meta_hash = { + can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), + channel_status: object.chat_channel.status, + user_silenced: !scope.can_create_chat_message?, + can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), + can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), + can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), + } + meta_hash[:can_load_more_past] = object.can_load_more_past unless object.can_load_more_past.nil? + meta_hash[ + :can_load_more_future + ] = object.can_load_more_future unless object.can_load_more_future.nil? + meta_hash + end +end diff --git a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb new file mode 100644 index 0000000000..3fb674c653 --- /dev/null +++ b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ChatWebhookEventSerializer < ApplicationSerializer + attributes :username, :emoji +end diff --git a/plugins/chat/app/serializers/direct_message_serializer.rb b/plugins/chat/app/serializers/direct_message_serializer.rb new file mode 100644 index 0000000000..817902467d --- /dev/null +++ b/plugins/chat/app/serializers/direct_message_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class DirectMessageSerializer < ApplicationSerializer + has_many :users, serializer: UserWithCustomFieldsAndStatusSerializer, embed: :objects + + def users + users = object.direct_message_users.map(&:user).map { |u| u || DeletedChatUser.new } + + return users - [scope.user] if users.count > 1 + users + end +end diff --git a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb new file mode 100644 index 0000000000..7f097e62bf --- /dev/null +++ b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class IncomingChatWebhookSerializer < ApplicationSerializer + has_one :chat_channel, serializer: ChatChannelSerializer, embed: :objects + + attributes :id, :name, :description, :emoji, :url, :username, :updated_at +end diff --git a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb new file mode 100644 index 0000000000..5c56d39fb7 --- /dev/null +++ b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_dependency "reviewable_serializer" + +class ReviewableChatMessageSerializer < ReviewableSerializer + target_attributes :cooked + payload_attributes :transcript_topic_id, :message_cooked + attributes :target_id + + has_one :chat_channel, serializer: ChatChannelSerializer, root: false, embed: :objects + + def chat_channel + object.chat_message.chat_channel + end + + def target_id + object.target&.id + end +end diff --git a/plugins/chat/app/serializers/structured_channel_serializer.rb b/plugins/chat/app/serializers/structured_channel_serializer.rb new file mode 100644 index 0000000000..e3ee4f7783 --- /dev/null +++ b/plugins/chat/app/serializers/structured_channel_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class StructuredChannelSerializer < ApplicationSerializer + attributes :public_channels, :direct_message_channels + + def public_channels + object[:public_channels].map do |channel| + ChatChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + ) + end + end + + def direct_message_channels + object[:direct_message_channels].map do |channel| + ChatChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + ) + end + end + + def channel_membership(channel_id) + return if scope.anonymous? + object[:memberships].find { |membership| membership.chat_channel_id == channel_id } + end +end diff --git a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb new file mode 100644 index 0000000000..93e7e5bd85 --- /dev/null +++ b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class UserChatChannelMembershipSerializer < ApplicationSerializer + attributes :following, + :muted, + :desktop_notification_level, + :mobile_notification_level, + :chat_channel_id, + :last_read_message_id, + :unread_count, + :unread_mentions + + has_one :user, serializer: BasicUserSerializer, embed: :objects + + def user + object.user + end +end diff --git a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb new file mode 100644 index 0000000000..49f4c7af6f --- /dev/null +++ b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class UserChatMessageBookmarkSerializer < UserBookmarkBaseSerializer + attr_reader :chat_message + + def title + fancy_title + end + + def fancy_title + @fancy_title ||= chat_message.chat_channel.title(scope.user) + end + + def cooked + chat_message.cooked + end + + def bookmarkable_user + @bookmarkable_user ||= chat_message.user + end + + def bookmarkable_url + chat_message.url + end + + def excerpt + return nil unless cooked + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + private + + def chat_message + object.bookmarkable + end +end diff --git a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb new file mode 100644 index 0000000000..e0897abfd5 --- /dev/null +++ b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class UserWithCustomFieldsAndStatusSerializer < UserWithCustomFieldsSerializer + attributes :status + + def include_status? + SiteSetting.enable_user_status && user.has_status? + end + + def status + UserStatusSerializer.new(user.user_status, root: false) + end +end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb new file mode 100644 index 0000000000..ab7c8bea28 --- /dev/null +++ b/plugins/chat/app/services/chat_publisher.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module ChatPublisher + def self.publish_new!(chat_channel, chat_message, staged_id) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :sent + content[:stagedId] = staged_id + permissions = permissions(chat_channel) + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) + MessageBus.publish( + "/chat/#{chat_channel.id}/new-messages", + { + message_id: chat_message.id, + user_id: chat_message.user.id, + username: chat_message.user.username, + }, + permissions, + ) + end + + def self.publish_processed!(chat_message) + chat_channel = chat_message.chat_channel + content = { + type: :processed, + chat_message: { + id: chat_message.id, + cooked: chat_message.cooked, + }, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_edit!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :edit + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_refresh!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :refresh + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) + content = { + action: action, + user: BasicUserSerializer.new(user, root: false).as_json, + emoji: emoji, + type: :reaction, + chat_message_id: chat_message.id, + } + MessageBus.publish( + "/chat/message-reactions/#{chat_message.id}", + content.as_json, + permissions(chat_channel), + ) + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_presence!(chat_channel, user, typ) + raise NotImplementedError + end + + def self.publish_delete!(chat_channel, chat_message) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, + permissions(chat_channel), + ) + end + + def self.publish_bulk_delete!(chat_channel, deleted_message_ids) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, + permissions(chat_channel), + ) + end + + def self.publish_restore!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :restore + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_flag!(chat_message, user, reviewable, score) + # Publish to user who created flag + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: "self_flagged", + user_flag_status: score.status_for_database, + chat_message_id: chat_message.id, + }.as_json, + user_ids: [user.id], + ) + + # Publish flag with link to reviewable to staff + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, + group_ids: [Group::AUTO_GROUPS[:staff]], + ) + end + + def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + MessageBus.publish( + "/chat/user-tracking-state/#{user.id}", + { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.as_json, + user_ids: [user.id], + ) + end + + def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) + MessageBus.publish( + "/chat/#{chat_channel_id}/new-mentions", + { message_id: chat_message_id }.as_json, + user_ids: [user_id], + ) + end + + def self.publish_new_channel(chat_channel, users) + users.each do |user| + serialized_channel = + ChatChannelSerializer.new( + chat_channel, + scope: Guardian.new(user), # We need a guardian here for direct messages + root: :chat_channel, + membership: chat_channel.membership_for(user), + ).as_json + MessageBus.publish("/chat/new-channel", serialized_channel, user_ids: [user.id]) + end + end + + def self.publish_inaccessible_mentions( + user_id, + chat_message, + cannot_chat_users, + without_membership + ) + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: :mention_warning, + chat_message_id: chat_message.id, + cannot_see: + ActiveModel::ArraySerializer.new( + cannot_chat_users, + each_serializer: BasicUserSerializer, + ).as_json, + without_membership: + ActiveModel::ArraySerializer.new( + without_membership, + each_serializer: BasicUserSerializer, + ).as_json, + }, + user_ids: [user_id], + ) + end + + def self.publish_chat_channel_edit(chat_channel, acting_user) + MessageBus.publish( + "/chat/channel-edits", + { + chat_channel_id: chat_channel.id, + name: chat_channel.title(acting_user), + description: chat_channel.description, + }, + permissions(chat_channel), + ) + end + + def self.publish_channel_status(chat_channel) + MessageBus.publish( + "/chat/channel-status", + { chat_channel_id: chat_channel.id, status: chat_channel.status }, + permissions(chat_channel), + ) + end + + def self.publish_chat_channel_metadata(chat_channel) + MessageBus.publish( + "/chat/channel-metadata", + { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, + permissions(chat_channel), + ) + end + + def self.publish_archive_status( + chat_channel, + archive_status:, + archived_messages:, + archive_topic_id:, + total_messages: + ) + MessageBus.publish( + "/chat/channel-archive-status", + { + chat_channel_id: chat_channel.id, + archive_failed: archive_status == :failed, + archive_completed: archive_status == :success, + archived_messages: archived_messages, + total_messages: total_messages, + archive_topic_id: archive_topic_id, + }, + permissions(chat_channel), + ) + end + + private + + def self.permissions(chat_channel) + { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } + end + + def self.anonymous_guardian + Guardian.new(nil) + end +end diff --git a/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb b/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb new file mode 100644 index 0000000000..bf88c73205 --- /dev/null +++ b/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb @@ -0,0 +1,10 @@ +<% if @chat_email_frequencies %> +

    + + <%= + select_tag :chat_email_frequency, + options_for_select(@chat_email_frequencies, @current_chat_email_frequency), + class: 'combobox' + %> +

    +<% end %> diff --git a/plugins/chat/app/views/user_notifications/chat_summary.html.erb b/plugins/chat/app/views/user_notifications/chat_summary.html.erb new file mode 100644 index 0000000000..d3a235d50d --- /dev/null +++ b/plugins/chat/app/views/user_notifications/chat_summary.html.erb @@ -0,0 +1,84 @@ +
    + + + + + + + +
    + + + <%- if logo_url.blank? %> + <%= SiteSetting.title %> + <%- else %> + <%= SiteSetting.title %> + <%- end %> + + +
    + <%= I18n.t("user_notifications.chat_summary.description", count: @messages.size) %> +
    + + <%- @grouped_messages.each do |chat_channel, messages| %> + <%- other_messages_count = messages.size - 2 %> + + + + + + <%- messages.take(2).each do |chat_message| %> + <%- sender = chat_message.user %> + <%- sender_name = @display_usernames ? sender.username : sender.name %> + + + + + + + + <%- end %> + + + + +
    +
    <%= chat_channel.title(@user) %>
    +
    + <%= sender_name -%> + + <%= sender_name -%> + + + <%= I18n.l(@user_tz.to_local(chat_message.created_at), format: :long) -%> + +
    + <%= email_excerpt(chat_message.cooked_for_excerpt) %> +
    + + <%- if other_messages_count <= 0 %> + <%= I18n.t("user_notifications.chat_summary.view_messages", count: messages.size)%> + <%- else %> + <%= I18n.t("user_notifications.chat_summary.view_more", count: other_messages_count)%> + <%- end %> + +
    + <%- end %> +
    + + + + + + diff --git a/plugins/chat/app/views/user_notifications/chat_summary.text.erb b/plugins/chat/app/views/user_notifications/chat_summary.text.erb new file mode 100644 index 0000000000..76955166f2 --- /dev/null +++ b/plugins/chat/app/views/user_notifications/chat_summary.text.erb @@ -0,0 +1,15 @@ +<%- site_link = raw(@markdown_linker.create(@site_name, '/')) %> +<%= t('user_notifications.chat_summary.description', count: @messages.size,) %> +<%= raw(@markdown_linker.create(t("user_notifications.chat_summary.view_messages", count: @messages.size), "/chat")) %> +<%- if @unsubscribe_link %> + <%= raw(t :'user_notifications.chat_summary.unsubscribe', + site_link: site_link, + email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path), + unsubscribe_link: @markdown_linker.create(t('user_notifications.digest.click_here'), @unsubscribe_link)) %> +<%- else %> + <%= raw(t :'user_notifications.chat_summary.unsubscribe_no_link', + site_link: site_link, + email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path)) %> +<%- end %> + +<%= raw(@markdown_linker.references) %> diff --git a/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js b/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js new file mode 100644 index 0000000000..51f4b36f25 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js @@ -0,0 +1,22 @@ +import RESTAdapter from "discourse/adapters/rest"; + +export default class ChatMessage extends RESTAdapter { + pathFor(store, type, findArgs) { + if (findArgs.targetMessageId) { + return `/chat/lookup/${findArgs.targetMessageId}.json?chat_channel_id=${findArgs.channelId}`; + } + + let path = `/chat/${findArgs.channelId}/messages.json?page_size=${findArgs.pageSize}`; + if (findArgs.messageId) { + path += `&message_id=${findArgs.messageId}`; + } + if (findArgs.direction) { + path += `&direction=${findArgs.direction}`; + } + return path; + } + + apiNameFor() { + return "chat-message"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js b/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js new file mode 100644 index 0000000000..a1e74d0708 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js @@ -0,0 +1,7 @@ +export default { + resource: "admin.adminPlugins", + path: "/plugins", + map() { + this.route("chat"); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js new file mode 100644 index 0000000000..a4e03558a2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -0,0 +1,25 @@ +export default function () { + this.route("chat", { path: "/chat" }, function () { + this.route( + "channel", + { path: "/channel/:channelId/:channelTitle" }, + function () { + this.route("info", { path: "/info" }, function () { + this.route("about", { path: "/about" }); + this.route("members", { path: "/members" }); + this.route("settings", { path: "/settings" }); + }); + } + ); + + this.route("draft-channel", { path: "/draft-channel" }); + this.route("browse", { path: "/browse" }, function () { + this.route("all", { path: "/all" }); + this.route("closed", { path: "/closed" }); + this.route("open", { path: "/open" }); + this.route("archived", { path: "/archived" }); + }); + this.route("message", { path: "/message/:messageId" }); + this.route("channelByName", { path: "/chat_channels/:channelName" }); + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js new file mode 100644 index 0000000000..ae145525a0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -0,0 +1,110 @@ +import { bind } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { and, empty, reads } from "@ember/object/computed"; + +export default class ChannelsList extends Component { + @service chat; + @service router; + @service chatStateManager; + tagName = ""; + inSidebar = false; + toggleSection = null; + @reads("chat.publicChannels.[]") publicChannels; + @reads("chat.directMessageChannels.[]") directMessageChannels; + @empty("publicChannels") publicChannelsEmpty; + @and("site.mobileView", "showDirectMessageChannels") + showMobileDirectMessageButton; + + @computed("canCreateDirectMessageChannel") + get createDirectMessageChannelLabel() { + if (!this.canCreateDirectMessageChannel) { + return "chat.direct_messages.cannot_create"; + } + + return "chat.direct_messages.new"; + } + + @computed("canCreateDirectMessageChannel", "directMessageChannels") + get showDirectMessageChannels() { + return ( + this.canCreateDirectMessageChannel || + this.directMessageChannels?.length > 0 + ); + } + + get canCreateDirectMessageChannel() { + return this.chat.userCanDirectMessage; + } + + @computed("directMessageChannels.@each.last_message_sent_at") + get sortedDirectMessageChannels() { + if (!this.directMessageChannels?.length) { + return []; + } + + return this.chat.truncateDirectMessageChannels( + this.chat.sortDirectMessageChannels(this.directMessageChannels) + ); + } + + @computed("inSidebar") + get publicChannelClasses() { + return `channels-list-container public-channels ${ + this.inSidebar ? "collapsible-sidebar-section" : "" + }`; + } + + @computed( + "publicChannelsEmpty", + "currentUser.{staff,has_joinable_public_channels}" + ) + get displayPublicChannels() { + if (this.publicChannelsEmpty) { + return ( + this.currentUser?.staff || + this.currentUser?.has_joinable_public_channels + ); + } + + return true; + } + + @computed("inSidebar") + get directMessageChannelClasses() { + return `channels-list-container direct-message-channels ${ + this.inSidebar ? "collapsible-sidebar-section" : "" + }`; + } + + @action + toggleChannelSection(section) { + this.toggleSection(section); + } + + didRender() { + this._super(...arguments); + + schedule("afterRender", this._applyScrollPosition); + } + + @action + storeScrollPosition() { + const scroller = document.querySelector(".channels-list"); + if (scroller) { + const scrollTop = scroller.scrollTop || 0; + this.session.set("channels-list-position", scrollTop); + } + } + + @bind + _applyScrollPosition() { + const data = this.session.get("channels-list-position"); + if (data) { + const scroller = document.querySelector(".channels-list"); + scroller.scrollTo(0, data); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js new file mode 100644 index 0000000000..a453ad360f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js @@ -0,0 +1,99 @@ +import { INPUT_DELAY } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; +import showModal from "discourse/lib/show-modal"; + +const TABS = ["all", "open", "closed", "archived"]; +const PER_PAGE = 20; + +export default class ChatBrowseView extends Component { + @service router; + @tracked isLoading = false; + @tracked channels = []; + tagName = ""; + + tabs = TABS; + offset = 0; + canLoadMore = true; + + didReceiveAttrs() { + this._super(...arguments); + + this.channels = []; + this.canLoadMore = true; + this.offset = 0; + this.fetchChannels(); + } + + async fetchChannels(params) { + if (this.isLoading || !this.canLoadMore) { + return; + } + + this.isLoading = true; + + try { + const results = await ChatApi.chatChannels({ + limit: PER_PAGE, + offset: this.offset, + status: this.status, + filter: this.filter, + ...params, + }); + + if (results.length) { + this.channels.pushObjects(results); + } + + if (results.length < PER_PAGE) { + this.canLoadMore = false; + } + } finally { + this.offset = this.offset + PER_PAGE; + this.isLoading = false; + } + } + + get chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + } + + @action + onScroll() { + if (this.isLoading) { + return; + } + + discourseDebounce(this, this.fetchChannels, INPUT_DELAY); + } + + @action + debouncedFiltering(event) { + discourseDebounce( + this, + this.filterChannels, + event.target.value, + INPUT_DELAY + ); + } + + @action + createChannel() { + showModal("create-channel"); + } + + @bind + filterChannels(filter) { + this.canLoadMore = true; + this.filter = filter; + this.channels = []; + this.offset = 0; + + this.fetchChannels(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js new file mode 100644 index 0000000000..3b4d038645 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js @@ -0,0 +1,19 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelAboutView extends Component { + @service chat; + tagName = ""; + channel = null; + onEditChatChannelTitle = null; + onEditChatChannelDescription = null; + isLoading = false; + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels().then(() => { + this.chat.openChannel(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js new file mode 100644 index 0000000000..2569653fac --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js @@ -0,0 +1,114 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import discourseLater from "discourse-common/lib/later"; +import { isEmpty } from "@ember/utils"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { + EXISTING_TOPIC_SELECTION, + NEW_TOPIC_SELECTION, +} from "discourse/plugins/chat/discourse/components/chat-to-topic-selector"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { htmlSafe } from "@ember/template"; + +export default Component.extend({ + chat: service(), + tagName: "", + chatChannel: null, + + selection: "newTopic", + newTopic: equal("selection", NEW_TOPIC_SELECTION), + existingTopic: equal("selection", EXISTING_TOPIC_SELECTION), + + saving: false, + + topicTitle: null, + categoryId: null, + tags: null, + selectedTopicId: null, + + @action + archiveChannel() { + this.set("saving", true); + return ajax({ + url: `/chat/chat_channels/${this.chatChannel.id}/archive.json`, + type: "PUT", + data: this._data(), + }) + .then(() => { + this.appEvents.trigger("modal-body:flash", { + text: I18n.t("chat.channel_archive.process_started"), + messageClass: "success", + }); + + this.chatChannel.set("status", CHANNEL_STATUSES.archived); + + discourseLater(() => { + this.closeModal(); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => this.set("saving", false)); + }, + + _data() { + const data = { + type: this.selection, + chat_channel_id: this.chatChannel.id, + }; + if (this.newTopic) { + data.title = this.topicTitle; + data.category_id = this.categoryId; + data.tags = this.tags; + } + if (this.existingTopic) { + data.topic_id = this.selectedTopicId; + } + return data; + }, + + @discourseComputed("saving", "selectedTopicId", "topicTitle", "selection") + buttonDisabled(saving, selectedTopicId, topicTitle) { + if (saving) { + return true; + } + if ( + this.newTopic && + (!topicTitle || + topicTitle.length < this.siteSettings.min_topic_title_length || + topicTitle.length > this.siteSettings.max_topic_title_length) + ) { + return true; + } + + if (this.existingTopic && isEmpty(selectedTopicId)) { + return true; + } + return false; + }, + + @discourseComputed() + instructionLabels() { + const labels = {}; + labels[NEW_TOPIC_SELECTION] = I18n.t( + "chat.selection.new_topic.instructions_channel_archive" + ); + labels[EXISTING_TOPIC_SELECTION] = I18n.t( + "chat.selection.existing_topic.instructions_channel_archive" + ); + return labels; + }, + + @discourseComputed() + instructionsText() { + return htmlSafe( + I18n.t("chat.channel_archive.instructions", { + channelTitle: this.chatChannel.escapedTitle, + }) + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js new file mode 100644 index 0000000000..6f006cddd0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js @@ -0,0 +1,81 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { ajax } from "discourse/lib/ajax"; +import getURL from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Component.extend({ + channel: null, + tagName: "", + + @discourseComputed( + "channel.status", + "channel.archived_messages", + "channel.total_messages", + "channel.archive_failed" + ) + channelArchiveFailedMessage() { + return htmlSafe( + I18n.t("chat.channel_status.archive_failed", { + completed: this.channel.archived_messages, + total: this.channel.total_messages, + topic_url: this._getTopicUrl(), + }) + ); + }, + + @discourseComputed( + "channel.status", + "channel.archived_messages", + "channel.total_messages", + "channel.archive_completed" + ) + channelArchiveCompletedMessage() { + return htmlSafe( + I18n.t("chat.channel_status.archive_completed", { + topic_url: this._getTopicUrl(), + }) + ); + }, + + @action + retryArchive() { + return ajax({ + url: `/chat/chat_channels/${this.channel.id}/retry_archive.json`, + type: "PUT", + }) + .then(() => { + this.channel.set("archive_failed", false); + }) + .catch(popupAjaxError); + }, + + didInsertElement() { + this._super(...arguments); + if (this.currentUser.admin) { + this.messageBus.subscribe("/chat/channel-archive-status", (busData) => { + if (busData.chat_channel_id === this.channel.id) { + this.channel.setProperties({ + archive_failed: busData.archive_failed, + archive_completed: busData.archive_completed, + archived_messages: busData.archived_messages, + archive_topic_id: busData.archive_topic_id, + total_messages: busData.total_messages, + }); + } + }); + } + }, + + willDestroyElement() { + this._super(...arguments); + this.messageBus.unsubscribe("/chat/channel-archive-status"); + }, + + _getTopicUrl() { + return getURL(`/t/-/${this.channel.archive_topic_id}`); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js new file mode 100644 index 0000000000..3392fe9f05 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js @@ -0,0 +1,13 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelCard extends Component { + @service chat; + tagName = ""; + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js new file mode 100644 index 0000000000..68622e7560 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js @@ -0,0 +1,3 @@ +import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; + +export default ComboBoxSelectBoxHeaderComponent.extend({}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js new file mode 100644 index 0000000000..50e8d0b319 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js @@ -0,0 +1,5 @@ +import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; + +export default SelectKitRowComponent.extend({ + classNames: ["chat-channel-chooser-row"], +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js new file mode 100644 index 0000000000..94ab9da6d8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js @@ -0,0 +1,14 @@ +import ComboBoxComponent from "select-kit/components/combo-box"; + +export default ComboBoxComponent.extend({ + pluginApiIdentifiers: ["chat-channel-chooser"], + classNames: ["chat-channel-chooser"], + + selectKitOptions: { + headerComponent: "chat-channel-chooser-header", + }, + + modifyComponentForRow() { + return "chat-channel-chooser-row"; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js new file mode 100644 index 0000000000..3f38523f18 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js @@ -0,0 +1,68 @@ +import Component from "@ember/component"; +import { isEmpty } from "@ember/utils"; +import I18n from "I18n"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseLater from "discourse-common/lib/later"; +import { htmlSafe } from "@ember/template"; + +export default Component.extend({ + chat: service(), + router: service(), + tagName: "", + chatChannel: null, + channelNameConfirmation: null, + deleting: false, + confirmed: false, + + @discourseComputed("deleting", "channelNameConfirmation", "confirmed") + buttonDisabled(deleting, channelNameConfirmation, confirmed) { + if (deleting || confirmed) { + return true; + } + + if ( + isEmpty(channelNameConfirmation) || + channelNameConfirmation.toLowerCase() !== + this.chatChannel.title.toLowerCase() + ) { + return true; + } + return false; + }, + + @action + deleteChannel() { + this.set("deleting", true); + return ajax(`/chat/chat_channels/${this.chatChannel.id}.json`, { + method: "DELETE", + data: { channel_name_confirmation: this.channelNameConfirmation }, + }) + .then(() => { + this.set("confirmed", true); + this.appEvents.trigger("modal-body:flash", { + text: I18n.t("chat.channel_delete.process_started"), + messageClass: "success", + }); + + discourseLater(() => { + this.closeModal(); + this.router.transitionTo("chat"); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => this.set("deleting", false)); + }, + + @discourseComputed() + instructionsText() { + return htmlSafe( + I18n.t("chat.channel_delete.instructions", { + name: this.chatChannel.escapedTitle, + }) + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js new file mode 100644 index 0000000000..3347b5e236 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js @@ -0,0 +1,25 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { equal } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + chat: service(), + + isDirectMessageRow: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + + @discourseComputed("isDirectMessageRow") + leaveChatTitleKey(isDirectMessageRow) { + if (isDirectMessageRow) { + return "chat.direct_messages.leave"; + } else { + return "chat.leave"; + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js new file mode 100644 index 0000000000..49907dbd68 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js @@ -0,0 +1,113 @@ +import { isEmpty } from "@ember/utils"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseDebounce from "discourse-common/lib/debounce"; + +const LIMIT = 50; + +export default class ChatChannelMembersView extends Component { + tagName = ""; + channel = null; + members = null; + isSearchFocused = false; + isFetchingMembers = false; + onlineUsers = null; + offset = 0; + filter = null; + inputSelector = "channel-members-view__search-input"; + canLoadMore = true; + + didInsertElement() { + this._super(...arguments); + + if (!this.channel || this.channel.isDraft) { + return; + } + + this._focusSearch(); + this.set("members", []); + this.fetchMembers(); + + this.appEvents.on("chat:refresh-channel-members", this, "onFilterMembers"); + } + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat:refresh-channel-members", this, "onFilterMembers"); + } + + get chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + } + + @action + onFilterMembers(username) { + this.set("filter", username); + this.set("offset", 0); + this.set("canLoadMore", true); + + discourseDebounce( + this, + this.fetchMembers, + this.filter, + this.offset, + INPUT_DELAY + ); + } + + @action + loadMore() { + if (!this.canLoadMore) { + return; + } + + discourseDebounce( + this, + this.fetchMembers, + this.filter, + this.offset, + INPUT_DELAY + ); + } + + fetchMembersHandler(id, params = {}) { + return ChatApi.chatChannelMemberships(id, params); + } + + fetchMembers(filter = null, offset = 0) { + this.set("isFetchingMembers", true); + + return this.fetchMembersHandler(this.channel.id, { + username: filter, + offset, + }) + .then((response) => { + if (this.offset === 0) { + this.set("members", []); + } + + if (isEmpty(response)) { + this.set("canLoadMore", false); + } else { + this.set("offset", this.offset + LIMIT); + this.members.pushObjects(response); + } + }) + .finally(() => { + this.set("isFetchingMembers", false); + }); + } + + _focusSearch() { + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + schedule("afterRender", () => { + document.getElementsByClassName(this.inputSelector)[0]?.focus(); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js new file mode 100644 index 0000000000..23be8f9a67 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js @@ -0,0 +1,26 @@ +import Component from "@ember/component"; +import { isEmpty } from "@ember/utils"; +import { action, computed } from "@ember/object"; +import { readOnly } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelPreviewCard extends Component { + @service chat; + tagName = ""; + + channel = null; + + @readOnly("channel.isOpen") showJoinButton; + + @computed("channel.description") + get hasDescription() { + return !isEmpty(this.channel.description); + } + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels().then(() => { + this.chat.openChannel(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js new file mode 100644 index 0000000000..ae3b281645 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js @@ -0,0 +1,83 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import discourseComputed from "discourse-common/utils/decorators"; +import { equal } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + router: service(), + chat: service(), + channel: null, + isDirectMessageRow: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + options: null, + + didInsertElement() { + this._super(...arguments); + + if (this.isDirectMessageRow) { + this.channel.chatable.users[0].trackStatus(); + } + }, + + willDestroyElement() { + this._super(...arguments); + + if (this.isDirectMessageRow) { + this.channel.chatable.users[0].stopTrackingStatus(); + } + }, + + @discourseComputed( + "channel.id", + "chat.activeChannel.id", + "router.currentRouteName" + ) + active(channelId, activeChannelId, currentRouteName) { + return ( + currentRouteName?.startsWith("chat.channel") && + channelId === activeChannelId + ); + }, + + @discourseComputed("active", "channel.{id,muted}", "channel.focused") + rowClassNames(active, channel, focused) { + const classes = ["chat-channel-row", `chat-channel-${channel.id}`]; + if (active) { + classes.push("active"); + } + if (focused) { + classes.push("focused"); + } + if (channel.current_user_membership.muted) { + classes.push("muted"); + } + return classes.join(" "); + }, + + @discourseComputed( + "isDirectMessageRow", + "channel.chatable.users.[]", + "channel.chatable.users.@each.status" + ) + showUserStatus(isDirectMessageRow) { + return !!( + isDirectMessageRow && + this.channel.chatable.users.length === 1 && + this.channel.chatable.users[0].status + ); + }, + + @discourseComputed("channel.chatable_type") + leaveChatTitle() { + if (this.channel.isDirectMessageChannel) { + return I18n.t("chat.direct_messages.leave"); + } else { + return I18n.t("chat.channel_settings.leave_channel"); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js new file mode 100644 index 0000000000..07d4e9b6c5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + @discourseComputed("model", "model.focused") + rowClassNames(model, focused) { + return `chat-channel-selection-row ${focused ? "focused" : ""} ${ + this.model.user ? "user-row" : "channel-row" + }`; + }, + + @action + handleClick(event) { + if (this.onClick) { + this.onClick(this.model); + event.preventDefault(); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js new file mode 100644 index 0000000000..bfd46d64c0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js @@ -0,0 +1,181 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { ajax } from "discourse/lib/ajax"; +import { bind } from "discourse-common/utils/decorators"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { isPresent } from "@ember/utils"; + +export default Component.extend({ + chat: service(), + tagName: "", + filter: "", + channels: null, + searchIndex: 0, + loading: false, + + init() { + this._super(...arguments); + this.appEvents.on("chat-channel-selector-modal:close", this.close); + this.getInitialChannels(); + }, + + didInsertElement() { + this._super(...arguments); + document.addEventListener("keyup", this.onKeyUp); + document + .getElementById("chat-channel-selector-modal-inner") + ?.addEventListener("mouseover", this.mouseover); + document.getElementById("chat-channel-selector-input")?.focus(); + }, + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat-channel-selector-modal:close", this.close); + document.removeEventListener("keyup", this.onKeyUp); + document + .getElementById("chat-channel-selector-modal-inner") + ?.removeEventListener("mouseover", this.mouseover); + }, + + @bind + mouseover(e) { + if (e.target.classList.contains("chat-channel-selection-row")) { + let channel; + const id = parseInt(e.target.dataset.id, 10); + if (e.target.classList.contains("channel-row")) { + channel = this.channels.findBy("id", id); + } else { + channel = this.channels.find((c) => c.user && c.id === id); + } + channel?.set("focused", true); + this.channels.forEach((c) => { + if (c !== channel) { + c.set("focused", false); + } + }); + } + }, + + @bind + onKeyUp(e) { + if (e.key === "Enter") { + let focusedChannel = this.channels.find((c) => c.focused); + this.switchChannel(focusedChannel); + e.preventDefault(); + } else if (e.key === "ArrowDown") { + this.arrowNavigateChannels("down"); + e.preventDefault(); + } else if (e.key === "ArrowUp") { + this.arrowNavigateChannels("up"); + e.preventDefault(); + } + }, + + arrowNavigateChannels(direction) { + const indexOfFocused = this.channels.findIndex((c) => c.focused); + if (indexOfFocused > -1) { + const nextIndex = direction === "down" ? 1 : -1; + const nextChannel = this.channels[indexOfFocused + nextIndex]; + if (nextChannel) { + this.channels[indexOfFocused].set("focused", false); + nextChannel.set("focused", true); + } + } else { + this.channels[0].set("focused", true); + } + + schedule("afterRender", () => { + let focusedChannel = document.querySelector( + "#chat-channel-selector-modal-inner .chat-channel-selection-row.focused" + ); + focusedChannel?.scrollIntoView({ block: "nearest", inline: "start" }); + }); + }, + + @action + switchChannel(channel) { + if (channel.user) { + return this.fetchOrCreateChannelForUser(channel).then((response) => { + this.chat + .startTrackingChannel(ChatChannel.create(response.chat_channel)) + .then((newlyTracked) => { + this.chat.openChannel(newlyTracked); + this.close(); + }); + }); + } else { + this.chat.openChannel(channel); + this.close(); + } + }, + + @action + search(value) { + if (isPresent(value?.trim())) { + discourseDebounce( + this, + this.fetchChannelsFromServer, + value?.trim(), + INPUT_DELAY + ); + } else { + discourseDebounce(this, this.getInitialChannels, INPUT_DELAY); + } + }, + + @action + fetchChannelsFromServer(filter) { + this.setProperties({ + loading: true, + searchIndex: this.searchIndex + 1, + }); + const thisSearchIndex = this.searchIndex; + ajax("/chat/chat_channels/search", { data: { filter } }) + .then((searchModel) => { + if (this.searchIndex === thisSearchIndex) { + this.set("searchModel", searchModel); + const channels = searchModel.public_channels.concat( + searchModel.direct_message_channels, + searchModel.users + ); + channels.forEach((c) => { + if (c.username) { + c.user = true; // This is used by the `chat-channel-selection-row` component + } + }); + this.setProperties({ + channels: channels.map((channel) => ChatChannel.create(channel)), + loading: false, + }); + this.focusFirstChannel(this.channels); + } + }) + .catch(popupAjaxError); + }, + + @action + getInitialChannels() { + return this.chat.getChannelsWithFilter(this.filter).then((channels) => { + this.focusFirstChannel(channels); + this.set("channels", channels); + }); + }, + + @action + fetchOrCreateChannelForUser(user) { + return ajax("/chat/direct_messages/create.json", { + method: "POST", + data: { usernames: [user.username] }, + }).catch(popupAjaxError); + }, + + focusFirstChannel(channels) { + channels.forEach((c) => c.set("focused", false)); + channels[0]?.set("focused", true); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js new file mode 100644 index 0000000000..9918af7a34 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js @@ -0,0 +1,40 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +const NOTIFICATION_LEVELS = [ + { name: I18n.t("chat.notification_levels.never"), value: "never" }, + { name: I18n.t("chat.notification_levels.mention"), value: "mention" }, + { name: I18n.t("chat.notification_levels.always"), value: "always" }, +]; + +const MUTED_OPTIONS = [ + { name: I18n.t("chat.settings.muted_on"), value: true }, + { name: I18n.t("chat.settings.muted_off"), value: false }, +]; + +export default Component.extend({ + channel: null, + loading: false, + showSaveSuccess: false, + notificationLevels: NOTIFICATION_LEVELS, + mutedOptions: MUTED_OPTIONS, + chat: service(), + router: service(), + + didInsertElement() { + this._super(...arguments); + }, + + @discourseComputed("channel.chatable_type") + chatChannelClass(channelType) { + return `${channelType.toLowerCase()}-chat-channel`; + }, + + @action + previewChannel() { + this.chat.openChannel(this.channel); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js new file mode 100644 index 0000000000..8ca9eda17a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js @@ -0,0 +1,135 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import showModal from "discourse/lib/show-modal"; +import I18n from "I18n"; +import { camelize } from "@ember/string"; +import discourseLater from "discourse-common/lib/later"; + +const NOTIFICATION_LEVELS = [ + { name: I18n.t("chat.notification_levels.never"), value: "never" }, + { name: I18n.t("chat.notification_levels.mention"), value: "mention" }, + { name: I18n.t("chat.notification_levels.always"), value: "always" }, +]; + +const MUTED_OPTIONS = [ + { name: I18n.t("chat.settings.muted_on"), value: true }, + { name: I18n.t("chat.settings.muted_off"), value: false }, +]; + +export default class ChatChannelSettingsView extends Component { + @service chat; + @service router; + @service dialog; + tagName = ""; + channel = null; + + notificationLevels = NOTIFICATION_LEVELS; + mutedOptions = MUTED_OPTIONS; + isSavingNotificationSetting = false; + savedDesktopNotificationLevel = false; + savedMobileNotificationLevel = false; + savedMuted = false; + + _updateAutoJoinUsers(value) { + return ChatApi.modifyChatChannel(this.channel.id, { + auto_join_users: value, + }) + .then((chatChannel) => { + this.channel.set("auto_join_users", chatChannel.auto_join_users); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + saveNotificationSettings(key, value) { + if (this.channel[key] === value) { + return; + } + + const camelizedKey = camelize(`saved_${key}`); + this.set(camelizedKey, false); + + const settings = {}; + settings[key] = value; + return ChatApi.updateChatChannelNotificationsSettings( + this.channel.id, + settings + ) + .then((membership) => { + this.channel.current_user_membership.setProperties({ + muted: membership.muted, + desktop_notification_level: membership.desktop_notification_level, + mobile_notification_level: membership.mobile_notification_level, + }); + this.set(camelizedKey, true); + }) + .finally(() => { + discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set(camelizedKey, false); + }, 2000); + }); + } + + @computed( + "siteSettings.chat_allow_archiving_channels", + "channel.{isArchived,isReadOnly}" + ) + get canArchiveChannel() { + return ( + this.siteSettings.chat_allow_archiving_channels && + !this.channel.isArchived && + !this.channel.isReadOnly + ); + } + + @computed("channel.isCategoryChannel") + get autoJoinAvailable() { + return ( + this.siteSettings.max_chat_auto_joined_users > 0 && + this.channel.isCategoryChannel + ); + } + + @action + onArchiveChannel() { + const controller = showModal("chat-channel-archive-modal"); + controller.set("chatChannel", this.channel); + } + + @action + onDeleteChannel() { + const controller = showModal("chat-channel-delete-modal"); + controller.set("chatChannel", this.channel); + } + + @action + onToggleChannelState() { + const controller = showModal("chat-channel-toggle"); + controller.set("chatChannel", this.channel); + } + + @action + onDisableAutoJoinUsers() { + this._updateAutoJoinUsers(false); + } + + @action + onEnableAutoJoinUsers() { + this.dialog.confirm({ + message: I18n.t("chat.settings.auto_join_users_warning", { + category: this.channel.chatable.name, + }), + didConfirm: () => this._updateAutoJoinUsers(true), + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js new file mode 100644 index 0000000000..f6048b21b9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js @@ -0,0 +1,57 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import Component from "@ember/component"; +import { + CHANNEL_STATUSES, + channelStatusIcon, + channelStatusName, +} from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + format: null, + + init() { + this._super(...arguments); + if (!["short", "long"].includes(this.format)) { + this.set("format", "long"); + } + }, + + @discourseComputed("channel.status") + channelStatusMessage(channelStatus) { + if (channelStatus === CHANNEL_STATUSES.open) { + return null; + } + + if (this.format === "long") { + return this._longStatusMessage(channelStatus); + } else { + return this._shortStatusMessage(channelStatus); + } + }, + + @discourseComputed("channel.status") + channelStatusIcon(channelStatus) { + return channelStatusIcon(channelStatus); + }, + + _shortStatusMessage(channelStatus) { + return channelStatusName(channelStatus); + }, + + _longStatusMessage(channelStatus) { + switch (channelStatus) { + case CHANNEL_STATUSES.closed: + return I18n.t("chat.channel_status.closed_header"); + break; + case CHANNEL_STATUSES.readOnly: + return I18n.t("chat.channel_status.read_only_header"); + break; + case CHANNEL_STATUSES.archived: + return I18n.t("chat.channel_status.archived_header"); + break; + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js new file mode 100644 index 0000000000..fc261ddcfa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js @@ -0,0 +1,23 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { computed } from "@ember/object"; +import { gt, reads } from "@ember/object/computed"; + +export default class ChatChannelTitle extends Component { + tagName = ""; + channel = null; + unreadIndicator = false; + + @reads("channel.chatable.users.[]") users; + @gt("users.length", 1) multiDm; + + @computed("users") + get usernames() { + return this.users.mapBy("username").join(", "); + } + + @computed("channel.chatable.color") + get channelColorStyle() { + return htmlSafe(`color: #${this.channel.chatable.color}`); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js new file mode 100644 index 0000000000..87c0cfd9b2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js @@ -0,0 +1,62 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import I18n from "I18n"; +import { action, computed } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default class ChatChannelToggleView extends Component { + @service chat; + @service router; + tagName = ""; + channel = null; + onStatusChange = null; + + @computed("channel.isClosed") + get buttonLabel() { + if (this.channel.isClosed) { + return "chat.channel_settings.open_channel"; + } else { + return "chat.channel_settings.close_channel"; + } + } + + @computed("channel.isClosed") + get instructions() { + if (this.channel.isClosed) { + return htmlSafe(I18n.t("chat.channel_open.instructions")); + } else { + return htmlSafe(I18n.t("chat.channel_close.instructions")); + } + } + + @computed("channel.isClosed") + get modalTitle() { + if (this.channel.isClosed) { + return "chat.channel_open.title"; + } else { + return "chat.channel_close.title"; + } + } + + @action + changeChannelStatus() { + const status = this.channel.isClosed + ? CHANNEL_STATUSES.open + : CHANNEL_STATUSES.closed; + + return ajax(`/chat/chat_channels/${this.channel.id}/change_status.json`, { + method: "PUT", + data: { status }, + }) + .then(() => { + this.channel.set("status", status); + }) + .catch(popupAjaxError) + .finally(() => { + this.onStatusChange?.(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js new file mode 100644 index 0000000000..f24cd64a31 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js @@ -0,0 +1,46 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { equal, gt } from "@ember/object/computed"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + + isDirectMessage: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + + hasUnread: gt("unreadCount", 0), + + @discourseComputed( + "currentUser.chat_channel_tracking_state.@each.{unread_count,unread_mentions}", + "channel.id" + ) + channelTrackingState(state, channelId) { + return state?.[channelId]; + }, + + @discourseComputed( + "channelTrackingState.unread_mentions", + "channel", + "isDirectMessage" + ) + isUrgent(unreadMentions, channel, isDirectMessage) { + if (!channel) { + return; + } + + return isDirectMessage || unreadMentions > 0; + }, + + @discourseComputed("channelTrackingState.unread_count", "channel") + unreadCount(unreadCount, channel) { + if (!channel) { + return; + } + + return unreadCount || 0; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js new file mode 100644 index 0000000000..36dad78ae3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js @@ -0,0 +1,7 @@ +import Component from "@ember/component"; + +export default class ChatComposerDropdown extends Component { + tagName = ""; + buttons = null; + isDisabled = false; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js new file mode 100644 index 0000000000..88361c2939 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default class ChatComposerInlineButtons extends Component { + tagName = ""; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js new file mode 100644 index 0000000000..44494409ab --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default Component.extend({ + tagName: "", +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js new file mode 100644 index 0000000000..a2a89a2119 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js @@ -0,0 +1,25 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import { isImage } from "discourse/lib/uploads"; + +export default Component.extend({ + IMAGE_TYPE: "image", + + tagName: "", + classNames: "chat-upload", + isDone: false, + upload: null, + onCancel: null, + + @discourseComputed("upload.{original_filename,fileName}") + type(upload) { + if (isImage(upload.original_filename || upload.fileName)) { + return this.IMAGE_TYPE; + } + }, + + @discourseComputed("isDone", "upload.{original_filename,fileName}") + fileName(isDone, upload) { + return isDone ? upload.original_filename : upload.fileName; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js new file mode 100644 index 0000000000..dba574f963 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js @@ -0,0 +1,132 @@ +import Component from "@ember/component"; +import { clipboardHelpers } from "discourse/lib/utilities"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; +import UppyUploadMixin from "discourse/mixins/uppy-upload"; + +export default Component.extend(UppyUploadMixin, { + classNames: ["chat-composer-uploads"], + mediaOptimizationWorker: service(), + chatStateManager: service(), + id: "chat-composer-uploader", + type: "chat-composer", + uploads: null, + useMultipartUploadsIfAvailable: true, + + init() { + this._super(...arguments); + this.setProperties({ + uploads: [], + fileInputSelector: `#${this.fileUploadElementId}`, + }); + this.appEvents.on("chat-composer:load-uploads", this, "_loadUploads"); + }, + + didInsertElement() { + this._super(...arguments); + this.composerInputEl = document.querySelector(".chat-composer-input"); + this.composerInputEl?.addEventListener("paste", this._pasteEventListener); + }, + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat-composer:load-uploads", this, "_loadUploads"); + this.composerInputEl?.removeEventListener( + "paste", + this._pasteEventListener + ); + }, + + uploadDone(upload) { + this.uploads.pushObject(upload); + this.onUploadChanged(this.uploads); + }, + + @discourseComputed("uploads.length", "inProgressUploads.length") + showUploadsContainer(uploadsCount, inProgressUploadsCount) { + return uploadsCount > 0 || inProgressUploadsCount > 0; + }, + + @action + cancelUploading(upload) { + this.appEvents.trigger(`upload-mixin:${this.id}:cancel-upload`, { + fileId: upload.id, + }); + this.uploads.removeObject(upload); + this.onUploadChanged(this.uploads); + }, + + @action + removeUpload(upload) { + this.uploads.removeObject(upload); + this.onUploadChanged(this.uploads); + }, + + _uploadDropTargetOptions() { + let targetEl; + if (this.chatStateManager.isFullPage) { + targetEl = document.querySelector(".full-page-chat"); + } else { + targetEl = document.querySelector( + ".topic-chat-container.expanded.visible" + ); + } + + if (!targetEl) { + return this._super(); + } + + return { + target: targetEl, + }; + }, + + _loadUploads(uploads) { + this._uppyInstance?.cancelAll(); + this.set("uploads", uploads); + }, + + _uppyReady() { + if (this.siteSettings.composer_media_optimization_image_enabled) { + this._useUploadPlugin(UppyMediaOptimization, { + optimizeFn: (data, opts) => + this.mediaOptimizationWorker.optimizeImage(data, opts), + runParallel: !this.site.isMobileDevice, + }); + } + + this._onPreProcessProgress((file) => { + const inProgressUpload = this.inProgressUploads.findBy("id", file.id); + if (!inProgressUpload?.processing) { + inProgressUpload?.set("processing", true); + } + }); + + this._onPreProcessComplete((file) => { + const inProgressUpload = this.inProgressUploads.findBy("id", file.id); + inProgressUpload?.set("processing", false); + }); + }, + + @bind + _pasteEventListener(event) { + if (document.activeElement !== this.composerInputEl) { + return; + } + + const { canUpload, canPasteHtml, types } = clipboardHelpers(event, { + siteSettings: this.siteSettings, + canUpload: true, + }); + + if (!canUpload || canPasteHtml || types.includes("text/plain")) { + return; + } + + if (event && event.clipboardData && event.clipboardData.files) { + this._addFiles([...event.clipboardData.files], { pasted: true }); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js new file mode 100644 index 0000000000..bb9f68be19 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -0,0 +1,732 @@ +import { isEmpty } from "@ember/utils"; +import Component from "@ember/component"; +import showModal from "discourse/lib/show-modal"; +import discourseComputed, { + afterRender, + bind, +} from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; +import userSearch from "discourse/lib/user-search"; +import { action } from "@ember/object"; +import { cancel, next, schedule, throttle } from "@ember/runloop"; +import { cloneJSON } from "discourse-common/lib/object"; +import { findRawTemplate } from "discourse-common/lib/raw-templates"; +import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; +import { emojiUrlFor } from "discourse/lib/text"; +import { inject as service } from "@ember/service"; +import { readOnly, reads } from "@ember/object/computed"; +import { SKIP } from "discourse/lib/autocomplete"; +import { Promise } from "rsvp"; +import { translations } from "pretty-text/emoji/data"; +import { channelStatusName } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; +import { + chatComposerButtons, + chatComposerButtonsDependentKeys, +} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +const THROTTLE_MS = 150; + +export default Component.extend(TextareaTextManipulation, { + chatChannel: null, + lastChatChannelId: null, + chat: service(), + classNames: ["chat-composer-container"], + classNameBindings: ["emojiPickerVisible:with-emoji-picker"], + userSilenced: readOnly("details.user_silenced"), + chatEmojiReactionStore: service("chat-emoji-reaction-store"), + chatEmojiPickerManager: service("chat-emoji-picker-manager"), + chatStateManager: service("chat-state-manager"), + editingMessage: null, + onValueChange: null, + timer: null, + value: "", + inProgressUploads: null, + composerEventPrefix: "chat", + composerFocusSelector: ".chat-composer-input", + canAttachUploads: reads("siteSettings.chat_allow_uploads"), + isNetworkUnreliable: reads("chat.isNetworkUnreliable"), + + @discourseComputed(...chatComposerButtonsDependentKeys()) + inlineButtons() { + return chatComposerButtons(this, "inline"); + }, + + @discourseComputed(...chatComposerButtonsDependentKeys()) + dropdownButtons() { + return chatComposerButtons(this, "dropdown"); + }, + + @discourseComputed("chatEmojiPickerManager.{opened,context}") + emojiPickerVisible(picker) { + return picker.opened && picker.context === "chat-composer"; + }, + + @discourseComputed("chatStateManager.isFullPage") + fileUploadElementId(fullPage) { + return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader"; + }, + + init() { + this._super(...arguments); + + this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged"); + this.appEvents.on( + "upload-mixin:chat-composer-uploader:in-progress-uploads", + this, + "_inProgressUploadsChanged" + ); + + this.setProperties({ + inProgressUploads: [], + _uploads: [], + }); + }, + + didInsertElement() { + this._super(...arguments); + + this._textarea = this.element.querySelector(".chat-composer-input"); + this._$textarea = $(this._textarea); + this._applyCategoryHashtagAutocomplete(this._$textarea); + this._applyEmojiAutocomplete(this._$textarea); + this.appEvents.on("chat:focus-composer", this, "_focusTextArea"); + this.appEvents.on("chat:insert-text", this, "insertText"); + this._focusTextArea(); + + this.appEvents.on("chat:modify-selection", this, "_modifySelection"); + this.appEvents.on( + "chat:open-insert-link-modal", + this, + "_openInsertLinkModal" + ); + document.addEventListener("visibilitychange", this._blurInput); + document.addEventListener("resume", this._blurInput); + document.addEventListener("freeze", this._blurInput); + + this.set("ready", true); + }, + + _modifySelection(opts = { type: null }) { + const sel = this.getSelected("", { lineVal: true }); + if (opts.type === "bold") { + this.applySurround(sel, "**", "**", "bold_text"); + } else if (opts.type === "italic") { + this.applySurround(sel, "_", "_", "italic_text"); + } else if (opts.type === "code") { + this.applySurround(sel, "`", "`", "code_text"); + } + }, + + _openInsertLinkModal() { + const selected = this.getSelected("", { lineVal: true }); + const linkText = selected?.value; + showModal("insert-hyperlink").setProperties({ + linkText, + toolbarEvent: { + addText: (text) => this.addText(selected, text), + }, + }); + }, + + willDestroyElement() { + this._super(...arguments); + + this.appEvents.off( + "chat-composer:reply-to-set", + this, + "_replyToMsgChanged" + ); + this.appEvents.off( + "upload-mixin:chat-composer-uploader:in-progress-uploads", + this, + "_inProgressUploadsChanged" + ); + + if (this.timer) { + cancel(this.timer); + this.timer = null; + } + + this.appEvents.off("chat:focus-composer", this, "_focusTextArea"); + this.appEvents.off("chat:insert-text", this, "insertText"); + this.appEvents.off("chat:modify-selection", this, "_modifySelection"); + this.appEvents.off( + "chat:open-insert-link-modal", + this, + "_openInsertLinkModal" + ); + document.removeEventListener("visibilitychange", this._blurInput); + document.removeEventListener("resume", this._blurInput); + document.removeEventListener("freeze", this._blurInput); + }, + + // It is important that this is keyDown and not keyUp, otherwise + // we add new lines to chat message on send and on edit, because + // you cannot prevent default with a keyUp event -- it is like trying + // to shut the gate after the horse has already bolted! + keyDown(event) { + if (this.site.mobileView || event.altKey || event.metaKey) { + return; + } + + // keyCode for 'Enter' + if (event.keyCode === 13) { + if (event.shiftKey) { + // Shift+Enter: insert newline + return; + } + + // Ctrl+Enter, plain Enter: send + if (!event.ctrlKey) { + // if we are inside a code block just insert newline + const { pre } = this.getSelected(null, { lineVal: true }); + if (this.isInside(pre, /(^|\n)```/g)) { + return; + } + } + + this.sendClicked(); + return false; + } + + if ( + event.key === "ArrowUp" && + this._messageIsEmpty() && + !this.editingMessage + ) { + event.preventDefault(); + this.onEditLastMessageRequested(); + } + + if (event.keyCode === 27) { + // keyCode for 'Escape' + if (this.replyToMsg) { + this.set("value", ""); + this._replyToMsgChanged(null); + return false; + } else if (this.editingMessage) { + this.set("value", ""); + this.cancelEditing(); + return false; + } else { + this._textarea.blur(); + } + } + }, + + didReceiveAttrs() { + this._super(...arguments); + + if ( + !this.editingMessage && + this.draft && + this.chatChannel?.canModifyMessages(this.currentUser) + ) { + // uses uploads from draft here... + this.setProperties({ + value: this.draft.value, + replyToMsg: this.draft.replyToMsg, + }); + + this._syncUploads(this.draft.uploads); + this.setInReplyToMsg(this.draft.replyToMsg); + } + + if (this.editingMessage && !this.loading) { + this.setProperties({ + replyToMsg: null, + value: this.editingMessage.message, + }); + + this._syncUploads(this.editingMessage.uploads); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); + } + + this.set("lastChatChannelId", this.chatChannel.id); + this.resizeTextarea(); + }, + + // the chat-composer needs to be able to set the internal list of uploads + // for chat-composer-uploads to preload in existing uploads for drafts + // and for when messages are being edited. + // + // the opposite is true as well -- when an upload is completed the chat-composer + // needs its internal state updated so drafts can be saved, which is handled + // by the uploadsChanged action + _syncUploads(newUploads = []) { + const currentUploadIds = this._uploads.mapBy("id"); + const newUploadIds = newUploads.mapBy("id"); + + // don't need to load the uploads into chat-composer-uploads if + // nothing has changed otherwise we would rerender for no reason + if ( + currentUploadIds.length === newUploadIds.length && + newUploadIds.every((newUploadId) => + currentUploadIds.includes(newUploadId) + ) + ) { + return; + } + + this.set("_uploads", cloneJSON(newUploads)); + this.appEvents.trigger("chat-composer:load-uploads", this._uploads); + }, + + _inProgressUploadsChanged(inProgressUploads) { + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("inProgressUploads", inProgressUploads); + }); + }, + + _replyToMsgChanged(replyToMsg) { + this.set("replyToMsg", replyToMsg); + this.onValueChange?.(this.value, this._uploads, replyToMsg); + }, + + @action + onTextareaInput(value) { + this.set("value", value); + this.resizeTextarea(); + + // throttle, not debounce, because we do eventually want to react during the typing + this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS); + }, + + @bind + _handleTextareaInput() { + this._applyUserAutocomplete(); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @bind + _blurInput() { + document.activeElement?.blur(); + }, + + @action + uploadClicked() { + this.element.querySelector(`#${this.fileUploadElementId}`).click(); + }, + + @bind + didSelectEmoji(emoji) { + const code = `:${emoji}:`; + this.chatEmojiReactionStore.track(code); + this.addText(this.getSelected(), code); + }, + + @action + insertDiscourseLocalDate() { + showModal("discourse-local-dates-create-modal").setProperties({ + insertDate: (markup) => { + this.addText(this.getSelected(), markup); + }, + }); + }, + + // text-area-manipulation mixin override + addText() { + this._super(...arguments); + + this.resizeTextarea(); + }, + + _applyUserAutocomplete() { + if (this.siteSettings.enable_mentions) { + $(this._textarea).autocomplete({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: true, + transformComplete: (v) => v.username || v.name, + dataSource: (term) => userSearch({ term, includeGroups: true }), + afterComplete: (text) => { + this.set("value", text); + this._focusTextArea(); + }, + }); + } + }, + + _applyCategoryHashtagAutocomplete($textarea) { + setupHashtagAutocomplete( + "chat-composer", + $textarea, + this.siteSettings, + (value) => { + this.set("value", value); + return this._focusTextArea(); + } + ); + }, + + _applyEmojiAutocomplete($textarea) { + if (!this.siteSettings.enable_emoji) { + return; + } + + $textarea.autocomplete({ + template: findRawTemplate("emoji-selector-autocomplete"), + key: ":", + afterComplete: (text) => { + this.set("value", text); + this._focusTextArea(); + }, + treatAsTextarea: true, + + onKeyUp: (text, cp) => { + const matches = + /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( + text.substring(0, cp) + ); + + if (matches && matches[1]) { + return [matches[1]]; + } + }, + + transformComplete: (v) => { + if (v.code) { + this.chatEmojiReactionStore.track(v.code); + return `${v.code}:`; + } else { + $textarea.autocomplete({ cancel: true }); + this.set("emojiPickerIsActive", true); + return ""; + } + }, + + dataSource: (term) => { + return new Promise((resolve) => { + const full = `:${term}`; + term = term.toLowerCase(); + + // We need to avoid quick emoji autocomplete cause it can interfere with quick + // typing, set minimal length to 2 + let minLength = Math.max( + this.siteSettings.emoji_autocomplete_min_chars, + 2 + ); + + if (term.length < minLength) { + return resolve(SKIP); + } + + // bypass :-p and other common typed smileys + if ( + !term.match( + /[^-\{\}\[\]\(\)\*_\<\>\\\/].*[^-\{\}\[\]\(\)\*_\<\>\\\/]/ + ) + ) { + return resolve(SKIP); + } + + if (term === "") { + if (this.chatEmojiReactionStore.favorites.length) { + return resolve(this.chatEmojiReactionStore.favorites.slice(0, 5)); + } else { + return resolve([ + "slight_smile", + "smile", + "wink", + "sunny", + "blush", + ]); + } + } + + // note this will only work for emojis starting with : + // eg: :-) + const emojiTranslation = + this.get("site.custom_emoji_translation") || {}; + const allTranslations = Object.assign( + {}, + translations, + emojiTranslation + ); + if (allTranslations[full]) { + return resolve([allTranslations[full]]); + } + + const match = term.match(/^:?(.*?):t([2-6])?$/); + if (match) { + const name = match[1]; + const scale = match[2]; + + if (isSkinTonableEmoji(name)) { + if (scale) { + return resolve([`${name}:t${scale}`]); + } else { + return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`)); + } + } + } + + const options = emojiSearch(term, { + maxResults: 5, + diversity: this.chatEmojiReactionStore.diversity, + }); + + return resolve(options); + }) + .then((list) => { + if (list === SKIP) { + return; + } + return list.map((code) => ({ code, src: emojiUrlFor(code) })); + }) + .then((list) => { + if (list?.length) { + list.push({ label: I18n.t("composer.more_emoji"), term }); + } + return list; + }); + }, + }); + }, + + @afterRender + _focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) { + if (this.chatChannel.isDraft) { + return; + } + + if (!this._textarea) { + return; + } + + if (opts.resizeTextarea) { + this.resizeTextarea(); + } + + if (opts.ensureAtEnd) { + this._textarea.setSelectionRange(this.value.length, this.value.length); + } + + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + schedule("afterRender", () => { + this._textarea?.focus(); + }); + }, + + @action + onEmojiSelected(code) { + this.emojiSelected(code); + this.set("emojiPickerIsActive", false); + }, + + @discourseComputed( + "chatChannel.{id,chatable.users.[]}", + "canInteractWithChat" + ) + disableComposer(channel, canInteractWithChat) { + return ( + (channel.isDraft && isEmpty(channel?.chatable?.users)) || + !canInteractWithChat || + !channel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}") + placeholder(userSilenced, chatChannel) { + if (!chatChannel.canModifyMessages(this.currentUser)) { + return I18n.t("chat.placeholder_new_message_disallowed", { + status: channelStatusName(chatChannel.status).toLowerCase(), + }); + } + + if (chatChannel.isDraft) { + return I18n.t("chat.placeholder_start_conversation", { + usernames: chatChannel?.chatable?.users?.length + ? chatChannel.chatable.users.mapBy("username").join(", ") + : "...", + }); + } + + if (userSilenced) { + return I18n.t("chat.placeholder_silenced"); + } else { + return this.messageRecipient(chatChannel); + } + }, + + messageRecipient(chatChannel) { + if (chatChannel.isDirectMessageChannel) { + const directMessageRecipients = chatChannel.chatable.users; + if ( + directMessageRecipients.length === 1 && + directMessageRecipients[0].id === this.currentUser.id + ) { + return I18n.t("chat.placeholder_self"); + } + + return I18n.t("chat.placeholder_others", { + messageRecipient: directMessageRecipients + .map((u) => u.name || `@${u.username}`) + .join(", "), + }); + } else { + return I18n.t("chat.placeholder_others", { + messageRecipient: `#${chatChannel.title}`, + }); + } + }, + + @discourseComputed( + "value", + "loading", + "disableComposer", + "inProgressUploads.[]" + ) + sendDisabled(value, loading, disableComposer, inProgressUploads) { + if (loading || disableComposer || inProgressUploads.length > 0) { + return true; + } + + return !this._messageIsValid(); + }, + + @action + sendClicked() { + if (this.site.mobileView) { + // prevents android to hide the keyboard after sending a message + // we do a focusTextarea later but it's too late for android + document.querySelector(this.composerFocusSelector).focus(); + } + + if (this.sendDisabled) { + return; + } + + this.editingMessage + ? this.internalEditMessage() + : this.internalSendMessage(); + }, + + @action + internalSendMessage() { + return this.sendMessage(this.value, this._uploads).then(this.reset); + }, + + @action + internalEditMessage() { + return this.editMessage( + this.editingMessage, + this.value, + this._uploads + ).then(this.reset); + }, + + _messageIsValid() { + const validLength = + (this.value || "").trim().length >= + (this.siteSettings.chat_minimum_message_length || 0); + + if (this.canAttachUploads) { + if (this._messageIsEmpty()) { + // If message is empty, an an upload must present for sending to be enabled + return this._uploads.length; + } else { + // Message is non-empty. Make sure it's long enough to be valid. + return validLength; + } + } + + // Attachments are disabled so for a message to be valid it must be long enough. + return validLength; + }, + + _messageIsEmpty() { + return (this.value || "").trim() === ""; + }, + + @action + reset() { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.setProperties({ + value: "", + inReplyMsg: null, + }); + this._syncUploads([]); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + cancelReplyTo() { + this.set("replyToMsg", null); + this.setInReplyToMsg(null); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + cancelEditing() { + this.onCancelEditing(); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); + }, + + _cursorIsOnEmptyLine() { + const selectionStart = this._textarea.selectionStart; + if (selectionStart === 0) { + return true; + } else if (this._textarea.value.charAt(selectionStart - 1) === "\n") { + return true; + } else { + return false; + } + }, + + @action + uploadsChanged(uploads) { + this.set("_uploads", cloneJSON(uploads)); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + onTextareaFocusIn(target) { + if (!this.capabilities.isIOS) { + return; + } + + // hack to prevent the whole viewport + // to move on focus input + target = document.querySelector(".chat-composer-input"); + target.style.transform = "translateY(-99999px)"; + target.focus(); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + target.style.transform = ""; + }); + }); + }, + + @action + resizeTextarea() { + schedule("afterRender", () => { + if (!this._textarea) { + return; + } + + // this is a quirk which forces us to `auto` first or textarea + // won't resize + this._textarea.style.height = "auto"; + + // +1 is to workaround a rounding error visible on electron + // causing scrollbars to show when they shouldn’t + this._textarea.style.height = this._textarea.scrollHeight + 1 + "px"; + }); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js new file mode 100644 index 0000000000..e374564c35 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js @@ -0,0 +1,53 @@ +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { inject as service } from "@ember/service"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { cloneJSON } from "discourse-common/lib/object"; +export default class ChatDraftChannelScreen extends Component { + @service chat; + @service router; + tagName = ""; + onSwitchChannel = null; + + @action + onCancelChatDraft() { + return this.router.transitionTo("chat.index"); + } + + @action + onChangeSelectedUsers(users) { + this._fetchPreviewedChannel(users); + } + + @action + onSwitchFromDraftChannel(channel) { + channel.set("isDraft", false); + this.onSwitchChannel?.(channel); + } + + _fetchPreviewedChannel(users) { + this.set("previewedChannel", null); + + return this.chat + .getDmChannelForUsernames(users.mapBy("username")) + .then((response) => { + this.set( + "previewedChannel", + ChatChannel.create( + Object.assign({}, response.chat_channel, { isDraft: true }) + ) + ); + }) + .catch((error) => { + if (error?.jqXHR?.status === 404) { + this.set( + "previewedChannel", + ChatChannel.create({ + chatable: { users: cloneJSON(users) }, + isDraft: true, + }) + ); + } + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js new file mode 100644 index 0000000000..4395e95d44 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -0,0 +1,368 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { emojiUrlFor } from "discourse/lib/text"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { bind } from "discourse-common/utils/decorators"; +import { later, schedule } from "@ember/runloop"; + +export const FITZPATRICK_MODIFIERS = [ + { + scale: 1, + modifier: null, + }, + { + scale: 2, + modifier: ":t2", + }, + { + scale: 3, + modifier: ":t3", + }, + { + scale: 4, + modifier: ":t4", + }, + { + scale: 5, + modifier: ":t5", + }, + { + scale: 6, + modifier: ":t6", + }, +]; + +export default class ChatEmojiPicker extends Component { + @service chatEmojiPickerManager; + @service emojiPickerScrollObserver; + @service chatEmojiReactionStore; + @tracked filteredEmojis = null; + @tracked isExpandedFitzpatrickScale = false; + tagName = ""; + + fitzpatrickModifiers = FITZPATRICK_MODIFIERS; + + get groups() { + const emojis = this.chatEmojiPickerManager.emojis; + const favorites = { + favorites: this.chatEmojiReactionStore.favorites.map((name) => { + return { + name, + group: "favorites", + url: emojiUrlFor(name), + }; + }), + }; + + return { + ...favorites, + ...emojis, + }; + } + + get flatEmojis() { + // eslint-disable-next-line no-unused-vars + let { favorites, ...rest } = this.chatEmojiPickerManager.emojis; + return Object.values(rest).flat(); + } + + get navIndicatorStyle() { + const section = this.chatEmojiPickerManager.lastVisibleSection; + const index = Object.keys(this.groups).indexOf(section); + + return htmlSafe( + `width: ${ + 100 / Object.keys(this.groups).length + }%; transform: translateX(${index * 100}%);` + ); + } + + get navBtnStyle() { + return htmlSafe(`width: ${100 / Object.keys(this.groups).length}%;`); + } + + @action + didPressEscape(event) { + if (event.key === "Escape") { + this.chatEmojiPickerManager.close(); + } + } + + @action + didNavigateFitzpatrickScale(event) { + if (event.type !== "keyup") { + return; + } + + const scaleNodes = + event.target + .closest(".chat-emoji-picker__fitzpatrick-scale") + ?.querySelectorAll(".chat-emoji-picker__fitzpatrick-modifier-btn") || + []; + + const scales = [...scaleNodes]; + + if (event.key === "ArrowRight") { + event.preventDefault(); + + if (event.target === scales[scales.length - 1]) { + scales[0].focus(); + } else { + event.target.nextElementSibling?.focus(); + } + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + + if (event.target === scales[0]) { + scales[scales.length - 1].focus(); + } else { + event.target.previousElementSibling?.focus(); + } + } + } + + @action + didToggleFitzpatrickScale(event) { + if (event.type === "keyup") { + if (event.key === "Escape") { + event.preventDefault(); + this.isExpandedFitzpatrickScale = false; + return; + } + + if (event.key !== "Enter") { + return; + } + } + + this.toggleProperty("isExpandedFitzpatrickScale"); + } + + @action + didRequestFitzpatrickScale(scale, event) { + if (event.type === "keyup") { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.isExpandedFitzpatrickScale = false; + this._focusCurrentFitzpatrickScale(); + return; + } + + if (event.key !== "Enter") { + return; + } + } + + event.preventDefault(); + event.stopPropagation(); + + this.isExpandedFitzpatrickScale = false; + this.chatEmojiReactionStore.diversity = scale; + this._focusCurrentFitzpatrickScale(); + } + + _focusCurrentFitzpatrickScale() { + schedule("afterRender", () => { + document + .querySelector(".chat-emoji-picker__fitzpatrick-modifier-btn.current") + ?.focus(); + }); + } + + @action + didInputFilter(event) { + if (!event.target.value.length) { + this.filteredEmojis = null; + return; + } + + discourseDebounce( + this, + this.debouncedDidInputFilter, + event.target.value, + INPUT_DELAY + ); + } + + @action + focusFilter(target) { + target.focus(); + } + + debouncedDidInputFilter(filter = "") { + filter = filter.toLowerCase(); + + this.filteredEmojis = this.flatEmojis.filter( + (emoji) => + emoji.name.toLowerCase().includes(filter) || + emoji.search_aliases?.any((alias) => + alias.toLowerCase().includes(filter) + ) + ); + + schedule("afterRender", () => { + const scrollableContent = document.querySelector( + ".chat-emoji-picker__scrollable-content" + ); + + if (scrollableContent) { + scrollableContent.scrollTop = 0; + } + }); + } + + @action + didNavigateSection(event) { + if (event.type !== "keyup") { + return; + } + + const sectionEmojis = [ + ...event.target + .closest(".chat-emoji-picker__section") + .querySelectorAll(".emoji"), + ]; + + if (event.key === "ArrowRight") { + event.preventDefault(); + + if (event.target === sectionEmojis[sectionEmojis.length - 1]) { + sectionEmojis[0].focus(); + } else { + event.target.nextElementSibling?.focus(); + } + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + + if (event.target === sectionEmojis[0]) { + sectionEmojis[sectionEmojis.length - 1].focus(); + } else { + event.target.previousElementSibling?.focus(); + } + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + + sectionEmojis + .filter((c) => c.offsetTop > event.target.offsetTop) + .find((c) => c.offsetLeft === event.target.offsetLeft) + ?.focus(); + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + + sectionEmojis + .reverse() + .filter((c) => c.offsetTop < event.target.offsetTop) + .find((c) => c.offsetLeft === event.target.offsetLeft) + ?.focus(); + } + } + + @action + didSelectEmoji(event) { + if (!event.target.classList.contains("emoji")) { + return; + } + + if ( + event.type === "click" || + (event.type === "keyup" && event.key === "Enter") + ) { + event.preventDefault(); + event.stopPropagation(); + const originalTarget = event.target; + let emoji = event.target.dataset.emoji; + const tonable = event.target.dataset.tonable; + const diversity = this.chatEmojiReactionStore.diversity; + if (tonable && diversity > 1) { + emoji = `${emoji}:t${diversity}`; + } + + this.chatEmojiPickerManager.didSelectEmoji(emoji); + + schedule("afterRender", () => { + originalTarget.focus(); + }); + } + } + + @action + didFocusFirstEmoji(event) { + event.preventDefault(); + const section = event.target.closest(".chat-emoji-picker__section").dataset + .section; + this.didRequestSection(section); + } + + @action + didRequestSection(section) { + const scrollableContent = document.querySelector( + ".chat-emoji-picker__scrollable-content" + ); + + this.filteredEmojis = null; + + // we disable scroll listener during requesting section + // to avoid it from detecting another section during scroll to requested section + this.emojiPickerScrollObserver.enabled = false; + this.chatEmojiPickerManager.addVisibleSections([section]); + this.chatEmojiPickerManager.lastVisibleSection = section; + + // iOS hack to avoid blank div when requesting section during momentum + if (scrollableContent && this.capabilities.isIOS) { + document.querySelector( + ".chat-emoji-picker__scrollable-content" + ).style.overflow = "hidden"; + } + + schedule("afterRender", () => { + document + .querySelector(`.chat-emoji-picker__section[data-section="${section}"]`) + .scrollIntoView({ + behavior: "auto", + block: "start", + inline: "nearest", + }); + + later(() => { + // iOS hack to avoid blank div when requesting section during momentum + if (scrollableContent && this.capabilities.isIOS) { + document.querySelector( + ".chat-emoji-picker__scrollable-content" + ).style.overflow = "scroll"; + } + + this.emojiPickerScrollObserver.enabled = true; + }, 200); + }); + } + + @action + addClickOutsideEventListener() { + document.addEventListener("click", this.didClickOutside); + } + + @action + removeClickOutsideEventListener() { + document.removeEventListener("click", this.didClickOutside); + } + + @bind + didClickOutside(event) { + if (!event.target.closest(".chat-emoji-picker")) { + this.chatEmojiPickerManager.close(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js new file mode 100644 index 0000000000..c23974fa4e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -0,0 +1,1493 @@ +import isElementInViewport from "discourse/lib/is-element-in-viewport"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import { cloneJSON } from "discourse-common/lib/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import Component from "@ember/component"; +import discourseComputed, { + afterRender, + bind, + observes, +} from "discourse-common/utils/decorators"; +import discourseDebounce from "discourse-common/lib/debounce"; +import EmberObject, { action } from "@ember/object"; +import I18n from "I18n"; +import { A } from "@ember/array"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cancel, next, schedule, throttle } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; +import { inject as service } from "@ember/service"; +import { Promise } from "rsvp"; +import { resetIdle } from "discourse/lib/desktop-notifications"; +import { capitalize } from "@ember/string"; +import { + onPresenceChange, + removeOnPresenceChange, +} from "discourse/lib/user-presence"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; +import { isTesting } from "discourse-common/config/environment"; + +const MAX_RECENT_MSGS = 100; +const STICKY_SCROLL_LENIENCE = 50; +const PAGE_SIZE = 50; + +const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 100; +const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500; + +const PAST = "past"; +const FUTURE = "future"; + +export default Component.extend({ + classNameBindings: [":chat-live-pane", "sendingLoading", "loading"], + chatChannel: null, + registeredChatChannelId: null, // ?Number + loading: false, + loadingMorePast: false, + loadingMoreFuture: false, + hoveredMessageId: null, + onSwitchChannel: null, + + allPastMessagesLoaded: false, + sendingLoading: false, + selectingMessages: false, + stickyScroll: true, + stickyScrollTimer: null, + showChatQuoteSuccess: false, + showCloseFullScreenBtn: false, + includeHeader: true, + + editingMessage: null, // ?Message + replyToMsg: null, // ?Message + details: null, // Object { chat_channel_id, ... } + messages: null, // Array + messageLookup: null, // Object + _unloadedReplyIds: null, // Array + _nextStagedMessageId: 0, // Iterate on every new message + _lastSelectedMessage: null, + targetMessageId: null, + hasNewMessages: null, + + chat: service(), + router: service(), + chatEmojiPickerManager: service(), + chatComposerPresenceManager: service(), + chatStateManager: service(), + + getCachedChannelDetails: null, + clearCachedChannelDetails: null, + _scrollerEl: null, + + init() { + this._super(...arguments); + + this.set("messages", []); + }, + + didInsertElement() { + this._super(...arguments); + + this._unloadedReplyIds = []; + this.appEvents.on( + "chat-live-pane:highlight-message", + this, + "highlightOrFetchMessage" + ); + + this._scrollerEl = this.element.querySelector(".chat-messages-scroll"); + this._scrollerEl.addEventListener("scroll", this.onScrollHandler, { + passive: true, + }); + window.addEventListener("resize", this.onResizeHandler); + window.addEventListener("mousewheel", this.onScrollHandler, { + passive: true, + }); + + this.appEvents.on("chat:cancel-message-selection", this, "cancelSelecting"); + + this.set("showCloseFullScreenBtn", !this.site.mobileView); + + document.addEventListener("scroll", this._forceBodyScroll, { + passive: true, + }); + + onPresenceChange({ + callback: this.onPresenceChangeCallback, + }); + }, + + willDestroyElement() { + this._super(...arguments); + + this.element + .querySelector(".chat-messages-scroll") + ?.removeEventListener("scroll", this.onScrollHandler); + + window.removeEventListener("resize", this.onResizeHandler); + window.removeEventListener("mousewheel", this.onScrollHandler); + + this.appEvents.off( + "chat-live-pane:highlight-message", + this, + "highlightOrFetchMessage" + ); + + // don't need to removeEventListener from scroller as the DOM element goes away + cancel(this.stickyScrollTimer); + + cancel(this.resizeHandler); + + this._resetChannelState(); + this._unloadedReplyIds = null; + this.appEvents.off( + "chat:cancel-message-selection", + this, + "cancelSelecting" + ); + + document.removeEventListener("scroll", this._forceBodyScroll); + + removeOnPresenceChange(this.onPresenceChangeCallback); + }, + + didReceiveAttrs() { + this._super(...arguments); + + this.currentUserTimezone = this.currentUser?.resolvedTimezone( + this.currentUser + ); + + this.set("targetMessageId", this.chat.messageId); + + if ( + this.chatChannel?.id && + this.registeredChatChannelId !== this.chatChannel.id + ) { + this._resetChannelState(); + this.cancelEditing(); + + if (!this.chatChannel.isDraft) { + this.loadDraftForChannel(this.chatChannel.id); + } + } + + if (this.chatChannel?.id) { + this.fetchMessages(this.chatChannel); + } + }, + + @discourseComputed("chatChannel.isDirectMessageChannel") + displayMembers(isDirectMessageChannel) { + return !isDirectMessageChannel; + }, + + @discourseComputed("displayMembers") + infoTabRoute(displayMembers) { + if (displayMembers) { + return "chat.channel.info.members"; + } + + return "chat.channel.info.settings"; + }, + + @bind + onScrollHandler(event) { + throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, true); + }, + + @bind + onResizeHandler() { + cancel(this.resizeHandler); + this.resizeHandler = discourseDebounce( + this, + this.fillPaneAttempt, + this.details, + 250 + ); + }, + + @bind + onPresenceChangeCallback(present) { + if (present) { + this.chat.updateLastReadMessage(); + } + }, + + fetchMessages(channel, options = {}) { + this.set("loading", true); + + return this.chat.loadCookFunction(this.site.categories).then((cook) => { + if (this._selfDeleted) { + return; + } + + this.set("cook", cook); + + const findArgs = { + channelId: channel.id, + pageSize: PAGE_SIZE, + }; + const fetchingFromLastRead = !options.fetchFromLastMessage; + + if (fetchingFromLastRead) { + findArgs["targetMessageId"] = + this.targetMessageId || this._getLastReadId(); + } + + return this.store + .findAll("chat-message", findArgs) + .then((messages) => { + if (this._selfDeleted || this.chatChannel.id !== channel.id) { + return; + } + this.setMessageProps(messages, fetchingFromLastRead); + + if (this.targetMessageId) { + this.highlightOrFetchMessage(this.targetMessageId); + } + + this.focusComposer(); + }) + .catch(this._handleErrors) + .finally(() => { + if (this._selfDeleted || this.chatChannel.id !== channel.id) { + return; + } + + this.chat.set("messageId", null); + this.set("loading", false); + }); + }); + }, + + loadDraftForChannel(channelId) { + this.set("draft", this.chat.getDraftForChannel(channelId)); + }, + + @bind + _fetchMoreMessages(direction) { + const loadingPast = direction === PAST; + const canLoadMore = loadingPast + ? this.details?.can_load_more_past + : this.details?.can_load_more_future; + const loadingMoreKey = `loadingMore${capitalize(direction)}`; + const loadingMore = this.get(loadingMoreKey); + + if ( + (this.details && !canLoadMore) || + loadingMore || + this.loading || + !this.messages.length + ) { + return Promise.resolve(); + } + + this.set(loadingMoreKey, true); + this.ignoreStickyScrolling = true; + + const messageIndex = loadingPast ? 0 : this.messages.length - 1; + const messageId = this.messages[messageIndex].id; + const findArgs = { + channelId: this.chatChannel.id, + pageSize: PAGE_SIZE, + direction, + messageId, + }; + const channelId = this.chatChannel.id; + + return this.store + .findAll("chat-message", findArgs) + .then((messages) => { + if (this._selfDeleted || channelId !== this.chatChannel.id) { + return; + } + + const newMessages = this._prepareMessages(messages || []); + if (newMessages.length) { + this.set( + "messages", + loadingPast + ? newMessages.concat(this.messages) + : this.messages.concat(newMessages) + ); + } + this.setCanLoadMoreDetails(messages.resultSetMeta); + + if (!loadingPast && newMessages.length) { + // Adding newer messages also causes a scroll-down, + // firing another event, fetching messages again, and so on. + // Scroll to the first new one to prevent this. + this.scrollToMessage(newMessages.firstObject.messageLookupId); + } + + return messages; + }) + .catch(this._handleErrors) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set(loadingMoreKey, false); + this.ignoreStickyScrolling = false; + }); + }, + + fillPaneAttempt(meta) { + if (this._selfDeleted) { + return; + } + + // safeguard + if (this.messages.length > 200) { + return; + } + + if (!meta?.can_load_more_past) { + return; + } + + schedule("afterRender", () => { + const firstMessageId = this.messages.firstObject?.id; + if (!firstMessageId) { + return; + } + + const scroller = document.querySelector(".chat-messages-container"); + const messageContainer = document.querySelector( + `.chat-message-container[data-id="${firstMessageId}"]` + ); + if ( + !scroller || + !messageContainer || + !isElementInViewport(messageContainer) + ) { + return; + } + + this._fetchMoreMessagesThrottled(PAST); + }); + }, + + _fetchMoreMessagesThrottled(direction) { + throttle( + this, + "_fetchMoreMessages", + direction, + FETCH_MORE_MESSAGES_THROTTLE_MS + ); + }, + + setCanLoadMoreDetails(meta) { + const metaKeys = Object.keys(meta); + if (metaKeys.includes("can_load_more_past")) { + this.set("details.can_load_more_past", meta.can_load_more_past); + this.set( + "allPastMessagesLoaded", + this.details.can_load_more_past === false + ); + } + if (metaKeys.includes("can_load_more_future")) { + this.set("details.can_load_more_future", meta.can_load_more_future); + } + }, + + setMessageProps(messages, fetchingFromLastRead) { + this._unloadedReplyIds = []; + this.setProperties({ + messages: this._prepareMessages(messages), + details: { + chat_channel_id: this.chatChannel.id, + chatable_type: this.chatChannel.chatable_type, + can_delete_self: messages.resultSetMeta.can_delete_self, + can_delete_others: messages.resultSetMeta.can_delete_others, + can_flag: messages.resultSetMeta.can_flag, + user_silenced: messages.resultSetMeta.user_silenced, + can_moderate: messages.resultSetMeta.can_moderate, + }, + registeredChatChannelId: this.chatChannel.id, + }); + + schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + if (this.targetMessageId) { + this.scrollToMessage(this.targetMessageId, { + highlight: true, + position: "top", + autoExpand: true, + }); + this.set("targetMessageId", null); + } else if (fetchingFromLastRead) { + this._markLastReadMessage(); + } + + this.fillPaneAttempt(messages.resultSetMeta); + }); + + this.setCanLoadMoreDetails(messages.resultSetMeta); + this._subscribeToUpdates(this.chatChannel.id); + }, + + _prepareMessages(messages) { + const preparedMessages = A(); + let previousMessage; + messages.forEach((currentMessage) => { + let prepared = this._prepareSingleMessage( + currentMessage, + previousMessage + ); + preparedMessages.push(prepared); + previousMessage = prepared; + }); + return preparedMessages; + }, + + _areDatesOnSameDay(a, b) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); + }, + + _prepareSingleMessage(messageData, previousMessageData) { + if (previousMessageData) { + if ( + !this._areDatesOnSameDay( + new Date(previousMessageData.created_at), + new Date(messageData.created_at) + ) + ) { + messageData.firstMessageOfTheDayAt = moment( + messageData.created_at + ).calendar(moment(), { + sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, + lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, + lastWeek: "LL", + sameElse: "LL", + }); + } + } + if (messageData.in_reply_to?.id === previousMessageData?.id) { + // Reply-to message is directly above. Remove `in_reply_to` from message. + messageData.in_reply_to = null; + } + + if (messageData.in_reply_to) { + let inReplyToMessage = this.messageLookup[messageData.in_reply_to.id]; + if (inReplyToMessage) { + // Reply to message has already been added + messageData.in_reply_to = inReplyToMessage; + } else { + inReplyToMessage = EmberObject.create(messageData.in_reply_to); + this._unloadedReplyIds.push(inReplyToMessage.id); + this.messageLookup[inReplyToMessage.id] = inReplyToMessage; + } + } else { + // In reply-to is false. Check if previous message was created by same + // user and if so, no need to repeat avatar and username + + if ( + previousMessageData && + !previousMessageData.deleted_at && + Math.abs( + new Date(messageData.created_at) - + new Date(previousMessageData.created_at) + ) < 300000 && // If the time between messages is over 5 minutes, break. + messageData.user.id === previousMessageData.user.id + ) { + messageData.hideUserInfo = true; + } + } + this._handleMessageHidingAndExpansion(messageData); + messageData.messageLookupId = this._generateMessageLookupId(messageData); + const prepared = ChatMessage.create(messageData); + this.messageLookup[messageData.messageLookupId] = prepared; + return prepared; + }, + + _handleMessageHidingAndExpansion(messageData) { + if (this.currentUser.ignored_users) { + messageData.hidden = this.currentUser.ignored_users.includes( + messageData.user.username + ); + } + + // If a message has been hidden it is because the current user is ignoring + // the user who sent it, so we want to unconditionally hide it, even if + // we are going directly to the target + if (this.targetMessageId && this.targetMessageId === messageData.id) { + messageData.expanded = !messageData.hidden; + } else { + messageData.expanded = !(messageData.hidden || messageData.deleted_at); + } + }, + + _generateMessageLookupId(message) { + return message.id || `staged-${message.stagedId}`; + }, + + _getLastReadId() { + return this.currentUser?.chat_channel_tracking_state?.[this.chatChannel.id] + ?.chat_message_id; + }, + + _markLastReadMessage(opts = { reRender: false }) { + if (opts.reRender) { + this.messages.forEach((m) => { + if (m.newestMessage) { + m.set("newestMessage", false); + } + }); + } + const lastReadId = this._getLastReadId(); + if (!lastReadId) { + return; + } + + this.set("lastSendReadMessageId", lastReadId); + const indexOfLastReadMessage = + this.messages.findIndex((m) => m.id === lastReadId) || 0; + let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1]; + + if (newestUnreadMessage) { + newestUnreadMessage.set("newestMessage", true); + + next(() => this.scrollToMessage(newestUnreadMessage.id)); + + return; + } + this._stickScrollToBottom(); + }, + + highlightOrFetchMessage(messageId) { + if (this._selfDeleted) { + return; + } + + if (this.messageLookup[messageId]) { + // We have the message rendered. highlight and scrollTo + this.scrollToMessage(messageId, { + highlight: true, + position: "top", + autoExpand: true, + }); + } else { + this.set("targetMessageId", messageId); + this.fetchMessages(this.chatChannel); + } + }, + + scrollToMessage( + messageId, + opts = { highlight: false, position: "top", autoExpand: false } + ) { + if (this._selfDeleted) { + return; + } + const message = this.messageLookup[messageId]; + if (message?.deleted_at && opts.autoExpand) { + message.set("expanded", true); + } + + schedule("afterRender", () => { + const messageEl = this._scrollerEl.querySelector( + `.chat-message-container[data-id='${messageId}']` + ); + + if (!messageEl || this._selfDeleted) { + return; + } + + this._wrapIOSFix(() => { + messageEl.scrollIntoView({ + block: opts.position === "top" ? "start" : "end", + }); + }); + + if (opts.highlight) { + messageEl.classList.add("highlighted"); + + // Remove highlighted class, but keep `transition-slow` on for another 2 seconds + // to ensure the background color fades smoothly out + if (opts.highlight) { + discourseLater(() => { + messageEl.classList.add("transition-slow"); + }, 2000); + + discourseLater(() => { + messageEl.classList.remove("highlighted"); + + discourseLater(() => { + messageEl.classList.remove("transition-slow"); + }, 2000); + }, 3000); + } + } + }); + }, + + @afterRender + _stickScrollToBottom() { + if (this.ignoreStickyScrolling) { + return; + } + + this.set("stickyScroll", true); + + if (this._scrollerEl) { + // Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. + // Setting to just 0 doesn't work (it's at 0 by default, so there is no change) + // Very hacky, but no way to get around this Safari bug + this._scrollerEl.scrollTop = -1; + + this._wrapIOSFix(() => { + this._scrollerEl.scrollTop = 0; + this.set("showScrollToBottomBtn", false); + }); + } + }, + + onScroll(event) { + if (this._selfDeleted) { + return; + } + + resetIdle(); + + const atTop = + Math.abs( + this._scrollerEl.scrollHeight - + this._scrollerEl.clientHeight + + this._scrollerEl.scrollTop + ) <= STICKY_SCROLL_LENIENCE; + + if (atTop) { + this._fetchMoreMessagesThrottled(PAST); + } else if (Math.abs(this._scrollerEl.scrollTop) <= STICKY_SCROLL_LENIENCE) { + this._fetchMoreMessagesThrottled(FUTURE); + } + + this._calculateStickScroll(event.forceShowScrollToBottom); + }, + + _calculateStickScroll(forceShowScrollToBottom) { + const absoluteScrollTop = Math.abs(this._scrollerEl.scrollTop); + const shouldStick = absoluteScrollTop < STICKY_SCROLL_LENIENCE; + + if (forceShowScrollToBottom) { + this.set("showScrollToBottomBtn", forceShowScrollToBottom); + } else { + this.set( + "showScrollToBottomBtn", + shouldStick + ? false + : absoluteScrollTop / this._scrollerEl.offsetHeight > 0.67 + ); + } + + if (!this.showScrollToBottomBtn) { + this.set("hasNewMessages", false); + } + + if (shouldStick !== this.stickyScroll) { + if (shouldStick) { + this._stickScrollToBottom(); + } else { + this.set("stickyScroll", false); + } + } + }, + + @observes("floatHidden") + onFloatHiddenChange() { + if (!this.floatHidden) { + this.set("expanded", true); + this._markLastReadMessage({ reRender: true }); + this._stickScrollToBottom(); + } + }, + + removeMessage(msgData) { + delete this.messageLookup[msgData.id]; + }, + + handleMessage(data) { + switch (data.type) { + case "sent": + this.handleSentMessage(data); + break; + case "processed": + this.handleProcessedMessage(data); + break; + case "edit": + this.handleEditMessage(data); + break; + case "refresh": + this.handleRefreshMessage(data); + break; + case "delete": + this.handleDeleteMessage(data); + break; + case "bulk_delete": + this.handleBulkDeleteMessage(data); + break; + case "reaction": + this.handleReactionMessage(data); + break; + case "restore": + this.handleRestoreMessage(data); + break; + case "mention_warning": + this.handleMentionWarning(data); + break; + case "self_flagged": + this.handleSelfFlaggedMessage(data); + break; + case "flag": + this.handleFlaggedMessage(data); + break; + } + }, + + handleSentMessage(data) { + if (this.chatChannel.isFollowing) { + this.chatChannel.set("last_message_sent_at", new Date()); + } + + if (data.chat_message.user.id === this.currentUser.id) { + // User sent this message. Check staged messages to see if this client sent the message. + // If so, need to update the staged message with and id. + const stagedMessage = this.messageLookup[`staged-${data.stagedId}`]; + if (stagedMessage) { + stagedMessage.setProperties({ + error: null, + staged: false, + id: data.chat_message.id, + staged_id: null, + excerpt: data.chat_message.excerpt, + }); + + // some markdown is cooked differently on the server-side, e.g. + // quotes, avatar images etc. + if ( + data.chat_message.cooked && + data.chat_message.cooked !== stagedMessage.cooked + ) { + stagedMessage.set("cooked", data.chat_message.cooked); + } + this.appEvents.trigger( + `chat-message-staged-${data.stagedId}:id-populated` + ); + + this.messageLookup[data.chat_message.id] = stagedMessage; + delete this.messageLookup[`staged-${data.stagedId}`]; + return; + } + } + + const preparedMessage = this._prepareSingleMessage( + data.chat_message, + this.messages[this.messages.length - 1] + ); + + this.messages.pushObject(preparedMessage); + + if (this.messages.length >= MAX_RECENT_MSGS) { + this.removeMessage(this.messages.shiftObject()); + } + this.reStickScrollIfNeeded(); + }, + + handleProcessedMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + message.set("cooked", data.chat_message.cooked); + this.reStickScrollIfNeeded(); + } + }, + + handleRefreshMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + this.appEvents.trigger("chat:refresh-message", message); + } + }, + + handleEditMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + message.setProperties({ + message: data.chat_message.message, + cooked: data.chat_message.cooked, + excerpt: data.chat_message.excerpt, + uploads: cloneJSON(data.chat_message.uploads || []), + edited: true, + }); + } + }, + + handleBulkDeleteMessage(data) { + data.deleted_ids.forEach((deletedId) => { + this.handleDeleteMessage({ + deleted_id: deletedId, + deleted_at: data.deleted_at, + }); + }); + }, + + handleDeleteMessage(data) { + const deletedId = data.deleted_id; + const targetMsg = this.messageLookup[deletedId]; + if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { + targetMsg.setProperties({ + deleted_at: data.deleted_at, + expanded: false, + }); + } else { + this.messages.removeObject(targetMsg); + this.messageLookup[deletedId] = null; + } + }, + + handleReactionMessage(data) { + this.appEvents.trigger( + `chat-message-${data.chat_message_id}:reaction`, + data + ); + }, + + handleRestoreMessage(data) { + let message = this.messageLookup[data.chat_message.id]; + if (message) { + message.set("deleted_at", null); + } else { + // The message isn't present in the list for this user. Find the index + // where we should push the message to. Binary search is O(log(n)) + let newMessageIndex = this.binarySearchForMessagePosition( + this.messages, + message + ); + const previousMessage = + newMessageIndex > 0 ? this.messages[newMessageIndex - 1] : null; + message = this._prepareSingleMessage(data.chat_message, previousMessage); + if (newMessageIndex === 0) { + return; + } // Restored post is too old to show + + this.messages.splice(newMessageIndex, 0, message); + this.notifyPropertyChange("messages"); + } + }, + + binarySearchForMessagePosition(messages, newMessage) { + const newMessageCreatedAt = Date.parse(newMessage.created_at); + if (newMessageCreatedAt < Date.parse(messages[0].created_at)) { + return 0; + } + if ( + newMessageCreatedAt > Date.parse(messages[messages.length - 1].created_at) + ) { + return messages.length; + } + let m = 0; + let n = messages.length - 1; + while (m <= n) { + let k = Math.floor((n + m) / 2); + let comparison = this.compareCreatedAt(newMessageCreatedAt, messages[k]); + if (comparison > 0) { + m = k + 1; + } else if (comparison < 0) { + n = k - 1; + } else { + return k; + } + } + return m; + }, + + compareCreatedAt(newMessageCreatedAt, comparatorMessage) { + const compareDate = Date.parse(comparatorMessage.created_at); + if (newMessageCreatedAt > compareDate) { + return 1; + } else if (newMessageCreatedAt < compareDate) { + return -1; + } + return 0; + }, + + handleMentionWarning(data) { + this.messageLookup[data.chat_message_id]?.set("mentionWarning", data); + }, + + handleSelfFlaggedMessage(data) { + this.messageLookup[data.chat_message_id]?.set( + "user_flag_status", + data.user_flag_status + ); + }, + + handleFlaggedMessage(data) { + this.messageLookup[data.chat_message_id]?.set( + "reviewable_id", + data.reviewable_id + ); + }, + + get _selfDeleted() { + return !this.element || this.isDestroying || this.isDestroyed; + }, + + @action + sendMessage(message, uploads = []) { + resetIdle(); + + if (this.sendingLoading) { + return; + } + + this.set("sendingLoading", true); + this._setDraftForChannel(null); + + // TODO: all send message logic is due for massive refactoring + // This is all the possible case Im currently aware of + // - messaging to a public channel where you are not a member yet (preview = true) + // - messaging to an existing direct channel you were not tracking yet through dm creator (channel draft) + // - messaging to a new direct channel through DM creator (channel draft) + // - message to a direct channel you were tracking (preview = false, not draft) + // - message to a public channel you were tracking (preview = false, not draft) + // - message to a channel when we haven't loaded all future messages yet. + if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { + this.set("loading", true); + + return this._upsertChannelWithMessage( + this.chatChannel, + message, + uploads + ).finally(() => { + if (this._selfDeleted) { + return; + } + this.set("loading", false); + this.set("sendingLoading", false); + this._resetAfterSend(); + this._stickScrollToBottom(); + }); + } + + this.set("_nextStagedMessageId", this._nextStagedMessageId + 1); + const cooked = this.cook(message); + const stagedId = this._nextStagedMessageId; + let data = { + message, + cooked, + staged_id: stagedId, + upload_ids: uploads.map((upload) => upload.id), + }; + if (this.replyToMsg) { + data.in_reply_to_id = this.replyToMsg.id; + } + + // Start ajax request but don't return here, we want to stage the message instantly when all messages are loaded. + // Otherwise, we'll fetch latest and scroll to the one we just created. + // Return a resolved promise below. + const msgCreationPromise = ChatApi.sendMessage(this.chatChannel.id, data) + .catch((error) => { + this._onSendError(data.staged_id, error); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + + if (this.details.can_load_more_future) { + msgCreationPromise.then(() => this._fetchAndScrollToLatest()); + } else { + const stagedMessage = this._prepareSingleMessage( + // We need to add the user and created at for presentation of staged message + { + message, + cooked, + stagedId, + uploads: cloneJSON(uploads), + staged: true, + user: this.currentUser, + in_reply_to: this.replyToMsg, + created_at: new Date(), + }, + this.messages[this.messages.length - 1] + ); + this.messages.pushObject(stagedMessage); + this._stickScrollToBottom(); + } + + this._resetAfterSend(); + this.appEvents.trigger("chat-composer:reply-to-set", null); + return Promise.resolve(); + }, + + async _upsertChannelWithMessage(channel, message, uploads) { + let promise; + + if (channel.isDirectMessageChannel || channel.isDraft) { + promise = this.chat.upsertDmChannelForUsernames( + channel.chatable.users.mapBy("username") + ); + } else { + promise = ChatApi.loading(channel.id).then(() => channel); + } + + return promise + .then((c) => { + c.current_user_membership.set("following", true); + return this.chat.startTrackingChannel(c); + }) + .then((c) => + ajax(`/chat/${c.id}.json`, { + type: "POST", + data: { + message, + upload_ids: (uploads || []).mapBy("id"), + }, + }).then(() => { + this.chat.forceRefreshChannels(); + this.onSwitchChannel(ChatChannel.create(c)); + }) + ); + }, + + _onSendError(stagedId, error) { + const stagedMessage = this.messageLookup[`staged-${stagedId}`]; + if (stagedMessage) { + if (error.jqXHR?.responseJSON?.errors?.length) { + stagedMessage.set("error", error.jqXHR.responseJSON.errors[0]); + } else { + this.chat.markNetworkAsUnreliable(); + stagedMessage.set("error", "network_error"); + } + } + + this._resetAfterSend(); + }, + + @action + resendStagedMessage(stagedMessage) { + this.set("sendingLoading", true); + + stagedMessage.set("error", null); + + const data = { + cooked: stagedMessage.cooked, + message: stagedMessage.message, + upload_ids: stagedMessage.upload_ids, + staged_id: stagedMessage.stagedId, + }; + + ChatApi.sendMessage(this.chatChannel.id, data) + .catch((error) => { + this._onSendError(data.staged_id, error); + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + }, + + @action + editMessage(chatMessage, newContent, uploads) { + this.set("sendingLoading", true); + let data = { + new_message: newContent, + upload_ids: (uploads || []).map((upload) => upload.id), + }; + return ajax(`/chat/${this.chatChannel.id}/edit/${chatMessage.id}`, { + type: "PUT", + data, + }) + .then(() => { + this._resetAfterSend(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + }, + + _resetChannelState() { + this._unsubscribeToUpdates(this.registeredChatChannelId); + this.messages.clear(); + this.messageLookup = {}; + this.set("allPastMessagesLoaded", false); + this.set("registeredChatChannelId", null); + this.set("selectingMessages", false); + }, + + _resetAfterSend() { + if (this._selfDeleted) { + return; + } + this.setProperties({ + replyToMsg: null, + editingMessage: null, + }); + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, false); + }, + + @action + editLastMessageRequested() { + let lastUserMessage = null; + for ( + let messageIndex = this.messages.length - 1; + messageIndex >= 0; + messageIndex-- + ) { + let message = this.messages[messageIndex]; + if ( + !message.staged && + message.user.id === this.currentUser.id && + !message.error + ) { + lastUserMessage = message; + break; + } + } + if (lastUserMessage) { + this.set("editingMessage", lastUserMessage); + } + }, + + @action + setReplyTo(messageId) { + if (messageId) { + this.cancelEditing(); + this.set("replyToMsg", this.messageLookup[messageId]); + this.appEvents.trigger("chat-composer:reply-to-set", this.replyToMsg); + this._focusComposer(); + } else { + this.set("replyToMsg", null); + this.appEvents.trigger("chat-composer:reply-to-set", null); + } + }, + + @action + replyMessageClicked(message) { + const replyMessageFromLookup = this.messageLookup[message.id]; + if (this._unloadedReplyIds.includes(message.id)) { + // Message is not present in the loaded messages. Fetch it! + this.set("targetMessageId", message.id); + this.fetchMessages(this.chatChannel); + } else { + this.scrollToMessage(replyMessageFromLookup.id, { + highlight: true, + position: "top", + autoExpand: true, + }); + } + }, + + @action + editButtonClicked(messageId) { + const message = this.messageLookup[messageId]; + this.set("editingMessage", message); + next(this.reStickScrollIfNeeded.bind(this)); + this._focusComposer(); + }, + + @discourseComputed("details.user_silenced") + canInteractWithChat(userSilenced) { + return !userSilenced; + }, + + @discourseComputed + chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + }, + + @discourseComputed("messages.@each.selected") + selectedMessageIds(messages) { + return messages.filter((m) => m.selected).map((m) => m.id); + }, + + @action + onStartSelectingMessages(message) { + this._lastSelectedMessage = message; + this.set("selectingMessages", true); + }, + + @action + cancelSelecting() { + this.set("selectingMessages", false); + this.messages.setEach("selected", false); + }, + + @action + onSelectMessage(message) { + this._lastSelectedMessage = message; + }, + + @action + navigateToIndex() { + this.router.transitionTo("chat.index"); + }, + + @action + bulkSelectMessages(message, checked) { + const lastSelectedIndex = this._findIndexOfMessage( + this._lastSelectedMessage + ); + const newlySelectedIndex = this._findIndexOfMessage(message); + const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( + (a, b) => a - b + ); + + for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { + this.messages[i].set("selected", checked); + } + }, + + _findIndexOfMessage(message) { + return this.messages.findIndex((m) => m.id === message.id); + }, + + @action + onCloseFullScreen() { + this.chatStateManager.prefersDrawer(); + + this.router.transitionTo(this.chatStateManager.lastKnownAppURL).then(() => { + this.appEvents.trigger( + "chat:open-url", + this.chatStateManager.lastKnownChatURL + ); + }); + }, + + @action + cancelEditing() { + this.set("editingMessage", null); + }, + + @action + _setDraftForChannel(draft) { + if (this.chatChannel.isDraft) { + return; + } + + if (draft?.replyToMsg) { + draft.replyToMsg = { + id: draft.replyToMsg.id, + excerpt: draft.replyToMsg.excerpt, + user: draft.replyToMsg.user, + }; + } + this.chat.setDraftForChannel(this.chatChannel, draft); + this.set("draft", draft); + }, + + @action + setInReplyToMsg(inReplyMsg) { + this.set("replyToMsg", inReplyMsg); + }, + + @action + composerValueChanged(value, uploads, replyToMsg) { + if (!this.editingMessage && !this.chatChannel.directMessageChannelDraft) { + this._setDraftForChannel({ value, uploads, replyToMsg }); + } + + if (!this.chatChannel.directMessageChannelDraft) { + this._reportReplyingPresence(value); + } + }, + + @action + reStickScrollIfNeeded() { + if (this.stickyScroll) { + this._stickScrollToBottom(); + } + }, + + @action + onHoverMessage(message, options = {}, event) { + cancel(this._onHoverMessageDebouncedHandler); + + if (this.site.mobileView && options.desktopOnly) { + return; + } + + if (message?.staged) { + return; + } + + if ( + this.hoveredMessageId && + message?.id && + this.hoveredMessageId === message?.id + ) { + return; + } + + if (event) { + if ( + event.type === "mouseleave" && + (event.toElement || event.relatedTarget)?.closest( + ".chat-message-actions-desktop-anchor" + ) + ) { + return; + } + + if ( + event.type === "mouseenter" && + (event.fromElement || event.relatedTarget)?.closest( + ".chat-message-actions-desktop-anchor" + ) + ) { + this.set("hoveredMessageId", message?.id); + return; + } + } + + this._onHoverMessageDebouncedHandler = discourseDebounce( + this, + this.debouncedOnHoverMessage, + message, + 250 + ); + }, + + @bind + debouncedOnHoverMessage(message) { + if (this._selfDeleted) { + return; + } + + this.set( + "hoveredMessageId", + message?.id && message.id !== this.hoveredMessageId ? message.id : null + ); + }, + + _reportReplyingPresence(composerValue) { + if (this.chatChannel.isDraft) { + return; + } + + const replying = !this.editingMessage && !!composerValue; + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, replying); + }, + + @action + restickScrolling(event) { + event.preventDefault(); + + return this._fetchAndScrollToLatest(); + }, + + focusComposer() { + if ( + this._selfDeleted || + this.site.mobileView || + this.chatChannel?.isDraft + ) { + return; + } + + schedule("afterRender", () => { + document.querySelector(".chat-composer-input")?.focus(); + }); + }, + + @afterRender + _focusComposer() { + this.appEvents.trigger("chat:focus-composer"); + }, + + _unsubscribeToUpdates(channelId) { + this.messageBus.unsubscribe(`/chat/${channelId}`); + }, + + _subscribeToUpdates(channelId) { + this._unsubscribeToUpdates(channelId); + this.messageBus.subscribe(`/chat/${channelId}`, (busData) => { + if (!this.details.can_load_more_future || busData.type !== "sent") { + this.handleMessage(busData); + } else { + this.set("hasNewMessages", true); + } + }); + }, + + @bind + _forceBodyScroll() { + // when keyboard is visible this will ensure body + // doesn’t scroll out of viewport + if ( + this.capabilities.isIOS && + document.documentElement.classList.contains("keyboard-visible") && + !isZoomed() + ) { + document.documentElement.scrollTo(0, 0); + } + }, + + _fetchAndScrollToLatest() { + return this.fetchMessages(this.chatChannel, { + fetchFromLastMessage: true, + }).then(() => { + if (this._selfDeleted) { + return; + } + + this.set("stickyScroll", true); + this._stickScrollToBottom(); + }); + }, + + _handleErrors(error) { + switch (error?.jqXHR?.status) { + case 429: + case 404: + popupAjaxError(error); + break; + default: + throw error; + } + }, + + // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling + // we now use this hack to disable it + @bind + _wrapIOSFix(callback) { + if (!this._scrollerEl) { + return; + } + + if (this.capabilities.isIOS) { + this._scrollerEl.style.overflow = "hidden"; + } + + callback(); + + if (this.capabilities.isIOS) { + discourseLater(() => { + if (!this._scrollerEl) { + return; + } + + this._scrollerEl.style.overflow = "auto"; + }, 25); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js new file mode 100644 index 0000000000..5a7e3e1434 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js @@ -0,0 +1,57 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { createPopper } from "@popperjs/core"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; + +const MSG_ACTIONS_HORIZONTAL_PADDING = 2; +const MSG_ACTIONS_VERTICAL_PADDING = -15; + +export default Component.extend({ + tagName: "", + + chatStateManager: service(), + + messageActions: null, + + didReceiveAttrs() { + this._super(...arguments); + + this.popper?.destroy(); + + schedule("afterRender", () => { + this.popper = createPopper( + document.querySelector( + `.chat-message-container[data-id="${this.message.id}"]` + ), + document.querySelector( + `.chat-message-actions-container[data-id="${this.message.id}"] .chat-message-actions` + ), + { + placement: "right-start", + modifiers: [ + { name: "hide", enabled: true }, + { + name: "offset", + options: { + offset: ({ popper, placement }) => { + return [ + MSG_ACTIONS_VERTICAL_PADDING, + -(placement.includes("left") || placement.includes("right") + ? popper.width + MSG_ACTIONS_HORIZONTAL_PADDING + : popper.height), + ]; + }, + }, + }, + ], + } + ); + }); + }, + + @action + handleSecondaryButtons(id) { + this.messageActions?.[id]?.(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js new file mode 100644 index 0000000000..1457f56e55 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js @@ -0,0 +1,66 @@ +import Component from "@ember/component"; +import discourseLater from "discourse-common/lib/later"; +import { action } from "@ember/object"; +import { isTesting } from "discourse-common/config/environment"; + +export default Component.extend({ + tagName: "", + hasExpandedReply: false, + messageActions: null, + + didInsertElement() { + this._super(...arguments); + + discourseLater(this._addFadeIn); + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + }, + + @action + expandReply(event) { + event.stopPropagation(); + this.set("hasExpandedReply", true); + }, + + @action + collapseMenu(event) { + event.stopPropagation(); + this.onCloseMenu(); + }, + + @action + actAndCloseMenu(fn) { + fn?.(); + this.onCloseMenu(); + }, + + onCloseMenu() { + this._removeFadeIn(); + + // we don't want to remove the component right away as it's animating + // 200 is equal to the duration of the css animation + discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + // by ensuring we are not hovering any message anymore + // we also ensure the menu is fully removed + this.onHoverMessage?.(null); + }, 200); + }, + + _addFadeIn() { + document + .querySelector(".chat-message-actions-backdrop") + ?.classList.add("fade-in"); + }, + + _removeFadeIn() { + document + .querySelector(".chat-message-actions-backdrop") + ?.classList?.remove("fade-in"); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js new file mode 100644 index 0000000000..5b7b32a549 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default class ChatMessageAvatar extends Component { + tagName = ""; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js new file mode 100644 index 0000000000..ab91763bd8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -0,0 +1,214 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import { escapeExpression } from "discourse/lib/utilities"; +import domFromString from "discourse-common/lib/dom-from-string"; +import I18n from "I18n"; + +export default class ChatMessageCollapser extends Component { + tagName = ""; + collapsed = false; + uploads = null; + cooked = null; + + @computed("uploads") + get hasUploads() { + return hasUploads(this.uploads); + } + + @computed("uploads") + get uploadsHeader() { + let name = ""; + if (this.uploads.length === 1) { + name = this.uploads[0].original_filename; + } else { + name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); + } + return htmlSafe( + `${escapeExpression( + name + )}` + ); + } + + @computed("cooked") + get cookedBodies() { + const elements = Array.prototype.slice.call(domFromString(this.cooked)); + + if (hasYoutube(elements)) { + return this.youtubeCooked(elements); + } + + if (hasImageOnebox(elements)) { + return this.imageOneboxCooked(elements); + } + + if (hasImage(elements)) { + return this.imageCooked(elements); + } + + if (hasGallery(elements)) { + return this.galleryCooked(elements); + } + + return []; + } + + youtubeCooked(elements) { + return elements.reduce((acc, e) => { + if (youtubePredicate(e)) { + const id = e.dataset.youtubeId; + const link = `https://www.youtube.com/watch?v=${escapeExpression(id)}`; + const title = escapeExpression(e.dataset.youtubeTitle); + const header = htmlSafe( + `${title}` + ); + const body = document.createElement("div"); + body.className = "chat-message-collapser-youtube"; + body.appendChild(e); + + acc.push({ header, body, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + imageOneboxCooked(elements) { + return elements.reduce((acc, e) => { + if (imageOneboxPredicate(e)) { + let link = animatedImagePredicate(e) + ? e.firstChild.src + : e.firstElementChild.href; + + link = escapeExpression(link); + const header = htmlSafe( + `${link}` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + imageCooked(elements) { + return elements.reduce((acc, e) => { + if (imagePredicate(e)) { + const link = escapeExpression(e.firstElementChild.src); + const alt = escapeExpression(e.firstElementChild.alt); + const header = htmlSafe( + `${ + alt || link + }` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + galleryCooked(elements) { + return elements.reduce((acc, e) => { + if (galleryPredicate(e)) { + const link = escapeExpression(e.firstElementChild.href); + const title = escapeExpression( + e.firstElementChild.firstElementChild.textContent + ); + e.firstElementChild.removeChild(e.firstElementChild.firstElementChild); + const header = htmlSafe( + `${title}` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } +} + +function youtubePredicate(e) { + return ( + e.classList.length && + e.classList.contains("onebox") && + e.classList.contains("lazyYT-container") + ); +} + +function hasYoutube(elements) { + return elements.some((e) => youtubePredicate(e)); +} + +function animatedImagePredicate(e) { + return ( + e.firstChild && + e.firstChild.nodeName === "IMG" && + e.firstChild.classList.contains("animated") && + e.firstChild.classList.contains("onebox") + ); +} + +function externalImageOnebox(e) { + return ( + e.firstElementChild && + e.firstElementChild.nodeName === "A" && + e.firstElementChild.classList.contains("onebox") && + e.firstElementChild.firstElementChild && + e.firstElementChild.firstElementChild.nodeName === "IMG" + ); +} + +function imageOneboxPredicate(e) { + return animatedImagePredicate(e) || externalImageOnebox(e); +} + +function hasImageOnebox(elements) { + return elements.some((e) => imageOneboxPredicate(e)); +} + +function hasUploads(uploads) { + return uploads?.length > 0; +} + +function imagePredicate(e) { + return ( + e.nodeName === "P" && + e.firstElementChild && + e.firstElementChild.nodeName === "IMG" && + !e.firstElementChild.classList.contains("emoji") + ); +} + +function hasImage(elements) { + return elements.some((e) => imagePredicate(e)); +} + +function galleryPredicate(e) { + return ( + e.firstElementChild && + e.firstElementChild.nodeName === "A" && + e.firstElementChild.firstElementChild && + e.firstElementChild.firstElementChild.classList.contains("outer-box") + ); +} + +function hasGallery(elements) { + return elements.some((e) => galleryPredicate(e)); +} + +export function isCollapsible(cooked, uploads) { + const elements = Array.prototype.slice.call(domFromString(cooked)); + + return ( + hasYoutube(elements) || + hasImageOnebox(elements) || + hasUploads(uploads) || + hasImage(elements) || + hasGallery(elements) + ); +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js new file mode 100644 index 0000000000..9bb31d6f4c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js @@ -0,0 +1,70 @@ +import { computed } from "@ember/object"; +import Component from "@ember/component"; +import { prioritizeNameInUx } from "discourse/lib/settings"; + +export default class ChatMessageInfo extends Component { + tagName = ""; + message = null; + details = null; + + didInsertElement() { + this._super(...arguments); + this.message.user?.trackStatus?.(); + } + + willDestroyElement() { + this._super(...arguments); + this.message.user?.stopTrackingStatus?.(); + } + + @computed("message.user") + get name() { + return this.prioritizeName + ? this.message.user.name + : this.message.user.username; + } + + @computed("message.reviewable_id", "message.user_flag_status") + get isFlagged() { + return this.message?.reviewable_id || this.message?.user_flag_status === 0; + } + + @computed("message.user.name") + get prioritizeName() { + return ( + this.siteSettings.display_name_on_posts && + prioritizeNameInUx(this.message?.user?.name) + ); + } + + @computed("message.user.status") + get showStatus() { + return !!this.message.user?.status; + } + + @computed("message.user") + get usernameClasses() { + const user = this.message?.user; + + const classes = this.prioritizeName ? ["is-full-name"] : ["is-username"]; + + if (!user) { + return classes; + } + + if (user.staff) { + classes.push("is-staff"); + } + if (user.admin) { + classes.push("is-admin"); + } + if (user.moderator) { + classes.push("is-moderator"); + } + if (user.groupModerator) { + classes.push("is-category-moderator"); + } + + return classes.join(" "); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js new file mode 100644 index 0000000000..3045b7f4ed --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js @@ -0,0 +1,65 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import { reads } from "@ember/object/computed"; +import { isBlank } from "@ember/utils"; +import { action, computed } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { htmlSafe } from "@ember/template"; + +export default class MoveToChannelModalInner extends Component { + @service chat; + @service router; + tagName = ""; + sourceChannel = null; + destinationChannelId = null; + selectedMessageIds = null; + + @reads("selectedMessageIds.length") selectedMessageCount; + + @computed("destinationChannelId") + get disableMoveButton() { + return isBlank(this.destinationChannelId); + } + + @computed("chat.publicChannels.[]") + get availableChannels() { + return this.chat.publicChannels.rejectBy("id", this.sourceChannel.id); + } + + @action + moveMessages() { + return ajax( + `/chat/${this.sourceChannel.id}/move_messages_to_channel.json`, + { + method: "PUT", + data: { + message_ids: this.selectedMessageIds, + destination_channel_id: this.destinationChannelId, + }, + } + ) + .then((response) => { + this.router.transitionTo( + "chat.channel", + response.destination_channel_id, + response.destination_channel_title, + { + queryParams: { messageId: response.first_moved_message_id }, + } + ); + }) + .catch(popupAjaxError); + } + + @computed() + get instructionsText() { + return htmlSafe( + I18n.t("chat.move_to_channel.instructions", { + channelTitle: this.sourceChannel.escapedTitle, + count: this.selectedMessageCount, + }) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js new file mode 100644 index 0000000000..a15cdc06b7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -0,0 +1,119 @@ +import { guidFor } from "@ember/object/internals"; +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { emojiUnescape, emojiUrlFor } from "discourse/lib/text"; +import setupPopover from "discourse/lib/d-popover"; +import I18n from "I18n"; +import { schedule } from "@ember/runloop"; + +export default class ChatMessageReaction extends Component { + reaction = null; + showUsersList = false; + tagName = ""; + react = null; + class = null; + + didReceiveAttrs() { + this._super(...arguments); + + if (this.showUsersList) { + schedule("afterRender", () => { + this._popover?.destroy(); + this._popover = this._setupPopover(); + }); + } + } + + willDestroyElement() { + this._super(...arguments); + + this._popover?.destroy(); + } + + @computed + get componentId() { + return guidFor(this); + } + + @computed("reaction.emoji") + get emojiString() { + return `:${this.reaction.emoji}:`; + } + + @computed("reaction.emoji") + get emojiUrl() { + return emojiUrlFor(this.reaction.emoji); + } + + @action + handleClick() { + this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add"); + return false; + } + + _setupPopover() { + const target = document.getElementById(this.componentId); + + if (!target) { + return; + } + + const popover = setupPopover(target, { + interactive: false, + allowHTML: true, + delay: 250, + content: emojiUnescape(this.popoverContent), + onClickOutside(instance) { + instance.hide(); + }, + onTrigger(instance, event) { + // ensures we close other reactions popovers when triggering one + document + .querySelectorAll(".chat-message-reaction") + .forEach((chatMessageReaction) => { + chatMessageReaction?._tippy?.hide(); + }); + + event.stopPropagation(); + }, + }); + + return popover?.id ? popover : null; + } + + @computed("reaction") + get popoverContent() { + let usernames = this.reaction.users.mapBy("username").join(", "); + if (this.reaction.reacted) { + if (this.reaction.count === 1) { + return I18n.t("chat.reactions.only_you", { + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count > 1 && this.reaction.count < 6) { + return I18n.t("chat.reactions.and_others", { + usernames, + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count >= 6) { + return I18n.t("chat.reactions.you_others_and_more", { + usernames, + emoji: this.reaction.emoji, + more: this.reaction.count - 5, + }); + } + } else { + if (this.reaction.count > 0 && this.reaction.count < 6) { + return I18n.t("chat.reactions.only_others", { + usernames, + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count >= 6) { + return I18n.t("chat.reactions.others_and_more", { + usernames, + emoji: this.reaction.emoji, + more: this.reaction.count - 5, + }); + } + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js new file mode 100644 index 0000000000..44494409ab --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default Component.extend({ + tagName: "", +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js new file mode 100644 index 0000000000..d02c47ba1c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js @@ -0,0 +1,15 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; + +export default class ChatMessageText extends Component { + tagName = ""; + cooked = null; + uploads = null; + edited = false; + + @computed("cooked", "uploads.[]") + get isCollapsible() { + return isCollapsible(this.cooked, this.uploads); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js new file mode 100644 index 0000000000..52908dd15d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -0,0 +1,820 @@ +import Bookmark from "discourse/models/bookmark"; +import { openBookmarkModal } from "discourse/controllers/bookmark"; +import { isTesting } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import I18n from "I18n"; +import getURL from "discourse-common/lib/get-url"; +import optionalService from "discourse/lib/optional-service"; +import discourseComputed, { + afterRender, + bind, +} from "discourse-common/utils/decorators"; +import EmberObject, { action, computed } from "@ember/object"; +import { and, not } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { cancel, once } from "@ember/runloop"; +import { clipboardCopy } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseLater from "discourse-common/lib/later"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; +import showModal from "discourse/lib/show-modal"; +import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; + +let _chatMessageDecorators = []; + +export function addChatMessageDecorator(decorator) { + _chatMessageDecorators.push(decorator); +} + +export function resetChatMessageDecorators() { + _chatMessageDecorators = []; +} + +export const MENTION_KEYWORDS = ["here", "all"]; + +export default Component.extend({ + ADD_REACTION: "add", + REMOVE_REACTION: "remove", + SHOW_LEFT: "showLeft", + SHOW_RIGHT: "showRight", + canInteractWithChat: false, + isHovered: false, + onHoverMessage: null, + mentionWarning: null, + chatEmojiReactionStore: service("chat-emoji-reaction-store"), + chatEmojiPickerManager: service("chat-emoji-picker-manager"), + adminTools: optionalService(), + _hasSubscribedToAppEvents: false, + tagName: "", + chat: service(), + dialog: service(), + chatMessageActionsMobileAnchor: null, + chatMessageActionsDesktopAnchor: null, + chatMessageEmojiPickerAnchor: null, + cachedFavoritesReactions: null, + + init() { + this._super(...arguments); + + this.set("_loadingReactions", []); + this.message.set("reactions", EmberObject.create(this.message.reactions)); + this.message.id + ? this._subscribeToAppEvents() + : this._waitForIdToBePopulated(); + if (this.message.bookmark) { + this.set("message.bookmark", Bookmark.create(this.message.bookmark)); + } + }, + + didInsertElement() { + this._super(...arguments); + + this.set( + "chatMessageActionsMobileAnchor", + document.querySelector(".chat-message-actions-mobile-anchor") + ); + this.set( + "chatMessageActionsDesktopAnchor", + document.querySelector(".chat-message-actions-desktop-anchor") + ); + + this.set("cachedFavoritesReactions", this.chatEmojiReactionStore.favorites); + }, + + willDestroyElement() { + this._super(...arguments); + if (this.message.stagedId) { + this.appEvents.off( + `chat-message-staged-${this.message.stagedId}:id-populated`, + this, + "_subscribeToAppEvents" + ); + } + + this.appEvents.off("chat:refresh-message", this, "_refreshedMessage"); + + this.appEvents.off( + `chat-message-${this.message.id}:reaction`, + this, + "_handleReactionMessage" + ); + + cancel(this._invitationSentTimer); + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.show || this.deletedAndCollapsed) { + this._decoratedMessageCooked = null; + } else if (this.message.cooked !== this._decoratedMessageCooked) { + once("afterRender", this.decorateMessageCooked); + this._decoratedMessageCooked = this.message.cooked; + } + }, + + @bind + _refreshedMessage(message) { + if (message.id === this.message.id) { + this.decorateMessageCooked(); + } + }, + + @bind + decorateMessageCooked() { + if (!this.messageContainer) { + return; + } + + _chatMessageDecorators.forEach((decorator) => { + decorator.call(this, this.messageContainer, this.chatChannel); + }); + }, + + @computed("message.{id,stagedId}") + get messageContainer() { + const id = this.message?.id || this.message?.stagedId; + return ( + id && document.querySelector(`.chat-message-container[data-id='${id}']`) + ); + }, + + _subscribeToAppEvents() { + if (!this.message.id || this._hasSubscribedToAppEvents) { + return; + } + + this.appEvents.on("chat:refresh-message", this, "_refreshedMessage"); + + this.appEvents.on( + `chat-message-${this.message.id}:reaction`, + this, + "_handleReactionMessage" + ); + this._hasSubscribedToAppEvents = true; + }, + + _waitForIdToBePopulated() { + this.appEvents.on( + `chat-message-staged-${this.message.stagedId}:id-populated`, + this, + "_subscribeToAppEvents" + ); + }, + + @discourseComputed("canInteractWithChat", "message.staged", "isHovered") + showActions(canInteractWithChat, messageStaged, isHovered) { + return canInteractWithChat && !messageStaged && isHovered; + }, + + deletedAndCollapsed: and("message.deleted_at", "collapsed"), + hiddenAndCollapsed: and("message.hidden", "collapsed"), + collapsed: not("message.expanded"), + + @discourseComputed( + "selectingMessages", + "canFlagMessage", + "showDeleteButton", + "showRestoreButton", + "showEditButton", + "showRebakeButton" + ) + secondaryButtons() { + const buttons = []; + + buttons.push({ + id: "copyLinkToMessage", + name: I18n.t("chat.copy_link"), + icon: "link", + }); + + if (this.showEditButton) { + buttons.push({ + id: "edit", + name: I18n.t("chat.edit"), + icon: "pencil-alt", + }); + } + + if (!this.selectingMessages) { + buttons.push({ + id: "selectMessage", + name: I18n.t("chat.select"), + icon: "tasks", + }); + } + + if (this.canFlagMessage) { + buttons.push({ + id: "flag", + name: I18n.t("chat.flag"), + icon: "flag", + }); + } + + if (this.showSilenceButton) { + buttons.push({ + id: "silence", + name: I18n.t("chat.silence"), + icon: "microphone-slash", + }); + } + + if (this.showDeleteButton) { + buttons.push({ + id: "deleteMessage", + name: I18n.t("chat.delete"), + icon: "trash-alt", + }); + } + + if (this.showRestoreButton) { + buttons.push({ + id: "restore", + name: I18n.t("chat.restore"), + icon: "undo", + }); + } + + if (this.showRebakeButton) { + buttons.push({ + id: "rebakeMessage", + name: I18n.t("chat.rebake_message"), + icon: "sync-alt", + }); + } + + return buttons; + }, + + get messageActions() { + return { + reply: this.reply, + react: this.react, + copyLinkToMessage: this.copyLinkToMessage, + edit: this.edit, + selectMessage: this.selectMessage, + flag: this.flag, + silence: this.silence, + deleteMessage: this.deleteMessage, + restore: this.restore, + rebakeMessage: this.rebakeMessage, + toggleBookmark: this.toggleBookmark, + startReactionForMessageActions: this.startReactionForMessageActions, + }; + }, + + get messageCapabilities() { + return { + canReact: this.canReact, + canReply: this.canReply, + canBookmark: this.showBookmarkButton, + }; + }, + + @discourseComputed("message", "details.can_moderate") + show(message, canModerate) { + return ( + !message.deleted_at || + this.currentUser.id === this.message.user.id || + this.currentUser.staff || + canModerate + ); + }, + + @action + handleTouchStart() { + // if zoomed don't track long press + if (isZoomed()) { + return; + } + + if (!this.isHovered) { + // when testing this must be triggered immediately because there + // is no concept of "long press" there, the Ember `tap` test helper + // does send the touchstart/touchend events but immediately, see + // https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap + if (isTesting()) { + this._handleLongPress(); + } + + this._isPressingHandler = discourseLater(this._handleLongPress, 500); + } + }, + + @action + handleTouchMove() { + if (!this.isHovered) { + cancel(this._isPressingHandler); + } + }, + + @action + handleTouchEnd() { + cancel(this._isPressingHandler); + }, + + @action + _handleLongPress() { + if (isZoomed()) { + // if zoomed don't handle long press + return; + } + + document.activeElement.blur(); + document.querySelector(".chat-composer-input")?.blur(); + + this.onHoverMessage(this.message); + }, + + @discourseComputed("message.hideUserInfo", "message.chat_webhook_event") + hideUserInfo(hide, webhookEvent) { + return hide && !webhookEvent; + }, + + @discourseComputed( + "message.staged", + "message.deleted_at", + "message.in_reply_to", + "message.error", + "message.bookmark", + "isHovered" + ) + chatMessageClasses(staged, deletedAt, inReplyTo, error, bookmark, isHovered) { + let classNames = ["chat-message"]; + + if (staged) { + classNames.push("chat-message-staged"); + } + if (deletedAt) { + classNames.push("deleted"); + } + if (inReplyTo) { + classNames.push("is-reply"); + } + if (this.hideUserInfo) { + classNames.push("user-info-hidden"); + } + if (error) { + classNames.push("errored"); + } + if (isHovered) { + classNames.push("chat-message-selected"); + } + if (bookmark) { + classNames.push("chat-message-bookmarked"); + } + return classNames.join(" "); + }, + + @discourseComputed("message", "message.deleted_at", "chatChannel.status") + showEditButton(message, deletedAt) { + return ( + !deletedAt && + this.currentUser.id === message.user?.id && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed( + "message", + "message.user_flag_status", + "details.can_flag", + "message.deleted_at" + ) + canFlagMessage(message, userFlagStatus, canFlag, deletedAt) { + return ( + this.currentUser?.id !== message.user?.id && + userFlagStatus === undefined && + canFlag && + !message.chat_webhook_event && + !deletedAt + ); + }, + + @discourseComputed("message") + showSilenceButton(message) { + return ( + this.currentUser?.staff && + this.currentUser?.id !== message.user?.id && + !message.chat_webhook_event + ); + }, + + @discourseComputed("message") + canManageDeletion(message) { + return this.currentUser?.id === message.user?.id + ? this.details.can_delete_self + : this.details.can_delete_others; + }, + + @discourseComputed("message.deleted_at", "chatChannel.status") + canReply(deletedAt) { + return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed("message.deleted_at", "chatChannel.status") + canReact(deletedAt) { + return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed( + "canManageDeletion", + "message.deleted_at", + "chatChannel.status" + ) + showDeleteButton(canManageDeletion, deletedAt) { + return ( + canManageDeletion && + !deletedAt && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed( + "canManageDeletion", + "message.deleted_at", + "chatChannel.status" + ) + showRestoreButton(canManageDeletion, deletedAt) { + return ( + canManageDeletion && + deletedAt && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("chatChannel.status") + showBookmarkButton() { + return this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed("chatChannel.status") + showRebakeButton() { + return ( + this.currentUser?.staff && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("message.reactions.@each") + hasReactions(reactions) { + return Object.values(reactions).some((r) => r.count > 0); + }, + + @discourseComputed("message.mentionWarning.cannot_see") + mentionedCannotSeeText(users) { + return I18n.t("chat.mention_warning.cannot_see", { + usernames: users.mapBy("username").join(", "), + count: users.length, + }); + }, + + @discourseComputed("message.mentionWarning.without_membership") + mentionedWithoutMembershipText(users) { + return I18n.t("chat.mention_warning.without_membership", { + usernames: users.mapBy("username").join(", "), + count: users.length, + }); + }, + + @action + inviteMentioned() { + const user_ids = this.message.mentionWarning.without_membership.mapBy("id"); + + ajax(`/chat/${this.details.chat_channel_id}/invite`, { + method: "PUT", + data: { user_ids, chat_message_id: this.message.id }, + }).then(() => { + this.message.set("mentionWarning.invitationSent", true); + this._invitationSentTimer = discourseLater(() => { + this.message.set("mentionWarning", null); + }, 3000); + }); + + return false; + }, + + @action + dismissMentionWarning() { + this.message.set("mentionWarning", null); + }, + + @action + startReactionForMessageActions() { + this.chatEmojiPickerManager.startFromMessageActions( + this.message, + this.site.desktopView, + this.selectReaction + ); + }, + + @action + startReactionForReactionList() { + this.chatEmojiPickerManager.startFromMessageReactionList( + this.message, + this.site.desktopView, + this.selectReaction + ); + }, + + deselectReaction(emoji) { + if (!this.canInteractWithChat) { + return; + } + + this.react(emoji, this.REMOVE_REACTION); + this.notifyPropertyChange("emojiReactions"); + }, + + @action + selectReaction(emoji) { + if (!this.canInteractWithChat) { + return; + } + + this.react(emoji, this.ADD_REACTION); + this.notifyPropertyChange("emojiReactions"); + }, + + @bind + _handleReactionMessage(busData) { + const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji); + if (loadingReactionIndex > -1) { + return this._loadingReactions.splice(loadingReactionIndex, 1); + } + + this._updateReactionsList(busData.emoji, busData.action, busData.user); + this.afterReactionAdded(); + }, + + @action + react(emoji, reactAction) { + if (!this.canInteractWithChat || this._loadingReactions.includes(emoji)) { + return; + } + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + + if (this.site.mobileView) { + this.set("isHovered", false); + } + + this._loadingReactions.push(emoji); + this._updateReactionsList(emoji, reactAction, this.currentUser); + + if (reactAction === this.ADD_REACTION) { + this.chatEmojiReactionStore.track(`:${emoji}:`); + } + + return this._publishReaction(emoji, reactAction).then(() => { + this.notifyPropertyChange("emojiReactions"); + + // creating reaction will create a membership if not present + // so we will fully refresh if we were not members of the channel + // already + if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { + this.chat.forceRefreshChannels().then(() => { + return this.chat + .getChannelBy("id", this.chatChannel.id) + .then((reactedChannel) => { + this.onSwitchChannel(reactedChannel); + }); + }); + } + }); + }, + + _updateReactionsList(emoji, reactAction, user) { + const selfReacted = this.currentUser.id === user.id; + if (this.message.reactions[emoji]) { + if ( + selfReacted && + reactAction === this.ADD_REACTION && + this.message.reactions[emoji].reacted + ) { + // User is already has reaction added; do nothing + return false; + } + + let newCount = + reactAction === this.ADD_REACTION + ? this.message.reactions[emoji].count + 1 + : this.message.reactions[emoji].count - 1; + + this.message.reactions.set(`${emoji}.count`, newCount); + if (selfReacted) { + this.message.reactions.set( + `${emoji}.reacted`, + reactAction === this.ADD_REACTION + ); + } else { + this.message.reactions[emoji].users.pushObject(user); + } + } else { + if (reactAction === this.ADD_REACTION) { + this.message.reactions.set(emoji, { + count: 1, + reacted: selfReacted, + users: selfReacted ? [] : [user], + }); + } + } + this.message.notifyPropertyChange("reactions"); + }, + + _publishReaction(emoji, reactAction) { + return ajax( + `/chat/${this.details.chat_channel_id}/react/${this.message.id}`, + { + type: "PUT", + data: { + react_action: reactAction, + emoji, + }, + } + ).catch((errResult) => { + popupAjaxError(errResult); + this._updateReactionsList(emoji, this.REMOVE_REACTION, this.currentUser); + }); + }, + + // TODO(roman): For backwards-compatibility. + // Remove after the 3.0 release. + _legacyFlag() { + this.dialog.yesNoConfirm({ + message: I18n.t("chat.confirm_flag", { + username: this.message.user?.username, + }), + didConfirm: () => { + return ajax("/chat/flag", { + method: "PUT", + data: { + chat_message_id: this.message.id, + flag_type_id: 7, // notify_moderators + }, + }).catch(popupAjaxError); + }, + }); + }, + + @action + reply() { + this.setReplyTo(this.message.id); + }, + + @action + viewReply() { + this.replyMessageClicked(this.message.in_reply_to); + }, + + @action + edit() { + this.editButtonClicked(this.message.id); + }, + + @action + flag() { + const targetFlagSupported = + requirejs.entries["discourse/lib/flag-targets/flag"]; + + if (targetFlagSupported) { + const model = EmberObject.create(this.message); + model.set("username", model.get("user.username")); + model.set("user_id", model.get("user.id")); + let controller = showModal("flag", { model }); + + controller.setProperties({ flagTarget: new ChatMessageFlag() }); + } else { + this._legacyFlag(); + } + }, + + @action + silence() { + this.adminTools.showSilenceModal(EmberObject.create(this.message.user)); + }, + + @action + expand() { + this.message.set("expanded", true); + }, + + @action + restore() { + return ajax( + `/chat/${this.details.chat_channel_id}/restore/${this.message.id}`, + { + type: "PUT", + } + ).catch(popupAjaxError); + }, + + @action + toggleBookmark() { + return openBookmarkModal( + this.message.bookmark || + Bookmark.create({ + bookmarkable_type: "ChatMessage", + bookmarkable_id: this.message.id, + user_id: this.currentUser.id, + }), + { + onAfterSave: (savedData) => { + const bookmark = Bookmark.create(savedData); + this.set("message.bookmark", bookmark); + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + }, + onAfterDelete: () => { + this.set("message.bookmark", null); + }, + } + ); + }, + + @action + rebakeMessage() { + return ajax( + `/chat/${this.details.chat_channel_id}/${this.message.id}/rebake`, + { + type: "PUT", + } + ).catch(popupAjaxError); + }, + + @action + deleteMessage() { + return ajax(`/chat/${this.details.chat_channel_id}/${this.message.id}`, { + type: "DELETE", + }).catch(popupAjaxError); + }, + + @action + selectMessage() { + this.message.set("selected", true); + this.onStartSelectingMessages(this.message); + }, + + @action + @afterRender + toggleChecked(e) { + if (e.shiftKey) { + this.bulkSelectMessages(this.message, e.target.checked); + } + + this.onSelectMessage(this.message); + }, + + @action + copyLinkToMessage() { + if (!this.messageContainer) { + return; + } + + this.messageContainer + .querySelector(".link-to-message-btn") + ?.classList?.add("copied"); + + const { protocol, host } = window.location; + let url = getURL( + `/chat/channel/${this.details.chat_channel_id}/-?messageId=${this.message.id}` + ); + url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; + clipboardCopy(url); + + discourseLater(() => { + this.messageContainer + ?.querySelector(".link-to-message-btn") + ?.classList?.remove("copied"); + }, 250); + }, + + @computed + get emojiReactions() { + const favorites = this.cachedFavoritesReactions; + + // may be a {} if no defaults defined in some production builds + if (!favorites || !favorites.slice) { + return []; + } + + const userReactions = Object.keys(this.message.reactions).filter((key) => { + return this.message.reactions[key].reacted; + }); + + return favorites.slice(0, 3).map((emoji) => { + if (userReactions.includes(emoji)) { + return { emoji, reacted: true }; + } else { + return { emoji, reacted: false }; + } + }); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js new file mode 100644 index 0000000000..0e0f797e93 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js @@ -0,0 +1,85 @@ +import { isBlank, isPresent } from "@ember/utils"; +import Component from "@ember/component"; +import { inject as service } from "@ember/service"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { fmt } from "discourse/lib/computed"; +import { next } from "@ember/runloop"; + +export default Component.extend({ + tagName: "", + presence: service(), + presenceChannel: null, + chatChannel: null, + + @discourseComputed("presenceChannel.users.[]") + usernames(users) { + return users + ?.filter((u) => u.id !== this.currentUser.id) + ?.mapBy("username"); + }, + + @discourseComputed("usernames.[]") + text(usernames) { + if (isBlank(usernames)) { + return; + } + + if (usernames.length === 1) { + return I18n.t("chat.replying_indicator.single_user", { + username: usernames[0], + }); + } + + if (usernames.length < 4) { + const lastUsername = usernames.pop(); + const commaSeparatedUsernames = usernames.join(", "); + return I18n.t("chat.replying_indicator.multiple_users", { + commaSeparatedUsernames, + lastUsername, + }); + } + + const commaSeparatedUsernames = usernames.slice(0, 2).join(", "); + return I18n.t("chat.replying_indicator.many_users", { + commaSeparatedUsernames, + count: usernames.length - 2, + }); + }, + + @discourseComputed("usernames.[]") + shouldDisplay(usernames) { + return isPresent(usernames); + }, + + channelName: fmt("chatChannel.id", "/chat-reply/%@"), + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.chatChannel || this.chatChannel.isDraft) { + this.presenceChannel?.unsubscribe(); + return; + } + + if (this.presenceChannel?.name !== this.channelName) { + this.presenceChannel?.unsubscribe(); + + next(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + const presenceChannel = this.presence.getChannel(this.channelName); + this.set("presenceChannel", presenceChannel); + presenceChannel.subscribe(); + }); + } + }, + + willDestroyElement() { + this._super(...arguments); + + this.presenceChannel?.unsubscribe(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js new file mode 100644 index 0000000000..ec66766760 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js @@ -0,0 +1,59 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Component.extend({ + tagName: "", + loading: false, + + @discourseComputed( + "chatChannel.chatable_type", + "currentUser.{needs_dm_retention_reminder,needs_channel_retention_reminder}" + ) + show() { + return ( + !this.chatChannel.isDraft && + ((this.chatChannel.isDirectMessageChannel && + this.currentUser.needs_dm_retention_reminder) || + (!this.chatChannel.isDirectMessageChannel && + this.currentUser.needs_channel_retention_reminder)) + ); + }, + + @discourseComputed("chatChannel.chatable_type") + text() { + let days = this.siteSettings.chat_channel_retention_days; + let translationKey = "chat.retention_reminders.public"; + + if (this.chatChannel.isDirectMessageChannel) { + days = this.siteSettings.chat_dm_retention_days; + translationKey = "chat.retention_reminders.dm"; + } + return I18n.t(translationKey, { days }); + }, + + @discourseComputed("chatChannel.chatable_type") + daysCount() { + return this.chatChannel.isDirectMessageChannel + ? this.siteSettings.chat_dm_retention_days + : this.siteSettings.chat_channel_retention_days; + }, + + @action + dismiss() { + return ajax("/chat/dismiss-retention-reminder", { + method: "POST", + data: { chatable_type: this.chatChannel.chatable_type }, + }) + .then(() => { + const field = this.chatChannel.isDirectMessageChannel + ? "needs_dm_retention_reminder" + : "needs_channel_retention_reminder"; + this.currentUser.set(field, false); + }) + .catch(popupAjaxError); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js new file mode 100644 index 0000000000..a9c8887318 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js @@ -0,0 +1,124 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import showModal from "discourse/lib/show-modal"; +import { clipboardCopyAsync } from "discourse/lib/utilities"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { ajax } from "discourse/lib/ajax"; +import { isTesting } from "discourse-common/config/environment"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import getURL from "discourse-common/lib/get-url"; +import { bind } from "discourse-common/utils/decorators"; + +export default class AdminCustomizeColorsShowController extends Component { + @service router; + tagName = ""; + chatChannel = null; + selectedMessageIds = null; + showChatQuoteSuccess = false; + cancelSelecting = null; + canModerate = false; + + @computed("selectedMessageIds.length") + get anyMessagesSelected() { + return this.selectedMessageIds.length > 0; + } + + @computed("chatChannel.isDirectMessageChannel", "canModerate") + get showMoveMessageButton() { + return !this.chatChannel.isDirectMessageChannel && this.canModerate; + } + + @bind + async generateQuote() { + const response = await ajax( + getURL(`/chat/${this.chatChannel.id}/quote.json`), + { + data: { message_ids: this.selectedMessageIds }, + type: "POST", + } + ); + + return new Blob([response.markdown], { + type: "text/plain", + }); + } + + @action + openMoveMessageModal() { + showModal("chat-message-move-to-channel-modal").setProperties({ + sourceChannel: this.chatChannel, + selectedMessageIds: this.selectedMessageIds, + }); + } + + @action + async quoteMessages() { + let quoteMarkdown; + + try { + const quoteMarkdownBlob = await this.generateQuote(); + quoteMarkdown = await quoteMarkdownBlob.text(); + } catch (error) { + popupAjaxError(error); + } + + const container = getOwner(this); + const composer = container.lookup("controller:composer"); + const openOpts = {}; + + if (this.chatChannel.isCategoryChannel) { + openOpts.categoryId = this.chatChannel.chatable_id; + } + + if (this.site.mobileView) { + // go to the relevant chatable (e.g. category) and open the + // composer to insert text + if (this.chatChannel.chatable_url) { + this.router.transitionTo(this.chatChannel.chatable_url); + } + + await composer.focusComposer({ + fallbackToNewTopic: true, + insertText: quoteMarkdown, + openOpts, + }); + } else { + // open the composer and insert text, reply to the current + // topic if there is one, use the active draft if there is one + const topic = container.lookup("controller:topic"); + await composer.focusComposer({ + fallbackToNewTopic: true, + topic: topic?.model, + insertText: quoteMarkdown, + openOpts, + }); + } + } + + @action + async copyMessages() { + try { + if (!isTesting()) { + // clipboard API throws errors in tests + await clipboardCopyAsync(this.generateQuote); + } + + this.set("showChatQuoteSuccess", true); + + schedule("afterRender", () => { + const element = document.querySelector(".chat-selection-message"); + element?.addEventListener("animationend", () => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("showChatQuoteSuccess", false); + }); + }); + } catch (error) { + popupAjaxError(error); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js b/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js new file mode 100644 index 0000000000..e7ae0190ff --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js @@ -0,0 +1,44 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import discourseComputed from "discourse-common/utils/decorators"; +import { alias, equal } from "@ember/object/computed"; + +export const NEW_TOPIC_SELECTION = "newTopic"; +export const EXISTING_TOPIC_SELECTION = "existingTopic"; +export const NEW_MESSAGE_SELECTION = "newMessage"; + +export default Component.extend({ + newTopicSelection: NEW_TOPIC_SELECTION, + existingTopicSelection: EXISTING_TOPIC_SELECTION, + newMessageSelection: NEW_MESSAGE_SELECTION, + + selection: null, + newTopic: equal("selection", NEW_TOPIC_SELECTION), + existingTopic: equal("selection", EXISTING_TOPIC_SELECTION), + newMessage: equal("selection", NEW_MESSAGE_SELECTION), + canAddTags: alias("site.can_create_tag"), + canTagMessages: alias("site.can_tag_pms"), + + topicTitle: null, + categoryId: null, + tags: null, + selectedTopicId: null, + + chatMessageIds: null, + chatChannelId: null, + + @discourseComputed() + newTopicInstruction() { + return htmlSafe(this.instructionLabels[NEW_TOPIC_SELECTION]); + }, + + @discourseComputed() + existingTopicInstruction() { + return htmlSafe(this.instructionLabels[EXISTING_TOPIC_SELECTION]); + }, + + @discourseComputed() + newMessageInstruction() { + return htmlSafe(this.instructionLabels[NEW_MESSAGE_SELECTION]); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-upload.js new file mode 100644 index 0000000000..e81190ed12 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload.js @@ -0,0 +1,51 @@ +import Component from "@glimmer/component"; + +import { inject as service } from "@ember/service"; +import { isImage, isVideo } from "discourse/lib/uploads"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { htmlSafe } from "@ember/template"; + +export default class extends Component { + @service siteSettings; + + @tracked loaded = false; + + IMAGE_TYPE = "image"; + VIDEO_TYPE = "video"; + ATTACHMENT_TYPE = "attachment"; + + get type() { + if (isImage(this.args.upload.original_filename)) { + return this.IMAGE_TYPE; + } + + if (isVideo(this.args.upload.original_filename)) { + return this.VIDEO_TYPE; + } + + return this.ATTACHMENT_TYPE; + } + + get size() { + const width = this.args.upload.width; + const height = this.args.upload.height; + + const ratio = Math.min( + this.siteSettings.max_image_width / width, + this.siteSettings.max_image_height / height + ); + return { width: width * ratio, height: height * ratio }; + } + + get imageStyle() { + if (this.args.upload.dominant_color && !this.loaded) { + return htmlSafe(`background-color: #${this.args.upload.dominant_color};`); + } + } + + @action + imageLoaded() { + this.loaded = true; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js new file mode 100644 index 0000000000..0b3853e915 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatUserAvatar extends Component { + @service chat; + tagName = ""; + + user = null; + + avatarSize = "tiny"; + + @computed("chat.presenceChannel.users.[]", "user.{id,username}") + get isOnline() { + const users = this.chat.presenceChannel?.users; + + return ( + !!users?.findBy("id", this.user?.id) || + !!users?.findBy("username", this.user?.username) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js new file mode 100644 index 0000000000..3130f9d6de --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js @@ -0,0 +1,33 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { formatUsername } from "discourse/lib/utilities"; + +export default class ChatUserDisplayName extends Component { + tagName = ""; + user = null; + + @computed + get shouldPrioritizeNameInUx() { + return !this.siteSettings.prioritize_username_in_ux; + } + + @computed("user.name") + get hasValidName() { + return this.user?.name && this.user?.name.trim().length > 0; + } + + @computed("user.username") + get formattedUsername() { + return formatUsername(this.user?.username); + } + + @computed("shouldPrioritizeNameInUx", "hasValidName") + get shouldShowNameFirst() { + return this.shouldPrioritizeNameInUx && this.hasValidName; + } + + @computed("shouldPrioritizeNameInUx", "hasValidName") + get shouldShowNameLast() { + return !this.shouldPrioritizeNameInUx && this.hasValidName; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js new file mode 100644 index 0000000000..270e4a3bc9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js @@ -0,0 +1,97 @@ +import { bind } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; + +const CSS_VAR = "--chat-vh"; +let pendingUpdate = false; + +export default class ChatVh extends Component { + tagName = ""; + + didInsertElement() { + this._super(...arguments); + + this.setVHFromVisualViewPort(); + + (window?.visualViewport || window).addEventListener( + "resize", + this.setVHFromVisualViewPort + ); + + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.addEventListener( + "geometrychange", + this.setVHFromKeyboard + ); + } + } + + willDestroyElement() { + this._super(...arguments); + + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.removeEventListener( + "geometrychange", + this.setVHFromKeyboard + ); + } else { + (window?.visualViewport || window).removeEventListener( + "resize", + this.setVHFromVisualViewPort + ); + } + + pendingUpdate = false; + } + + @bind + setVHFromKeyboard(event) { + if (pendingUpdate) { + return; + } + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (isZoomed()) { + return; + } + + pendingUpdate = true; + + requestAnimationFrame(() => { + const { height } = event.target.boundingRect; + const vhInPixels = + ((window.visualViewport?.height || window.innerHeight) - height) * 0.01; + document.documentElement.style.setProperty(CSS_VAR, `${vhInPixels}px`); + + pendingUpdate = false; + }); + } + + @bind + setVHFromVisualViewPort() { + if (pendingUpdate) { + return; + } + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (isZoomed()) { + return; + } + + pendingUpdate = true; + + requestAnimationFrame(() => { + const vhInPixels = + (window.visualViewport?.height || window.innerHeight) * 0.01; + document.documentElement.style.setProperty(CSS_VAR, `${vhInPixels}px`); + + pendingUpdate = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/collapser.js b/plugins/chat/assets/javascripts/discourse/components/collapser.js new file mode 100644 index 0000000000..f7d05cf837 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/collapser.js @@ -0,0 +1,19 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + collapsed: false, + header: null, + + @action + open() { + this.set("collapsed", false); + }, + + @action + close() { + this.set("collapsed", true); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js new file mode 100644 index 0000000000..4889d541a1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js @@ -0,0 +1,111 @@ +// temporary stuff to be moved in core with discourse-loading-slider + +import Component from "@ember/component"; +import { cancel, schedule } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +const STORE_LOADING_TIMES = 5; +const DEFAULT_LOADING_TIME = 0.3; +const MIN_LOADING_TIME = 0.1; +const STILL_LOADING_DURATION = 2; + +export default Component.extend({ + tagName: "", + isLoading: false, + key: null, + + init() { + this._super(...arguments); + + this.loadingTimes = [DEFAULT_LOADING_TIME]; + this.set("averageTime", DEFAULT_LOADING_TIME); + this.i = 0; + this.scheduled = []; + }, + + resetState() { + this.container?.classList?.remove("done", "loading", "still-loading"); + }, + + cancelScheduled() { + this.scheduled.forEach((s) => cancel(s)); + this.scheduled = []; + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.key) { + return; + } + + this.cancelScheduled(); + this.resetState(); + + if (this.isLoading) { + this.start(); + } else { + this.end(); + } + }, + + get container() { + return document.getElementById(this.key); + }, + + start() { + this.set("startedAt", Date.now()); + + this.scheduled.push(discourseLater(this, "startLoading")); + this.scheduled.push( + discourseLater(this, "stillLoading", STILL_LOADING_DURATION * 1000) + ); + }, + + startLoading() { + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.add("loading"); + document.documentElement.style.setProperty( + "--loading-duration", + `${this.averageTime.toFixed(2)}s` + ); + }) + ); + }, + + stillLoading() { + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.add("still-loading"); + }) + ); + }, + + end() { + this.updateAverage((Date.now() - this.startedAt) / 1000); + + this.cancelScheduled(); + + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.remove("loading", "still-loading"); + this.container?.classList?.add("done"); + }) + ); + }, + + updateAverage(durationSeconds) { + if (durationSeconds < MIN_LOADING_TIME) { + durationSeconds = MIN_LOADING_TIME; + } + + this.loadingTimes[this.i] = durationSeconds; + + this.i = (this.i + 1) % STORE_LOADING_TIMES; + this.set( + "averageTime", + this.loadingTimes.reduce((p, c) => p + c, 0) / this.loadingTimes.length + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js new file mode 100644 index 0000000000..60b7b82ea4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class DcFilterInput extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js new file mode 100644 index 0000000000..66cd36bcee --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js @@ -0,0 +1,316 @@ +import { caretPosition } from "discourse/lib/utilities"; +import { isEmpty } from "@ember/utils"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import discourseDebounce from "discourse-common/lib/debounce"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { inject as service } from "@ember/service"; +import { schedule } from "@ember/runloop"; +import { gt, not } from "@ember/object/computed"; +import { createDirectMessageChannelDraft } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + users: null, + selectedUsers: null, + term: null, + isFiltering: false, + isFilterFocused: false, + highlightedSelectedUser: null, + focusedUser: null, + chat: service(), + router: service(), + chatStateManager: service(), + isLoading: false, + onSwitchChannel: null, + + init() { + this._super(...arguments); + + this.set("users", []); + this.set("selectedUsers", []); + this.set("channel", createDirectMessageChannelDraft()); + }, + + didInsertElement() { + this._super(...arguments); + + this.filterUsernames(); + }, + + didReceiveAttrs() { + this._super(...arguments); + + this.set("term", null); + + this.focusFilter(); + + if (!this.hasSelection) { + this.filterUsernames(); + } + }, + + hasSelection: gt("channel.chatable.users.length", 0), + + @discourseComputed + chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + }, + + @bind + filterUsernames(term = null) { + this.set("isFiltering", true); + + this.chat + .searchPossibleDirectMessageUsers({ + term, + limit: 6, + exclude: this.channel.chatable?.users?.mapBy("username") || [], + lastSeenUsers: isEmpty(term) ? true : false, + }) + .then((r) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (r !== "__CANCELLED") { + this.set("users", r.users || []); + this.set("focusedUser", this.users.firstObject); + } + }) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isFiltering", false); + }); + }, + + shouldRenderResults: not("isFiltering"), + + @action + selectUser(user) { + this.selectedUsers.pushObject(user); + this.users.removeObject(user); + this.set("users", []); + this.set("focusedUser", null); + this.set("highlightedSelectedUser", null); + this.set("term", null); + this.focusFilter(); + this.onChangeSelectedUsers?.(this.selectedUsers); + }, + + @action + deselectUser(user) { + this.users.removeObject(user); + this.selectedUsers.removeObject(user); + this.set("focusedUser", this.users.firstObject); + this.set("highlightedSelectedUser", null); + this.set("term", null); + + if (isEmpty(this.selectedUsers)) { + this.filterUsernames(); + } + + this.focusFilter(); + this.onChangeSelectedUsers?.(this.selectedUsers); + }, + + @action + focusFilter() { + this.set("isFilterFocused", true); + + schedule("afterRender", () => { + document.querySelector(".filter-usernames")?.focus(); + }); + }, + + @action + onFilterInput(term) { + this.set("term", term); + this.set("users", []); + + if (!term?.length) { + return; + } + + this.set("isFiltering", true); + + discourseDebounce(this, this.filterUsernames, term, INPUT_DELAY); + }, + + @action + handleUserKeyUp(user, event) { + if (event.key === "Enter") { + event.stopPropagation(); + event.preventDefault(); + this.selectUser(user); + } + }, + + @action + onFilterInputFocusOut() { + this.set("isFilterFocused", false); + this.set("highlightedSelectedUser", null); + }, + + @action + leaveChannel() { + this.router.transitionTo("chat.index"); + }, + + @action + handleFilterKeyUp(event) { + if (event.key === "Tab") { + const enabledComposer = document.querySelector(".chat-composer-input"); + if (enabledComposer && !enabledComposer.disabled) { + event.preventDefault(); + event.stopPropagation(); + enabledComposer.focus(); + } + } + + if ( + (event.key === "Enter" || event.key === "Backspace") && + this.highlightedSelectedUser + ) { + event.preventDefault(); + event.stopPropagation(); + this.deselectUser(this.highlightedSelectedUser); + return; + } + + if (event.key === "Backspace" && isEmpty(this.term) && this.hasSelection) { + event.preventDefault(); + event.stopPropagation(); + + this.deselectUser(this.channel.chatable.users.lastObject); + } + + if (event.key === "Enter" && this.focusedUser) { + event.preventDefault(); + event.stopPropagation(); + this.selectUser(this.focusedUser); + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + this._handleVerticalArrowKeys(event); + } + + if (event.key === "Escape" && this.highlightedSelectedUser) { + this.set("highlightedSelectedUser", null); + } + + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + this._handleHorizontalArrowKeys(event); + } + }, + + _firstSelectWithArrows(event) { + if (event.key === "ArrowRight") { + return; + } + + if (event.key === "ArrowLeft") { + const position = caretPosition( + document.querySelector(".filter-usernames") + ); + if (position > 0) { + return; + } else { + event.preventDefault(); + event.stopPropagation(); + this.set( + "highlightedSelectedUser", + this.channel.chatable.users.lastObject + ); + } + } + }, + + _changeSelectionWithArrows(event) { + if (event.key === "ArrowRight") { + if ( + this.highlightedSelectedUser === this.channel.chatable.users.lastObject + ) { + this.set("highlightedSelectedUser", null); + return; + } + + if (this.channel.chatable.users.length === 1) { + return; + } + + this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1); + } + + if (event.key === "ArrowLeft") { + if (this.channel.chatable.users.length === 1) { + return; + } + + this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1); + } + }, + + _highlightNextSelectedUser(modifier) { + const newIndex = + this.channel.chatable.users.indexOf(this.highlightedSelectedUser) + + modifier; + + if (this.channel.chatable.users.objectAt(newIndex)) { + this.set( + "highlightedSelectedUser", + this.channel.chatable.users.objectAt(newIndex) + ); + } else { + this.set( + "highlightedSelectedUser", + event.key === "ArrowLeft" + ? this.channel.chatable.users.lastObject + : this.channel.chatable.users.firstObject + ); + } + }, + + _handleHorizontalArrowKeys(event) { + const position = caretPosition(document.querySelector(".filter-usernames")); + if (position > 0) { + return; + } + + if (!this.highlightedSelectedUser) { + this._firstSelectWithArrows(event); + } else { + this._changeSelectionWithArrows(event); + } + }, + + _handleVerticalArrowKeys(event) { + if (isEmpty(this.users)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!this.focusedUser) { + this.set("focusedUser", this.users.firstObject); + return; + } + + const modifier = event.key === "ArrowUp" ? -1 : 1; + const newIndex = this.users.indexOf(this.focusedUser) + modifier; + + if (this.users.objectAt(newIndex)) { + this.set("focusedUser", this.users.objectAt(newIndex)); + } else { + this.set( + "focusedUser", + event.key === "ArrowUp" ? this.users.lastObject : this.users.firstObject + ); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js new file mode 100644 index 0000000000..76edf0f21e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js @@ -0,0 +1,95 @@ +import Component from "@ember/component"; +import { bind } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + tagName: "", + router: service(), + chat: service(), + + init() { + this._super(...arguments); + + this.appEvents.on("chat:refresh-channels", this, "refreshModel"); + this.appEvents.on("chat:refresh-channel", this, "_refreshChannel"); + }, + + didInsertElement() { + this._super(...arguments); + + this._scrollSidebarToBottom(); + document.addEventListener("keydown", this._autoFocusChatComposer); + }, + + willDestroyElement() { + this._super(...arguments); + + this.appEvents.off("chat:refresh-channels", this, "refreshModel"); + this.appEvents.off("chat:refresh-channel", this, "_refreshChannel"); + document.removeEventListener("keydown", this._autoFocusChatComposer); + }, + + @bind + _autoFocusChatComposer(event) { + if ( + !event.key || + // Handles things like Enter, Tab, Shift + event.key.length > 1 || + // Don't need to focus if the user is beginning a shortcut. + event.metaKey || + event.ctrlKey || + // Space's key comes through as ' ' so it's not covered by event.key + event.code === "Space" || + // ? is used for the keyboard shortcut modal + event.key === "?" + ) { + return; + } + + if ( + !event.target || + /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName) + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const composer = document.querySelector(".chat-composer-input"); + if (composer && !this.chat.activeChannel.isDraft) { + this.appEvents.trigger("chat:insert-text", event.key); + composer.focus(); + } + }, + + _scrollSidebarToBottom() { + if (!this.teamsSidebarOn) { + return; + } + + const sidebarScroll = document.querySelector( + ".sidebar-container .scroll-wrapper" + ); + if (sidebarScroll) { + sidebarScroll.scrollTop = sidebarScroll.scrollHeight; + } + }, + + _refreshChannel(channelId) { + if (this.chat.activeChannel?.id === channelId) { + this.refreshModel(true); + } + }, + + @action + navigateToIndex() { + this.router.transitionTo("chat.index"); + }, + + @action + switchChannel(channel) { + return this.chat.openChannel(channel); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js new file mode 100644 index 0000000000..1cda1aaf90 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js @@ -0,0 +1,48 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { bind } from "discourse-common/utils/decorators"; +import { guidFor } from "@ember/object/internals"; + +export default class OnVisibilityAction extends Component { + action = null; + + root = document.body; + + @computed + get onVisibilityActionId() { + return "on-visibility-action-" + guidFor(this); + } + + _element() { + return document.getElementById(this.onVisibilityActionId); + } + + didInsertElement() { + this._super(...arguments); + + let options = { + root: this.root, + rootMargin: "0px", + threshold: 1.0, + }; + + this._observer = new IntersectionObserver(this._handleIntersect, options); + this._observer.observe(this._element()); + } + + willDestroyElement() { + this._super(...arguments); + + this._observer?.disconnect(); + this.root = null; + } + + @bind + _handleIntersect(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.action?.(); + } + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js new file mode 100644 index 0000000000..68884a0191 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js @@ -0,0 +1,20 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class SidebarChannels extends Component { + @service chat; + @service router; + tagName = ""; + toggleSection = null; + + @computed("chat.userCanChat") + get isDisplayed() { + return this.chat.userCanChat; + } + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js b/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js new file mode 100644 index 0000000000..866e85c26b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js @@ -0,0 +1,94 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action, computed } from "@ember/object"; + +export default class ToggleChannelMembershipButton extends Component { + @service chat; + + tagName = ""; + channel = null; + onToggle = null; + options = null; + isLoading = false; + + init() { + super.init(...arguments); + + this.set( + "options", + Object.assign( + { + labelType: "normal", + joinTitle: I18n.t("chat.channel_settings.join_channel"), + joinIcon: "", + joinClass: "", + leaveTitle: I18n.t("chat.channel_settings.leave_channel"), + leaveIcon: "", + leaveClass: "", + }, + this.options || {} + ) + ); + } + + @computed("channel.current_user_membership.following") + get label() { + if (this.options.labelType === "none") { + return ""; + } + + if (this.options.labelType === "short") { + if (this.channel.isFollowing) { + return I18n.t("chat.channel_settings.leave"); + } else { + return I18n.t("chat.channel_settings.join"); + } + } + + if (this.channel.isFollowing) { + return I18n.t("chat.channel_settings.leave_channel"); + } else { + return I18n.t("chat.channel_settings.join_channel"); + } + } + + @action + onJoinChannel() { + this.set("isLoading", true); + + return this.chat + .followChannel(this.channel) + .then(() => { + this.onToggle?.(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isLoading", false); + }); + } + + @action + onLeaveChannel() { + this.set("isLoading", true); + + return this.chat + .unfollowChannel(this.channel) + .then(() => { + this.onToggle?.(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isLoading", false); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js b/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js new file mode 100644 index 0000000000..d13ccf1eef --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js @@ -0,0 +1,335 @@ +import Component from "@ember/component"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { + CHAT_VIEW, + DRAFT_CHANNEL_VIEW, + LIST_VIEW, +} from "discourse/plugins/chat/discourse/services/chat"; +import { equal } from "@ember/object/computed"; +import { cancel, next, throttle } from "@ember/runloop"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + listView: equal("view", LIST_VIEW), + chatView: equal("view", CHAT_VIEW), + draftChannelView: equal("view", DRAFT_CHANNEL_VIEW), + classNameBindings: [":topic-chat-float-container", "hidden"], + chat: service(), + router: service(), + chatStateManager: service(), + hidden: true, + loading: false, + expanded: true, // TODO - false when not first-load topic + showClose: true, // TODO - false when on same topic + sizeTimer: null, + rafTimer: null, + view: null, + hasUnreadMessages: false, + + didInsertElement() { + this._super(...arguments); + if (!this.chat.userCanChat) { + return; + } + + this._checkSize(); + this.appEvents.on("chat:open-url", this, "openURL"); + this.appEvents.on("chat:toggle-close", this, "close"); + this.appEvents.on("chat:open-channel", this, "switchChannel"); + this.appEvents.on( + "chat:open-channel-at-message", + this, + "openChannelAtMessage" + ); + this.appEvents.on("chat:refresh-channels", this, "refreshChannels"); + this.appEvents.on("composer:closed", this, "_checkSize"); + this.appEvents.on("composer:opened", this, "_checkSize"); + this.appEvents.on("composer:resized", this, "_checkSize"); + this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize"); + this.appEvents.on( + "composer:resize-started", + this, + "_startDynamicCheckSize" + ); + this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize"); + }, + + willDestroyElement() { + this._super(...arguments); + if (!this.chat.userCanChat) { + return; + } + + if (this.appEvents) { + this.appEvents.off("chat:open-url", this, "openURL"); + this.appEvents.off("chat:toggle-close", this, "close"); + this.appEvents.off("chat:open-channel", this, "switchChannel"); + this.appEvents.off( + "chat:open-channel-at-message", + this, + "openChannelAtMessage" + ); + this.appEvents.off("chat:refresh-channels", this, "refreshChannels"); + this.appEvents.off("composer:closed", this, "_checkSize"); + this.appEvents.off("composer:opened", this, "_checkSize"); + this.appEvents.off("composer:resized", this, "_checkSize"); + this.appEvents.off("composer:div-resizing", this, "_dynamicCheckSize"); + this.appEvents.off( + "composer:resize-started", + this, + "_startDynamicCheckSize" + ); + this.appEvents.off( + "composer:resize-ended", + this, + "_clearDynamicCheckSize" + ); + } + if (this.sizeTimer) { + cancel(this.sizeTimer); + this.sizeTimer = null; + } + if (this.rafTimer) { + window.cancelAnimationFrame(this.rafTimer); + } + }, + + @observes("hidden") + _fireHiddenAppEvents() { + this.chat.set("chatOpen", !this.hidden); + this.appEvents.trigger("chat:rerender-header"); + }, + + @discourseComputed("expanded") + topLineClass(expanded) { + const baseClass = "topic-chat-drawer-header__top-line"; + return expanded ? `${baseClass}--expanded` : `${baseClass}--collapsed`; + }, + + @discourseComputed("expanded", "chat.activeChannel") + displayMembers(expanded, channel) { + return expanded && !channel?.isDirectMessageChannel; + }, + + @discourseComputed("displayMembers") + infoTabRoute(displayMembers) { + if (displayMembers) { + return "chat.channel.info.members"; + } + + return "chat.channel.info.settings"; + }, + + openChannelAtMessage(channel, messageId) { + this.chat.openChannel(channel, messageId); + }, + + _dynamicCheckSize() { + if (!this.rafTimer) { + this.rafTimer = window.requestAnimationFrame(() => { + this.rafTimer = null; + this._performCheckSize(); + }); + } + }, + + _startDynamicCheckSize() { + this.element.classList.add("clear-transitions"); + }, + + _clearDynamicCheckSize() { + this.element.classList.remove("clear-transitions"); + this._checkSize(); + }, + + _checkSize() { + this.sizeTimer = throttle(this, this._performCheckSize, 150); + }, + + _performCheckSize() { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + + const composer = document.getElementById("reply-control"); + const composerIsClosed = composer.classList.contains("closed"); + const minRightMargin = 15; + this.element.style.setProperty( + "--composer-right", + (composerIsClosed + ? minRightMargin + : Math.max(minRightMargin, composer.offsetLeft)) + "px" + ); + }, + + @discourseComputed( + "hidden", + "expanded", + "displayMembers", + "chat.activeChannel", + "chatView" + ) + containerClassNames(hidden, expanded, displayMembers, activeChannel) { + const classNames = ["topic-chat-container"]; + if (expanded) { + classNames.push("expanded"); + } + if (!hidden && expanded) { + classNames.push("visible"); + } + if (activeChannel) { + classNames.push(`channel-${activeChannel.id}`); + } + return classNames.join(" "); + }, + + @discourseComputed("expanded") + expandIcon(expanded) { + if (expanded) { + return "angle-double-down"; + } else { + return "angle-double-up"; + } + }, + + @discourseComputed( + "chat.activeChannel", + "currentUser.chat_channel_tracking_state" + ) + unreadCount(activeChannel, trackingState) { + return trackingState[activeChannel.id]?.unread_count || 0; + }, + + @action + openURL(URL = null) { + this.set("hidden", false); + this.set("expanded", true); + + this.chatStateManager.storeChatURL(URL); + + const route = this._buildRouteFromURL( + URL || this.chatStateManager.lastKnownChatURL + ); + + switch (route.name) { + case "chat": + this.chat.setActiveChannel(null); + this.set("view", LIST_VIEW); + this.appEvents.trigger("chat:float-toggled", false); + return; + case "chat.draft-channel": + this.chat.setActiveChannel(null); + this.set("view", DRAFT_CHANNEL_VIEW); + this.appEvents.trigger("chat:float-toggled", false); + return; + case "chat.channel": + return this.chat + .getChannelBy("id", route.params.channelId) + .then((channel) => { + this.chat.setActiveChannel(channel); + this.set("view", CHAT_VIEW); + this.appEvents.trigger("chat:float-toggled", false); + }); + } + }, + + @action + openInFullPage() { + this.chatStateManager.storeAppURL(); + this.chatStateManager.prefersFullPage(); + this.chat.setActiveChannel(null); + + return this.router.transitionTo(this.chatStateManager.lastKnownChatURL); + }, + + @action + toggleExpand() { + this.set("expanded", !this.expanded); + this.appEvents.trigger("chat:toggle-expand", this.expanded); + }, + + @action + close() { + this.set("hidden", true); + this.set("expanded", false); + this.chat.setActiveChannel(null); + this.appEvents.trigger("chat:float-toggled", this.hidden); + }, + + @action + refreshChannels() { + if (this.view === LIST_VIEW) { + this.fetchChannels(); + } + }, + + @action + fetchChannels() { + this.set("loading", true); + + this.chat.getChannels().then(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.setProperties({ + loading: false, + expanded: true, + view: LIST_VIEW, + }); + + this.chat.setActiveChannel(null); + }); + }, + + @action + switchChannel(channel) { + // we need next here to ensure we correctly let the time for routes transitions + // eg: deactivate hook of full page chat routes will set activeChannel to null + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.chat.setActiveChannel(channel); + + if (!channel) { + const URL = this._buildURLFromState(LIST_VIEW); + this.openURL(URL); + return; + } + + const URL = this._buildURLFromState(CHAT_VIEW, channel); + this.openURL(URL); + }); + }, + + _buildRouteFromURL(URL) { + let route = this.router.recognize(URL || "/"); + + // ember might recognize the index subroute + if (route.localName === "index") { + route = route.parent; + } + + return route; + }, + + _buildURLFromState(view, channel = null) { + switch (view) { + case LIST_VIEW: + return "/chat"; + case DRAFT_CHANNEL_VIEW: + return "/chat/draft-channel"; + case CHAT_VIEW: + if (channel) { + return `/chat/channel/${channel.id}/${channel.slug || "-"}`; + } else { + return "/chat"; + } + default: + return "/chat"; + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js b/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js new file mode 100644 index 0000000000..8d9f7d40ca --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js @@ -0,0 +1,17 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class UserCardChatButton extends Component { + @service chat; + + @action + startChatting() { + this.chat + .upsertDmChannelForUsernames([this.user.username]) + .then((chatChannel) => { + this.chat.openChannel(chatChannel); + this.appEvents.trigger("card:close"); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js new file mode 100644 index 0000000000..742bb3c18e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js @@ -0,0 +1,3 @@ +import templateOnly from "@ember/component/template-only"; + +export default templateOnly(); diff --git a/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js new file mode 100644 index 0000000000..5190e0d579 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js @@ -0,0 +1,11 @@ +import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; + +export default class UserMenuChatNotificationsList extends UserMenuNotificationsList { + get dismissTypes() { + return this.filterByTypes; + } + + get emptyStateComponent() { + return "user-menu/chat-notifications-list-empty-state"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs new file mode 100644 index 0000000000..d933bf21e5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs b/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs new file mode 100644 index 0000000000..8ea2da698b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs b/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs new file mode 100644 index 0000000000..c01ace03ae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs @@ -0,0 +1,3 @@ +{{#if this.user.can_chat_user}} + +{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs b/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs new file mode 100644 index 0000000000..954bade265 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs @@ -0,0 +1,5 @@ +{{#if this.model.can_chat}} + + {{i18n "chat.title_capitalized"}} + +{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js b/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js new file mode 100644 index 0000000000..3d74162136 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js @@ -0,0 +1,135 @@ +import Controller from "@ember/controller"; +import EmberObject, { action, computed } from "@ember/object"; +import I18n from "I18n"; +import { and } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; + +export default class AdminPluginsChatController extends Controller { + @service dialog; + queryParams = { selectedWebhookId: "id" }; + + loading = false; + creatingNew = false; + newWebhookName = ""; + newWebhookChannelId = null; + emojiPickerIsActive = false; + + @and("newWebhookName", "newWebhookChannelId") nameAndChannelValid; + + @computed("model.incoming_chat_webhooks.@each.updated_at") + get sortedWebhooks() { + return ( + this.model.incoming_chat_webhooks?.sortBy("updated_at").reverse() || [] + ); + } + + @computed("selectedWebhookId") + get selectedWebhook() { + if (!this.selectedWebhookId) { + return; + } + + const id = parseInt(this.selectedWebhookId, 10); + return this.model.incoming_chat_webhooks.findBy("id", id); + } + + @computed("selectedWebhook.name", "selectedWebhook.chat_channel.id") + get saveEditDisabled() { + return !this.selectedWebhook.name || !this.selectedWebhook.chat_channel.id; + } + + @action + createNewWebhook() { + if (this.loading) { + return; + } + + this.set("loading", true); + const data = { + name: this.newWebhookName, + chat_channel_id: this.newWebhookChannelId, + }; + + return ajax("/admin/plugins/chat/hooks", { data, type: "POST" }) + .then((webhook) => { + const newWebhook = EmberObject.create(webhook); + this.set( + "model.incoming_chat_webhooks", + [newWebhook].concat(this.model.incoming_chat_webhooks) + ); + this.resetNewWebhook(); + this.setProperties({ + loading: false, + selectedWebhookId: newWebhook.id, + }); + }) + .catch(popupAjaxError); + } + + @action + resetNewWebhook() { + this.setProperties({ + creatingNew: false, + newWebhookName: "", + newWebhookChannelId: null, + }); + } + + @action + destroyWebhook(webhook) { + this.dialog.deleteConfirm({ + message: I18n.t("chat.incoming_webhooks.confirm_destroy"), + didConfirm: () => { + this.set("loading", true); + return ajax(`/admin/plugins/chat/hooks/${webhook.id}`, { + type: "DELETE", + }) + .then(() => { + this.model.incoming_chat_webhooks.removeObject(webhook); + this.set("loading", false); + }) + .catch(popupAjaxError); + }, + }); + } + + @action + emojiSelected(emoji) { + this.selectedWebhook.set("emoji", `:${emoji}:`); + return this.set("emojiPickerIsActive", false); + } + + @action + saveEdit() { + this.set("loading", true); + const data = { + name: this.selectedWebhook.name, + chat_channel_id: this.selectedWebhook.chat_channel.id, + description: this.selectedWebhook.description, + emoji: this.selectedWebhook.emoji, + username: this.selectedWebhook.username, + }; + return ajax(`/admin/plugins/chat/hooks/${this.selectedWebhook.id}`, { + data, + type: "PUT", + }) + .then(() => { + this.selectedWebhook.set("updated_at", new Date()); + this.setProperties({ + loading: false, + selectedWebhookId: null, + }); + }) + .catch(popupAjaxError); + } + + @action + changeChatChannel(chatChannelId) { + this.selectedWebhook.set( + "chat_channel", + this.model.chat_channels.findBy("id", chatChannelId) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js new file mode 100644 index 0000000000..d46a9f241d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatChannelArchiveModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js new file mode 100644 index 0000000000..ad230c1021 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatChannelDeleteModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js new file mode 100644 index 0000000000..0efdb70bbf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js @@ -0,0 +1,49 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; + +export default class ChatChannelEditDescriptionController extends Controller.extend( + ModalFunctionality +) { + editedDescription = ""; + + @computed("model.description", "editedDescription") + get isSaveDisabled() { + return ( + this.model.description === this.editedDescription || + this.editedDescription?.length > 280 + ); + } + + onShow() { + this.set("editedDescription", this.model.description || ""); + } + + onClose() { + this.set("editedDescription", ""); + this.clearFlash(); + } + + @action + onSaveChatChannelDescription() { + return ChatApi.modifyChatChannel(this.model.id, { + description: this.editedDescription, + }) + .then((chatChannel) => { + this.model.set("description", chatChannel.description); + this.send("closeModal"); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + onChangeChatChannelDescription(description) { + this.clearFlash(); + this.set("editedDescription", description); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js new file mode 100644 index 0000000000..9eb3dbde1f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js @@ -0,0 +1,49 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; + +export default class ChatChannelEditTitleController extends Controller.extend( + ModalFunctionality +) { + editedTitle = ""; + + @computed("model.title", "editedTitle") + get isSaveDisabled() { + return ( + this.model.title === this.editedTitle || + this.editedTitle?.length > this.siteSettings.max_topic_title_length + ); + } + + onShow() { + this.set("editedTitle", this.model.title || ""); + } + + onClose() { + this.set("editedTitle", ""); + this.clearFlash(); + } + + @action + onSaveChatChannelTitle() { + return ChatApi.modifyChatChannel(this.model.id, { + name: this.editedTitle, + }) + .then((chatChannel) => { + this.model.set("title", chatChannel.title); + this.send("closeModal"); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + onChangeChatChannelTitle(title) { + this.clearFlash(); + this.set("editedTitle", title); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js new file mode 100644 index 0000000000..57c3dc7072 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelIndexController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js new file mode 100644 index 0000000000..c7976a24ff --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js @@ -0,0 +1,20 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import showModal from "discourse/lib/show-modal"; + +export default class ChatChannelInfoAboutController extends Controller.extend( + ModalFunctionality +) { + @action + onEditChatChannelTitle() { + showModal("chat-channel-edit-title", { model: this.model?.chatChannel }); + } + + @action + onEditChatChannelDescription() { + showModal("chat-channel-edit-description", { + model: this.model?.chatChannel, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js new file mode 100644 index 0000000000..48e3c61558 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelInfoMembersController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js new file mode 100644 index 0000000000..a70d62d1ea --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelInfoSettingsController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js new file mode 100644 index 0000000000..720e6f635f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js @@ -0,0 +1,37 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { reads } from "@ember/object/computed"; + +export default class ChatChannelInfoIndexController extends Controller { + @service router; + @service chat; + @service chatChannelInfoRouteOriginManager; + + @reads("router.currentRoute.localName") tab; + + @computed("model.chatChannel.{membershipsCount,status}") + get tabs() { + const tabs = []; + + if (!this.model.chatChannel.isDirectMessageChannel) { + tabs.push("about"); + } + + if ( + this.model.chatChannel.isOpen && + this.model.chatChannel.membershipsCount >= 1 + ) { + tabs.push("members"); + } + + tabs.push("settings"); + + return tabs; + } + + @action + switchChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js new file mode 100644 index 0000000000..ac07dd2483 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { action } from "@ember/object"; + +export default class ChatChannelSelectorModalController extends Controller.extend( + ModalFunctionality +) { + @action + closeSelf() { + this.send("closeModal"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js new file mode 100644 index 0000000000..e3dfaf5a61 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js @@ -0,0 +1,18 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelToggleController extends Controller.extend( + ModalFunctionality +) { + @service chat; + + chatChannel = null; + + @action + channelStatusChanged(channel) { + this.send("closeModal"); + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js new file mode 100644 index 0000000000..75d4a3b4ee --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js @@ -0,0 +1,14 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelController extends Controller { + @service chat; + + queryParams = ["messageId"]; + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js new file mode 100644 index 0000000000..162d6a72ef --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatDraftChannelController extends Controller { + @service chat; + + @action + onSwitchChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js new file mode 100644 index 0000000000..fd2b50ff96 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatIndexController extends Controller { + @service chat; + + @action + selectChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js new file mode 100644 index 0000000000..86ebd1e8ab --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatMessageMoveToChannelModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat.js b/plugins/chat/assets/javascripts/discourse/controllers/chat.js new file mode 100644 index 0000000000..d823b34f6e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatController extends Controller { + @service chat; + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js new file mode 100644 index 0000000000..00b9b8e0ae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -0,0 +1,172 @@ +import { escapeExpression } from "discourse/lib/utilities"; +import Controller from "@ember/controller"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import I18n from "I18n"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { ajax } from "discourse/lib/ajax"; +import { action, computed } from "@ember/object"; +import { gt, notEmpty } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { isBlank } from "@ember/utils"; +import { htmlSafe } from "@ember/template"; + +const DEFAULT_HINT = htmlSafe( + I18n.t("chat.create_channel.choose_category.default_hint", { + link: "/categories", + category: "category", + }) +); + +export default class CreateChannelController extends Controller.extend( + ModalFunctionality +) { + @service chat; + @service dialog; + + category = null; + categoryId = null; + name = ""; + description = ""; + categoryPermissionsHint = null; + autoJoinUsers = null; + autoJoinWarning = ""; + + @notEmpty("category") categorySelected; + @gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable; + + @computed("categorySelected", "name") + get createDisabled() { + return !this.categorySelected || isBlank(this.name); + } + + onShow() { + this.set("categoryPermissionsHint", DEFAULT_HINT); + } + + onClose() { + this.setProperties({ + categoryId: null, + category: null, + name: "", + description: "", + categoryPermissionsHint: DEFAULT_HINT, + autoJoinWarning: "", + }); + } + + _createChannel() { + const data = { + id: this.categoryId, + name: this.name, + description: this.description, + auto_join_users: this.autoJoinUsers, + }; + + return ajax("/chat/chat_channels", { method: "PUT", data }) + .then((response) => { + const chatChannel = ChatChannel.create(response.chat_channel); + + return this.chat.startTrackingChannel(chatChannel).then(() => { + this.send("closeModal"); + this.chat.openChannel(chatChannel); + }); + }) + .catch((e) => { + this.flash(e.jqXHR.responseJSON.errors[0], "error"); + }); + } + + _buildCategorySlug(category) { + const parent = category.parentCategory; + + if (parent) { + return `${this._buildCategorySlug(parent)}/${category.slug}`; + } else { + return category.slug; + } + } + + _updateAutoJoinConfirmWarning(category, catPermissions) { + const allowedGroups = catPermissions.allowed_groups; + + if (catPermissions.private) { + const warningTranslationKey = + allowedGroups.length < 3 ? "warning_groups" : "warning_multiple_groups"; + + this.set( + "autoJoinWarning", + I18n.t(`chat.create_channel.auto_join_users.${warningTranslationKey}`, { + members_count: catPermissions.members_count, + group: escapeExpression(allowedGroups[0]), + group_2: escapeExpression(allowedGroups[1]), + count: allowedGroups.length, + }) + ); + } else { + this.set( + "autoJoinWarning", + I18n.t(`chat.create_channel.auto_join_users.public_category_warning`, { + category: escapeExpression(category.name), + }) + ); + } + } + + _updatePermissionsHint(category) { + if (category) { + const fullSlug = this._buildCategorySlug(category); + + return ChatApi.categoryPermissions(category.id).then((catPermissions) => { + this._updateAutoJoinConfirmWarning(category, catPermissions); + const allowedGroups = catPermissions.allowed_groups; + const translationKey = + allowedGroups.length < 3 ? "hint_groups" : "hint_multiple_groups"; + + this.set( + "categoryPermissionsHint", + htmlSafe( + I18n.t(`chat.create_channel.choose_category.${translationKey}`, { + link: `/c/${escapeExpression(fullSlug)}/edit/security`, + hint: escapeExpression(allowedGroups[0]), + hint_2: escapeExpression(allowedGroups[1]), + count: allowedGroups.length, + }) + ) + ); + }); + } else { + this.set("categoryPermissionsHint", DEFAULT_HINT); + this.set("autoJoinWarning", ""); + } + } + + @action + onCategoryChange(categoryId) { + let category = categoryId + ? this.site.categories.findBy("id", categoryId) + : null; + this._updatePermissionsHint(category); + this.setProperties({ + categoryId, + category, + name: category?.name || "", + }); + } + + @action + create() { + if (this.createDisabled) { + return; + } + + if (this.autoJoinUsers) { + this.dialog.yesNoConfirm({ + message: this.autoJoinWarning, + didConfirm: () => this._createChannel(), + }); + } else { + this._createChannel(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js new file mode 100644 index 0000000000..b89baf2ccd --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js @@ -0,0 +1,56 @@ +import Controller from "@ember/controller"; +import { isTesting } from "discourse-common/config/environment"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { CHAT_SOUNDS } from "discourse/plugins/chat/discourse/services/chat-audio-manager"; +import { inject as service } from "@ember/service"; + +const CHAT_ATTRS = [ + "chat_enabled", + "only_chat_push_notifications", + "ignore_channel_wide_mention", + "chat_sound", + "chat_email_frequency", +]; + +const EMAIL_FREQUENCY_OPTIONS = [ + { name: I18n.t(`chat.email_frequency.never`), value: "never" }, + { name: I18n.t(`chat.email_frequency.when_away`), value: "when_away" }, +]; + +export default class PreferencesChatController extends Controller { + @service chatAudioManager; + + emailFrequencyOptions = EMAIL_FREQUENCY_OPTIONS; + + @discourseComputed + chatSounds() { + return Object.keys(CHAT_SOUNDS).map((value) => { + return { name: I18n.t(`chat.sounds.${value}`), value }; + }); + } + + @action + onChangeChatSound(sound) { + if (sound && !isTesting()) { + this.chatAudioManager.playImmediately(sound); + } + this.model.set("user_option.chat_sound", sound); + } + + @action + save() { + this.set("saved", false); + return this.model + .save(CHAT_ATTRS) + .then(() => { + this.set("saved", true); + if (!isTesting()) { + location.reload(); + } + }) + .catch(popupAjaxError); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js b/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js new file mode 100644 index 0000000000..c08e697595 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js @@ -0,0 +1,17 @@ +import Helper from "@ember/component/helper"; +import { inject as service } from "@ember/service"; +import { camelize } from "@ember/string"; + +export default class ChatGuardianHelper extends Helper { + @service chatGuardian; + + compute(inputs) { + const [key, ...params] = inputs; + + if (!key) { + return; + } + + return this.chatGuardian[camelize(key)]?.(...params); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js new file mode 100644 index 0000000000..9c0a1be4d7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js @@ -0,0 +1,34 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import { htmlSafe } from "@ember/template"; +import getURL from "discourse-common/lib/get-url"; +import I18n from "I18n"; +import User from "discourse/models/user"; + +registerUnbound("format-chat-date", function (message, details, mode) { + let currentUser = User.current(); + + let tz = currentUser + ? currentUser.resolvedTimezone(currentUser) + : moment.tz.guess(); + + let date = moment(new Date(message.created_at), tz); + + let url = ""; + + if (details) { + url = getURL( + `/chat/channel/${details.chat_channel_id}/-?messageId=${message.id}` + ); + } + + let title = date.format(I18n.t("dates.long_with_year")); + + let display = + mode === "tiny" + ? date.format(I18n.t("chat.dates.time_tiny")) + : date.format(I18n.t("dates.time")); + + return htmlSafe( + `${display}` + ); +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/noop.js b/plugins/chat/assets/javascripts/discourse/helpers/noop.js new file mode 100644 index 0000000000..c224727fc2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/noop.js @@ -0,0 +1,5 @@ +import { helper } from "@ember/component/helper"; + +export default helper(function noop() { + return () => {}; +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js new file mode 100644 index 0000000000..adcbee709f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js @@ -0,0 +1,8 @@ +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import Helper from "@ember/component/helper"; + +export default class SlugifyChannel extends Helper { + compute(inputs) { + return slugifyChannel(inputs[0]); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js new file mode 100644 index 0000000000..f1fb7642d0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js @@ -0,0 +1,9 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +registerUnbound("tonable-emoji-title", function (emoji, diversity) { + if (!emoji.tonable || diversity === 1) { + return `:${emoji.name}:`; + } + + return `:${emoji.name}:t${diversity}:`; +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js new file mode 100644 index 0000000000..75e0bdc08d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js @@ -0,0 +1,9 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +registerUnbound("tonable-emoji-url", function (emoji, scale) { + if (!emoji.tonable || scale === 1) { + return emoji.url; + } + + return emoji.url.split(".png")[0] + `/${scale}.png`; +}); diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js new file mode 100644 index 0000000000..448d31d13d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-audio.js @@ -0,0 +1,29 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; + +const MENTION = 29; +const MESSAGE = 30; +const CHAT_NOTIFICATION_TYPES = [MENTION, MESSAGE]; + +export default { + name: "chat-audio", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + const chatService = container.lookup("service:chat"); + + if (!chatService.userCanChat || !currentUser?.chat_sound) { + return; + } + + const chatAudioManager = container.lookup("service:chat-audio-manager"); + chatAudioManager.setup(); + + withPluginApi("0.12.1", (api) => { + api.registerDesktopNotificationHandler((data, siteSettings, user) => { + if (CHAT_NOTIFICATION_TYPES.includes(data.notification_type)) { + chatAudioManager.play(user.chat_sound); + } + }); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js new file mode 100644 index 0000000000..3a7d30aa18 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js @@ -0,0 +1,154 @@ +import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import highlightSyntax from "discourse/lib/highlight-syntax"; +import I18n from "I18n"; +import DiscourseURL from "discourse/lib/url"; +import { samePrefix } from "discourse-common/lib/get-url"; +import loadScript from "discourse/lib/load-script"; +import { spinnerHTML } from "discourse/helpers/loading-spinner"; + +export default { + name: "chat-decorators", + + initializeWithPluginApi(api, container) { + api.decorateChatMessage((element) => decorateGithubOneboxBody(element), { + id: "onebox-github-body", + }); + + api.decorateChatMessage( + (element) => { + element + .querySelectorAll(".onebox.githubblob li.selected") + .forEach((line) => { + const scrollingElement = this._getScrollParent(line, "onebox"); + + // most likely a very small file which doesn’t need scrolling + if (!scrollingElement) { + return; + } + + const scrollBarWidth = + scrollingElement.offsetHeight - scrollingElement.clientHeight; + + scrollingElement.scroll({ + top: + line.offsetTop + + scrollBarWidth - + scrollingElement.offsetHeight / 2 + + line.offsetHeight / 2, + }); + }); + }, + { + id: "onebox-github-scrolling", + } + ); + + const siteSettings = container.lookup("service:site-settings"); + api.decorateChatMessage( + (element) => + highlightSyntax( + element, + siteSettings, + container.lookup("service:session") + ), + { id: "highlightSyntax" } + ); + + api.decorateChatMessage(this.renderChatTranscriptDates, { + id: "transcriptDates", + }); + + api.decorateChatMessage(this.forceLinksToOpenNewTab, { + id: "linksNewTab", + }); + + api.decorateChatMessage( + (element) => + this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")), + { + id: "lightbox", + } + ); + }, + + _getScrollParent(node, maxParentSelector) { + if (node === null || node.classList.contains(maxParentSelector)) { + return null; + } + + if (node.scrollHeight > node.clientHeight) { + return node; + } else { + return this._getScrollParent(node.parentNode, maxParentSelector); + } + }, + + renderChatTranscriptDates(element) { + element.querySelectorAll(".chat-transcript").forEach((transcriptEl) => { + const dateTimeRaw = transcriptEl.dataset["datetime"]; + const dateTimeLinkEl = transcriptEl.querySelector( + ".chat-transcript-datetime a" + ); + + // we only show date for first message + if (!dateTimeLinkEl) { + return; + } + + if (dateTimeLinkEl.innerText !== "") { + // same as highlight, no need to do this for every single message every time + // any message changes + return; + } + + if (this.currentUserTimezone) { + dateTimeLinkEl.innerText = moment + .tz(dateTimeRaw, this.currentUserTimezone) + .format(I18n.t("dates.long_no_year")); + } else { + dateTimeLinkEl.innerText = moment(dateTimeRaw).format( + I18n.t("dates.long_no_year") + ); + } + }); + }, + + forceLinksToOpenNewTab(element) { + const links = element.querySelectorAll( + ".chat-message-text a:not([target='_blank'])" + ); + for (let linkIndex = 0; linkIndex < links.length; linkIndex++) { + const link = links[linkIndex]; + if (!DiscourseURL.isInternal(link.href) || !samePrefix(link.href)) { + link.setAttribute("target", "_blank"); + } + } + }, + + lightbox(images) { + loadScript("/javascripts/jquery.magnific-popup.min.js").then(function () { + $(images).magnificPopup({ + type: "image", + closeOnContentClick: false, + mainClass: "mfp-zoom-in", + tClose: I18n.t("lightbox.close"), + tLoading: spinnerHTML, + image: { + verticalFit: true, + }, + callbacks: { + elementParse: (item) => { + item.src = item.el[0].src; + }, + }, + }); + }); + }, + + initialize(container) { + withPluginApi("0.8.42", (api) => + this.initializeWithPluginApi(api, container) + ); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js new file mode 100644 index 0000000000..6b993a70d4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -0,0 +1,212 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import showModal from "discourse/lib/show-modal"; + +const APPLE = + navigator.platform.startsWith("Mac") || navigator.platform === "iPhone"; +export const KEY_MODIFIER = APPLE ? "meta" : "ctrl"; + +export default { + name: "chat-keyboard-shortcuts", + + initialize(container) { + const chatService = container.lookup("service:chat"); + if (!chatService.userCanChat) { + return; + } + + const router = container.lookup("service:router"); + const appEvents = container.lookup("service:app-events"); + const chatStateManager = container.lookup("service:chat-state-manager"); + const openChannelSelector = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (document.getElementById("chat-channel-selector-modal-inner")) { + appEvents.trigger("chat-channel-selector-modal:close"); + } else { + showModal("chat-channel-selector-modal"); + } + }; + + const handleMoveUpShortcut = (e) => { + e.preventDefault(); + e.stopPropagation(); + chatService.switchChannelUpOrDown("up"); + }; + + const handleMoveDownShortcut = (e) => { + e.preventDefault(); + e.stopPropagation(); + chatService.switchChannelUpOrDown("down"); + }; + + const isChatComposer = (el) => el.classList.contains("chat-composer-input"); + const isInputSelection = (el) => { + const inputs = ["input", "textarea", "select", "button"]; + const elementTagName = el?.tagName.toLowerCase(); + + if (inputs.includes(elementTagName)) { + return false; + } + return true; + }; + const isDrawerExpanded = () => { + return document.querySelector(".topic-chat-float-container:not(.hidden)") + ? true + : false; + }; + + const modifyComposerSelection = (event, type) => { + if (!isChatComposer(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:modify-selection", { type }); + }; + + const openInsertLinkModal = (event) => { + if (!isChatComposer(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:open-insert-link-modal", { event }); + }; + + const openChatDrawer = (event) => { + if (!isInputSelection(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + chatStateManager.prefersDrawer(); + router.transitionTo(chatStateManager.lastKnownChatURL || "chat"); + }; + + const closeChatDrawer = (event) => { + if (!isDrawerExpanded()) { + return; + } + + if (!isChatComposer(event.target)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:toggle-close", event); + }; + + withPluginApi("0.12.1", (api) => { + api.addKeyboardShortcut(`${KEY_MODIFIER}+k`, openChannelSelector, { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.open_quick_channel_selector", + definition: { + keys1: ["meta", "k"], + keysDelimiter: "plus", + }, + }, + }); + api.addKeyboardShortcut("alt+up", handleMoveUpShortcut, { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.switch_channel_arrows", + definition: { + keys1: ["alt", "↑"], + keys2: ["alt", "↓"], + keysDelimiter: "plus", + shortcutsDelimiter: "slash", + }, + }, + }); + + api.addKeyboardShortcut("alt+down", handleMoveDownShortcut, { + global: true, + }); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+b`, + (event) => modifyComposerSelection(event, "bold"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_bold", + definition: { + keys1: ["meta", "b"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+i`, + (event) => modifyComposerSelection(event, "italic"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_italic", + definition: { + keys1: ["meta", "i"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+e`, + (event) => modifyComposerSelection(event, "code"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_code", + definition: { + keys1: ["meta", "e"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+l`, + (event) => openInsertLinkModal(event), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.open_insert_link_modal", + definition: { + keys1: ["meta", "l"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut(`-`, (event) => openChatDrawer(event), { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.drawer_open", + definition: { + keys1: ["-"], + }, + }, + }); + api.addKeyboardShortcut("esc", (event) => closeChatDrawer(event), { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.drawer_close", + definition: { + keys1: ["esc"], + }, + }, + }); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js new file mode 100644 index 0000000000..3b4e4ea824 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js @@ -0,0 +1,58 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { applyLocalDates } from "discourse/lib/local-dates"; + +export default { + name: "chat-plugin-decorators", + + initializeWithPluginApi(api, siteSettings) { + api.decorateChatMessage( + (element) => { + applyLocalDates( + element.querySelectorAll(".discourse-local-date"), + siteSettings + ); + }, + { + id: "local-dates", + } + ); + + if (siteSettings.spoiler_enabled) { + const applySpoiler = requirejs( + "discourse/plugins/discourse-spoiler-alert/lib/apply-spoiler" + ).default; + api.decorateChatMessage( + (element) => { + element.querySelectorAll(".spoiler").forEach((spoiler) => { + spoiler.classList.remove("spoiler"); + spoiler.classList.add("spoiled"); + applySpoiler(spoiler); + }); + }, + { + id: "spoiler", + } + ); + } + + api.decorateChatMessage( + (element) => { + element + .querySelectorAll(".lazyYT:not(.lazyYT-video-loaded)") + .forEach((iframe) => { + $(iframe).lazyYT(); + }); + }, + { + id: "lazy-yt", + } + ); + }, + + initialize(container) { + const siteSettings = container.lookup("service:site-settings"); + withPluginApi("0.8.42", (api) => + this.initializeWithPluginApi(api, siteSettings) + ); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js new file mode 100644 index 0000000000..af4fc496da --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -0,0 +1,182 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import I18n from "I18n"; +import { bind } from "discourse-common/utils/decorators"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { MENTION_KEYWORDS } from "discourse/plugins/chat/discourse/components/chat-message"; +import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +let _lastForcedRefreshAt; +const MIN_REFRESH_DURATION_MS = 180000; // 3 minutes + +export default { + name: "chat-setup", + initialize(container) { + this.chatService = container.lookup("service:chat"); + this.siteSettings = container.lookup("service:site-settings"); + + this.appEvents = container.lookup("service:appEvents"); + this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged"); + + withPluginApi("0.12.1", (api) => { + api.registerChatComposerButton({ + id: "chat-upload-btn", + icon: "far-image", + label: "chat.upload", + position: "dropdown", + action: "uploadClicked", + dependentKeys: ["canAttachUploads"], + displayed() { + return this.canAttachUploads; + }, + }); + + if (this.siteSettings.discourse_local_dates_enabled) { + api.registerChatComposerButton({ + label: "discourse_local_dates.title", + id: "local-dates", + class: "chat-local-dates-btn", + icon: "calendar-alt", + position: "dropdown", + action() { + this.insertDiscourseLocalDate(); + }, + }); + } + + if (this.siteSettings.enable_experimental_hashtag_autocomplete) { + api.registerHashtagSearchParam("category", "chat-composer", 100); + api.registerHashtagSearchParam("tag", "chat-composer", 50); + } + + api.registerChatComposerButton({ + label: "chat.emoji", + id: "emoji", + class: "chat-emoji-btn", + icon: "discourse-emojis", + position: "dropdown", + action() { + const chatEmojiPickerManager = container.lookup( + "service:chat-emoji-picker-manager" + ); + chatEmojiPickerManager.startFromComposer(this.didSelectEmoji); + }, + }); + + // we want to decorate the chat quote dates regardless + // of whether the current user has chat enabled + api.decorateCookedElement( + (elem) => { + const currentUser = getOwner(this).lookup("service:current-user"); + const currentUserTimezone = + currentUser?.resolvedTimezone(currentUser); + const chatTranscriptElements = + elem.querySelectorAll(".chat-transcript"); + + chatTranscriptElements.forEach((el) => { + const dateTimeRaw = el.dataset["datetime"]; + const dateTimeEl = el.querySelector( + ".chat-transcript-datetime a, .chat-transcript-datetime span" + ); + + if (currentUserTimezone) { + dateTimeEl.innerText = moment + .tz(dateTimeRaw, currentUserTimezone) + .format(I18n.t("dates.long_no_year")); + } else { + dateTimeEl.innerText = moment(dateTimeRaw).format( + I18n.t("dates.long_no_year") + ); + } + }); + }, + { id: "chat-transcript-datetime" } + ); + + if (!this.chatService.userCanChat) { + return; + } + + document.body.classList.add("chat-enabled"); + + const currentUser = api.getCurrentUser(); + if (currentUser?.chat_channels) { + this.chatService.setupWithPreloadedChannels(currentUser.chat_channels); + } else { + this.chatService.getChannels(); + } + + const chatNotificationManager = container.lookup( + "service:chat-notification-manager" + ); + chatNotificationManager.start(); + + if (!this._registeredDocumentTitleCountCallback) { + api.addDocumentTitleCounter(this.documentTitleCountCallback); + this._registeredDocumentTitleCountCallback = true; + } + + api.addCardClickListenerSelector(".topic-chat-float-container"); + + api.dispatchWidgetAppEvent( + "site-header", + "header-chat-link", + "chat:rerender-header" + ); + + api.dispatchWidgetAppEvent( + "sidebar-header", + "header-chat-link", + "chat:rerender-header" + ); + + api.addToHeaderIcons("header-chat-link"); + + api.decorateChatMessage(function (chatMessage) { + if (!this.currentUser) { + return; + } + + const highlightable = [ + `@${this.currentUser.username}`, + ...MENTION_KEYWORDS.map((k) => `@${k}`), + ]; + + chatMessage.querySelectorAll(".mention").forEach((node) => { + const mention = node.textContent.trim(); + if (highlightable.includes(mention)) { + node.classList.add("highlighted", "valid-mention"); + } + }); + }); + }); + }, + + @bind + documentTitleCountCallback() { + return this.chatService.getDocumentTitleCount(); + }, + + teardown() { + this.appEvents.off("discourse:focus-changed", this, "_handleFocusChanged"); + _lastForcedRefreshAt = null; + clearChatComposerButtons(); + }, + + @bind + _handleFocusChanged(hasFocus) { + if (!hasFocus) { + _lastForcedRefreshAt = Date.now(); + return; + } + + _lastForcedRefreshAt = _lastForcedRefreshAt || Date.now(); + + const duration = Date.now() - _lastForcedRefreshAt; + if (duration <= MIN_REFRESH_DURATION_MS) { + return; + } + + _lastForcedRefreshAt = Date.now(); + this.chatService.refreshTrackingState(); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js new file mode 100644 index 0000000000..37790294a7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js @@ -0,0 +1,485 @@ +import { htmlSafe } from "@ember/template"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import I18n from "I18n"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; +import { avatarUrl, escapeExpression } from "discourse/lib/utilities"; +import { dasherize } from "@ember/string"; +import { emojiUnescape } from "discourse/lib/text"; +import { decorateUsername } from "discourse/helpers/decorate-username-selector"; +import { until } from "discourse/lib/formatter"; +import { inject as service } from "@ember/service"; +import { computed } from "@ember/object"; + +export default { + name: "chat-sidebar", + initialize(container) { + this.chatService = container.lookup("service:chat"); + + if (!this.chatService.userCanChat) { + return; + } + + withPluginApi("1.3.0", (api) => { + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const SidebarChatChannelsSectionLink = class extends BaseCustomSidebarSectionLink { + @tracked chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + + constructor({ channel, chatService }) { + super(...arguments); + this.channel = channel; + this.chatService = chatService; + + this.chatService.appEvents.on( + "chat:user-tracking-state-changed", + this._refreshTrackingState + ); + } + + @bind + willDestroy() { + this.chatService.appEvents.off( + "chat:user-tracking-state-changed", + this._refreshTrackingState + ); + } + + @bind + _refreshTrackingState() { + this.chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + } + + get name() { + return dasherize(slugifyChannel(this.channel)); + } + + @computed("chatService.activeChannel") + get classNames() { + const classes = []; + + if (this.channel.current_user_membership.muted) { + classes.push("sidebar-section-link--muted"); + } + + if (this.channel.id === this.chatService.activeChannel?.id) { + classes.push("sidebar-section-link--active"); + } + + return classes.join(" "); + } + + get route() { + return "chat.channel"; + } + + get models() { + return [this.channel.id, slugifyChannel(this.channel)]; + } + + get text() { + return htmlSafe(emojiUnescape(this.title)); + } + + get prefixType() { + return "icon"; + } + + get prefixValue() { + return "hashtag"; + } + + get prefixColor() { + return this.channel.chatable.color; + } + + get title() { + return this.channel.escapedTitle; + } + + get prefixBadge() { + return this.channel.chatable.read_restricted ? "lock" : ""; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + return this.chatChannelTrackingState?.unread_count > 0 + ? "circle" + : ""; + } + + get suffixCSSClass() { + return this.chatChannelTrackingState?.unread_mentions > 0 + ? "urgent" + : "unread"; + } + }; + + const SidebarChatChannelsSection = class extends BaseCustomSidebarSection { + @tracked sectionLinks = []; + + @tracked sectionIndicator = + this.chatService.publicChannels && + this.chatService.publicChannels[0].current_user_membership + .unread_count; + + @tracked currentUserCanJoinPublicChannels = + this.sidebar.currentUser && + (this.sidebar.currentUser.staff || + this.sidebar.currentUser.has_joinable_public_channels); + + constructor() { + super(...arguments); + + if (container.isDestroyed) { + return; + } + this.chatService = container.lookup("service:chat"); + this.router = container.lookup("service:router"); + this.appEvents = container.lookup("service:app-events"); + this.appEvents.on("chat:refresh-channels", this._refreshChannels); + this._refreshChannels(); + } + + @bind + willDestroy() { + if (!this.appEvents) { + return; + } + this.appEvents.off( + "chat:refresh-channels", + this._refreshChannels + ); + } + + @bind + _refreshChannels() { + const newSectionLinks = []; + this.chatService.getChannels().then((channels) => { + channels.publicChannels.forEach((channel) => { + newSectionLinks.push( + new SidebarChatChannelsSectionLink({ + channel, + chatService: this.chatService, + }) + ); + }); + this.sectionLinks = newSectionLinks; + }); + } + + get name() { + return "chat-channels"; + } + + get title() { + return I18n.t("chat.chat_channels"); + } + + get text() { + return I18n.t("chat.chat_channels"); + } + + get actions() { + return [ + { + id: "browseChannels", + title: I18n.t("chat.channels_list_popup.browse"), + action: () => this.router.transitionTo("chat.browse.open"), + }, + ]; + } + + get actionsIcon() { + return "pencil-alt"; + } + + get links() { + return this.sectionLinks; + } + + get displaySection() { + return ( + this.sectionLinks.length > 0 || + this.currentUserCanJoinPublicChannels + ); + } + }; + + return SidebarChatChannelsSection; + } + ); + + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const SidebarChatDirectMessagesSectionLink = class extends BaseCustomSidebarSectionLink { + @tracked chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + + constructor({ channel, chatService }) { + super(...arguments); + this.channel = channel; + this.chatService = chatService; + + if (this.oneOnOneMessage) { + this.channel.chatable.users[0].trackStatus(); + } + } + + @bind + willDestroy() { + if (this.oneOnOneMessage) { + this.channel.chatable.users[0].stopTrackingStatus(); + } + } + + get name() { + return slugifyChannel(this.channel); + } + + @computed("chatService.activeChannel") + get classNames() { + const classes = []; + + if (this.channel.current_user_membership.muted) { + classes.push("sidebar-section-link--muted"); + } + + if (this.channel.id === this.chatService.activeChannel?.id) { + classes.push("sidebar-section-link--active"); + } + + return classes.join(" "); + } + + get route() { + return "chat.channel"; + } + + get models() { + return [this.channel.id, slugifyChannel(this.channel)]; + } + + get title() { + return this.channel.escapedTitle; + } + + get oneOnOneMessage() { + return this.channel.chatable.users.length === 1; + } + + get text() { + const username = this.title.replaceAll("@", ""); + if (this.oneOnOneMessage) { + const status = this.channel.chatable.users[0].get("status"); + const statusHtml = status ? this._userStatusHtml(status) : ""; + return htmlSafe( + `${escapeExpression( + username + )}${statusHtml} ${decorateUsername( + escapeExpression(username) + )}` + ); + } else { + return username; + } + } + + get prefixType() { + if (this.oneOnOneMessage) { + return "image"; + } else { + return "text"; + } + } + + get prefixValue() { + if (this.channel.chatable.users.length === 1) { + return avatarUrl( + this.channel.chatable.users[0].avatar_template, + "tiny" + ); + } else { + return this.channel.chatable.users.length; + } + } + + get prefixCSSClass() { + const activeUsers = this.chatService.presenceChannel.users; + const user = this.channel.chatable.users[0]; + if ( + !!activeUsers?.findBy("id", user?.id) || + !!activeUsers?.findBy("username", user?.username) + ) { + return "active"; + } + return ""; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + return this.chatChannelTrackingState?.unread_count > 0 + ? "circle" + : ""; + } + + get suffixCSSClass() { + return "urgent"; + } + + get hoverType() { + return "icon"; + } + + get hoverValue() { + return "times"; + } + + get hoverAction() { + return (event) => { + event.stopPropagation(); + event.preventDefault(); + this.chatService.unfollowChannel(this.channel); + }; + } + + get hoverTitle() { + return I18n.t("chat.direct_messages.leave"); + } + + _userStatusHtml(status) { + const emoji = escapeExpression(`:${status.emoji}:`); + const title = this._userStatusTitle(status); + return `${emojiUnescape(emoji, { + title, + })}`; + } + + _userStatusTitle(status) { + let title = `${escapeExpression(status.description)}`; + + if (status.ends_at) { + const untilFormatted = until( + status.ends_at, + this.chatService.currentUser.timezone, + this.chatService.currentUser.locale + ); + title += ` ${untilFormatted}`; + } + + return title; + } + }; + + const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection { + @service site; + @service router; + @tracked sectionLinks = []; + @tracked userCanDirectMessage = + this.chatService.userCanDirectMessage; + + constructor() { + super(...arguments); + + if (container.isDestroyed) { + return; + } + this.chatService = container.lookup("service:chat"); + this.chatService.appEvents.on( + "chat:user-tracking-state-changed", + this._refreshDirectMessageChannels + ); + this._refreshDirectMessageChannels(); + } + + @bind + willDestroy() { + if (container.isDestroyed) { + return; + } + this.chatService.appEvents.off( + "chat:user-tracking-state-changed", + this._refreshDirectMessageChannels + ); + } + + @bind + _refreshDirectMessageChannels() { + const newSectionLinks = []; + this.chatService.getChannels().then((channels) => { + this.chatService + .truncateDirectMessageChannels(channels.directMessageChannels) + .forEach((channel) => { + newSectionLinks.push( + new SidebarChatDirectMessagesSectionLink({ + channel, + chatService: this.chatService, + }) + ); + }); + this.sectionLinks = newSectionLinks; + }); + } + + get name() { + return "chat-dms"; + } + + get title() { + return I18n.t("chat.direct_messages.title"); + } + + get text() { + return I18n.t("chat.direct_messages.title"); + } + + get actions() { + if (!this.userCanDirectMessage) { + return []; + } + + return [ + { + id: "startDm", + title: I18n.t("chat.direct_messages.new"), + action: () => { + this.router.transitionTo("chat.draft-channel"); + }, + }, + ]; + } + + get actionsIcon() { + return "plus"; + } + + get links() { + return this.sectionLinks; + } + + get displaySection() { + return this.sectionLinks.length > 0 || this.userCanDirectMessage; + } + }; + + return SidebarChatDirectMessagesSection; + } + ); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js new file mode 100644 index 0000000000..62758b9b0c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js @@ -0,0 +1,142 @@ +import I18n from "I18n"; + +import { withPluginApi } from "discourse/lib/plugin-api"; +import { formatUsername } from "discourse/lib/utilities"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +export default { + name: "chat-user-menu", + initialize(container) { + withPluginApi("1.3.0", (api) => { + const chat = container.lookup("service:chat"); + + if (!chat.userCanChat) { + return; + } + + if (api.registerNotificationTypeRenderer) { + api.registerNotificationTypeRenderer( + "chat_invitation", + (NotificationItemBase) => { + return class extends NotificationItemBase { + get linkHref() { + const slug = slugifyChannel({ + title: this.notification.data.chat_channel_title, + slug: this.notification.data.chat_channel_slug, + }); + return `/chat/channel/${ + this.notification.data.chat_channel_id + }/${slug || "-"}?messageId=${ + this.notification.data.chat_message_id + }`; + } + + get linkTitle() { + return I18n.t("notifications.titles.chat_invitation"); + } + + get icon() { + return "link"; + } + + get label() { + return formatUsername( + this.notification.data.invited_by_username + ); + } + + get description() { + return I18n.t("notifications.chat_invitation"); + } + }; + } + ); + + api.registerNotificationTypeRenderer( + "chat_mention", + (NotificationItemBase) => { + return class extends NotificationItemBase { + get linkHref() { + const slug = slugifyChannel({ + title: this.notification.data.chat_channel_title, + slug: this.notification.data.chat_channel_slug, + }); + return `/chat/channel/${ + this.notification.data.chat_channel_id + }/${slug || "-"}?messageId=${ + this.notification.data.chat_message_id + }`; + } + + get linkTitle() { + return I18n.t("notifications.titles.chat_mention"); + } + + get icon() { + return "comment"; + } + + get label() { + return formatUsername( + this.notification.data.mentioned_by_username + ); + } + + get description() { + const identifier = this.notification.data.identifier + ? `@${this.notification.data.identifier}` + : null; + + const i18nPrefix = this.notification.data + .is_direct_message_channel + ? "notifications.popup.direct_message_chat_mention" + : "notifications.popup.chat_mention"; + + const i18nSuffix = identifier ? "other_plain" : "direct"; + + return I18n.t(`${i18nPrefix}.${i18nSuffix}`, { + identifier, + channel: this.notification.data.chat_channel_title, + }); + } + }; + } + ); + } + + if (api.registerUserMenuTab) { + api.registerUserMenuTab((UserMenuTab) => { + return class extends UserMenuTab { + get id() { + return "chat-notifications"; + } + + get panelComponent() { + return "user-menu/chat-notifications-list"; + } + + get icon() { + return "comment"; + } + + get count() { + return ( + this.getUnreadCountForType("chat_mention") + + this.getUnreadCountForType("chat_invitation") + ); + } + + get notificationTypes() { + return [ + "chat_invitation", + "chat_mention", + "chat_message", + "chat_quoted", + ]; + } + }; + }); + } + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js new file mode 100644 index 0000000000..e7b9af1956 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js @@ -0,0 +1,24 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; + +const CHAT_ENABLED_FIELD = "chat_enabled"; +const ONLY_CHAT_PUSH_NOTIFICATIONS_FIELD = "only_chat_push_notifications"; +const IGNORE_CHANNEL_WIDE_MENTION = "ignore_channel_wide_mention"; +const CHAT_SOUND = "chat_sound"; +const CHAT_EMAIL_FREQUENCY = "chat_email_frequency"; + +export default { + name: "chat-user-options", + + initialize(container) { + withPluginApi("0.11.0", (api) => { + const siteSettings = container.lookup("service:site-settings"); + if (siteSettings.chat_enabled) { + api.addSaveableUserOptionField(CHAT_ENABLED_FIELD); + api.addSaveableUserOptionField(ONLY_CHAT_PUSH_NOTIFICATIONS_FIELD); + api.addSaveableUserOptionField(IGNORE_CHANNEL_WIDE_MENTION); + api.addSaveableUserOptionField(CHAT_SOUND); + api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY); + } + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-api.js b/plugins/chat/assets/javascripts/discourse/lib/chat-api.js new file mode 100644 index 0000000000..3b373570a7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-api.js @@ -0,0 +1,95 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +export default class ChatApi { + static async chatChannelMemberships(channelId, data) { + return await ajax(`/chat/api/chat_channels/${channelId}/memberships.json`, { + data, + }).catch(popupAjaxError); + } + + static async updateChatChannelNotificationsSettings(channelId, data = {}) { + return await ajax( + `/chat/api/chat_channels/${channelId}/notifications_settings.json`, + { + method: "PUT", + data, + } + ).catch(popupAjaxError); + } + + static async sendMessage(channelId, data = {}) { + return ajax(`/chat/${channelId}.json`, { + ignoreUnsent: false, + method: "POST", + data, + }); + } + + static async chatChannels(data = {}) { + if (data?.status === "all") { + delete data.status; + } + + return await ajax(`/chat/api/chat_channels.json`, { + method: "GET", + data, + }) + .then((channels) => + channels.map((channel) => ChatChannel.create(channel)) + ) + .catch(popupAjaxError); + } + + static async modifyChatChannel(channelId, data) { + return await this._performRequest( + `/chat/api/chat_channels/${channelId}.json`, + { + method: "PUT", + data, + } + ); + } + + static async unfollowChatChannel(channel) { + return await this._performRequest( + `/chat/chat_channels/${channel.id}/unfollow.json`, + { + method: "POST", + } + ).then((updatedChannel) => { + channel.updateMembership(updatedChannel.current_user_membership); + + // doesn't matter if this is inaccurate, it will be eventually consistent + // via the channel-metadata MessageBus channel + channel.set("memberships_count", channel.memberships_count - 1); + return channel; + }); + } + + static async followChatChannel(channel) { + return await this._performRequest( + `/chat/chat_channels/${channel.id}/follow.json`, + { + method: "POST", + } + ).then((updatedChannel) => { + channel.updateMembership(updatedChannel.current_user_membership); + + // doesn't matter if this is inaccurate, it will be eventually consistent + // via the channel-metadata MessageBus channel + channel.set("memberships_count", channel.memberships_count + 1); + return channel; + }); + } + + static async categoryPermissions(categoryId) { + return await this._performRequest( + `/chat/api/category-chatables/${categoryId}/permissions.json` + ); + } + + static async _performRequest(...args) { + return await ajax(...args).catch(popupAjaxError); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js new file mode 100644 index 0000000000..94ca01e972 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js @@ -0,0 +1,126 @@ +import I18n from "I18n"; + +let _chatComposerButtons = {}; + +export function registerChatComposerButton(button) { + if (!button.id) { + throw new Error("Attempted to register a chat composer button with no id."); + } + + if (_chatComposerButtons[button.id]) { + return; + } + + const defaultButton = { + id: null, + action: null, + icon: null, + title: null, + translatedTitle: null, + label: null, + translatedLabel: null, + ariaLabel: null, + translatedAriaLabel: null, + position: "inline", + classNames: [], + dependentKeys: [], + displayed: true, + disabled: false, + priority: 0, + }; + + const normalizedButton = Object.assign(defaultButton, button); + + if ( + !normalizedButton.icon && + !normalizedButton.label && + !normalizedButton.translatedLabel + ) { + throw new Error( + `Attempted to register a chat composer button: ${button.id} with no icon or label.` + ); + } + + _chatComposerButtons[normalizedButton.id] = normalizedButton; +} + +function computeButton(context, button, property) { + const field = button[property]; + + if (isFunction(field)) { + return field.apply(context); + } + + return field; +} + +function isFunction(descriptor) { + return descriptor && typeof descriptor === "function"; +} + +export function chatComposerButtonsDependentKeys() { + return [].concat( + ...Object.values(_chatComposerButtons) + .mapBy("dependentKeys") + .filter(Boolean) + ); +} + +export function chatComposerButtons(context, position) { + return Object.values(_chatComposerButtons) + .filter( + (button) => + computeButton(context, button, "displayed") && + computeButton(context, button, "position") === position + ) + .map((button) => { + const result = { id: button.id }; + + const label = computeButton(context, button, "label"); + result.label = label + ? label + : computeButton(context, button, "translatedLabel"); + + const ariaLabel = computeButton(context, button, "ariaLabel"); + if (ariaLabel) { + result.ariaLabel = I18n.t(ariaLabel); + } else { + const translatedAriaLabel = computeButton( + context, + button, + "translatedAriaLabel" + ); + result.ariaLabel = translatedAriaLabel || result.label; + } + + const title = computeButton(context, button, "title"); + result.title = title + ? I18n.t(title) + : computeButton(context, button, "translatedTitle"); + + result.classNames = ( + computeButton(context, button, "classNames") || [] + ).join(" "); + + result.icon = computeButton(context, button, "icon"); + result.disabled = computeButton(context, button, "disabled"); + result.priority = computeButton(context, button, "priority"); + + if (isFunction(button.action)) { + result.action = () => { + button.action.apply(context); + }; + } else { + const actionName = button.action; + result.action = () => { + context[actionName](); + }; + } + + return result; + }); +} + +export function clearChatComposerButtons() { + _chatComposerButtons = []; +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js new file mode 100644 index 0000000000..60a20c2206 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js @@ -0,0 +1,91 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; +import getURL from "discourse-common/lib/get-url"; + +export default class ChatMessageFlag { + title() { + return "flagging.title"; + } + + customSubmitLabel() { + return "flagging.notify_action"; + } + + submitLabel() { + return "chat.flagging.action"; + } + + targetsTopic() { + return false; + } + + editable() { + return false; + } + + _rewriteFlagDescriptions(flags) { + return flags.map((flag) => { + flag.set( + "description", + I18n.t(`chat.flags.${flag.name_key}`, { basePath: getURL("") }) + ); + return flag; + }); + } + + flagsAvailable(_controller, site, model) { + let flagsAvailable = site.flagTypes; + + flagsAvailable = flagsAvailable.filter((flag) => { + return model.available_flags.includes(flag.name_key); + }); + + // "message user" option should be at the top + const notifyUserIndex = flagsAvailable.indexOf( + flagsAvailable.filterBy("name_key", "notify_user")[0] + ); + + if (notifyUserIndex !== -1) { + const notifyUser = flagsAvailable[notifyUserIndex]; + flagsAvailable.splice(notifyUserIndex, 1); + flagsAvailable.splice(0, 0, notifyUser); + } + + return this._rewriteFlagDescriptions(flagsAvailable); + } + + create(controller, opts) { + controller.send("hideModal"); + + return ajax("/chat/flag", { + method: "PUT", + data: { + chat_message_id: controller.get("model.id"), + flag_type_id: controller.get("selected.id"), + message: opts.message, + is_warning: opts.isWarning, + take_action: opts.takeAction, + queue_for_review: opts.queue_for_review, + }, + }) + .then(() => { + if (controller.isDestroying || controller.isDestroyed) { + return; + } + + if (!opts.skipClose) { + controller.send("closeModal"); + } + if (opts.message) { + controller.set("message", ""); + } + }) + .catch((error) => { + if (!controller.isDestroying && !controller.isDestroyed) { + controller.send("closeModal"); + } + popupAjaxError(error); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js b/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js new file mode 100644 index 0000000000..37f85fb2c5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js @@ -0,0 +1,43 @@ +import getURL from "discourse-common/lib/get-url"; + +const domParser = new DOMParser(); + +export default function transform(cooked, categories) { + let html = domParser.parseFromString(cooked, "text/html"); + transformMentions(html); + transformCategoryTagHashes(html, categories); + return html.body.innerHTML; +} + +function transformMentions(html) { + (html.querySelectorAll("span.mention") || []).forEach((mentionSpan) => { + let mentionLink = document.createElement("a"); + let mentionText = document.createTextNode(mentionSpan.innerText); + mentionLink.classList.add("mention"); + mentionLink.appendChild(mentionText); + mentionLink.href = getURL(`/u/${mentionSpan.innerText.substring(1)}`); + mentionSpan.parentNode.replaceChild(mentionLink, mentionSpan); + }); +} + +function transformCategoryTagHashes(html, categories) { + (html.querySelectorAll("span.hashtag") || []).forEach((hashSpan) => { + const categoryTagName = hashSpan.innerText.substring(1); + const matchingCategory = categories.find( + (category) => + category.name.toLowerCase() === categoryTagName.toLowerCase() + ); + const href = getURL( + matchingCategory + ? `/c/${matchingCategory.name}/${matchingCategory.id}` + : `/tag/${categoryTagName}` + ); + + let hashLink = document.createElement("a"); + let hashText = document.createTextNode(hashSpan.innerText); + hashLink.classList.add("hashtag"); + hashLink.appendChild(hashText); + hashLink.href = href; + hashSpan.parentNode.replaceChild(hashLink, hashSpan); + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js new file mode 100644 index 0000000000..f7a2734879 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js @@ -0,0 +1,19 @@ +import { slugify } from "discourse/lib/utilities"; + +export default function slugifyChannel(channel) { + if (channel.slug) { + return channel.slug; + } + const slug = slugify(channel.escapedTitle || channel.title); + const resolvedSlug = ( + slug.length + ? slug + : channel.title.trim().toLowerCase().replace(/\s|_+/g, "-") + ).slice(0, 100); + + if (!resolvedSlug) { + return "-"; + } + + return resolvedSlug; +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js b/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js new file mode 100644 index 0000000000..094b9b5263 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js @@ -0,0 +1,10 @@ +import { isTesting } from "discourse-common/config/environment"; + +// return true when the browser viewport is zoomed +export default function isZoomed() { + return ( + !isTesting() && + visualViewport?.scale !== 1 && + document.documentElement.clientWidth / window.innerWidth !== 1 + ); +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js new file mode 100644 index 0000000000..7dca63e974 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -0,0 +1,196 @@ +import RestModel from "discourse/models/rest"; +import I18n from "I18n"; +import { computed } from "@ember/object"; +import User from "discourse/models/user"; +import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership"; +import { ajax } from "discourse/lib/ajax"; +import { escapeExpression } from "discourse/lib/utilities"; + +export const CHATABLE_TYPES = { + directMessageChannel: "DirectMessage", + categoryChannel: "Category", +}; +export const CHANNEL_STATUSES = { + open: "open", + readOnly: "read_only", + closed: "closed", + archived: "archived", +}; + +export function channelStatusName(channelStatus) { + switch (channelStatus) { + case CHANNEL_STATUSES.open: + return I18n.t("chat.channel_status.open"); + case CHANNEL_STATUSES.readOnly: + return I18n.t("chat.channel_status.read_only"); + case CHANNEL_STATUSES.closed: + return I18n.t("chat.channel_status.closed"); + case CHANNEL_STATUSES.archived: + return I18n.t("chat.channel_status.archived"); + } +} + +export function channelStatusIcon(channelStatus) { + if (channelStatus === CHANNEL_STATUSES.open) { + return null; + } + + switch (channelStatus) { + case CHANNEL_STATUSES.closed: + return "lock"; + break; + case CHANNEL_STATUSES.readOnly: + return "comment-slash"; + break; + case CHANNEL_STATUSES.archived: + return "archive"; + break; + } +} + +const STAFF_READONLY_STATUSES = [ + CHANNEL_STATUSES.readOnly, + CHANNEL_STATUSES.archived, +]; + +const READONLY_STATUSES = [ + CHANNEL_STATUSES.closed, + CHANNEL_STATUSES.readOnly, + CHANNEL_STATUSES.archived, +]; + +export default class ChatChannel extends RestModel { + isDraft = false; + lastSendReadMessageId = null; + + @computed("title") + get escapedTitle() { + return escapeExpression(this.title); + } + + @computed("description") + get escapedDescription() { + return escapeExpression(this.description); + } + + @computed("chatable_type") + get isDirectMessageChannel() { + return this.chatable_type === CHATABLE_TYPES.directMessageChannel; + } + + @computed("chatable_type") + get isCategoryChannel() { + return this.chatable_type === CHATABLE_TYPES.categoryChannel; + } + + @computed("status") + get isOpen() { + return !this.status || this.status === CHANNEL_STATUSES.open; + } + + @computed("status") + get isReadOnly() { + return this.status === CHANNEL_STATUSES.readOnly; + } + + @computed("status") + get isClosed() { + return this.status === CHANNEL_STATUSES.closed; + } + + @computed("status") + get isArchived() { + return this.status === CHANNEL_STATUSES.archived; + } + + @computed("isArchived", "isOpen") + get isJoinable() { + return this.isOpen && !this.isArchived; + } + + @computed("memberships_count") + get membershipsCount() { + return this.memberships_count; + } + + @computed("current_user_membership.following") + get isFollowing() { + return this.current_user_membership.following; + } + + canModifyMessages(user) { + if (user.staff) { + return !STAFF_READONLY_STATUSES.includes(this.status); + } + + return !READONLY_STATUSES.includes(this.status); + } + + updateMembership(membership) { + this.current_user_membership.setProperties({ + following: membership.following, + muted: membership.muted, + desktop_notification_level: membership.desktop_notification_level, + mobile_notification_level: membership.mobile_notification_level, + }); + } + + updateLastReadMessage(messageId) { + if (!this.isFollowing || !messageId) { + return; + } + + return ajax(`/chat/${this.id}/read/${messageId}.json`, { + method: "PUT", + }).then(() => { + this.set("lastSendReadMessageId", messageId); + }); + } +} + +ChatChannel.reopenClass({ + create(args) { + args = args || {}; + this._initUserModels(args); + this._initUserMembership(args); + + args.lastSendReadMessageId = + args.current_user_membership?.last_read_message_id; + + return this._super(args); + }, + + _initUserModels(args) { + if (args.chatable?.users?.length) { + for (let i = 0; i < args.chatable?.users?.length; i++) { + const userData = args.chatable.users[i]; + args.chatable.users[i] = User.create(userData); + } + } + }, + + _initUserMembership(args) { + if (args.current_user_membership instanceof UserChatChannelMembership) { + return; + } + + args.current_user_membership = UserChatChannelMembership.create( + args.current_user_membership || { + following: false, + muted: false, + unread_count: 0, + unread_mentions: 0, + } + ); + }, +}); + +export function createDirectMessageChannelDraft() { + return ChatChannel.create({ + isDraft: true, + chatable_type: CHATABLE_TYPES.directMessageChannel, + chatable: { + users: [], + }, + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js new file mode 100644 index 0000000000..812eb8748d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -0,0 +1,18 @@ +import RestModel from "discourse/models/rest"; +import User from "discourse/models/user"; + +export default class ChatMessage extends RestModel {} + +ChatMessage.reopenClass({ + create(args) { + args = args || {}; + this._initUserModel(args); + return this._super(args); + }, + + _initUserModel(args) { + if (args.user) { + args.user = User.create(args.user); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js new file mode 100644 index 0000000000..8e97e26bfe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js @@ -0,0 +1,3 @@ +import RestModel from "discourse/models/rest"; + +export default class UserChatChannelMembership extends RestModel {} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js new file mode 100644 index 0000000000..51552575c9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js @@ -0,0 +1,23 @@ +import Modifier from "ember-modifier"; +import { inject as service } from "@ember/service"; +import { registerDestructor } from "@ember/destroyable"; + +export default class EmojiPickerScrollListener extends Modifier { + @service emojiPickerScrollObserver; + + element = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.element = element; + this.emojiPickerScrollObserver.observe(element); + } + + cleanup() { + this.emojiPickerScrollObserver.unobserve(this.element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js new file mode 100644 index 0000000000..10474b067c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js @@ -0,0 +1,23 @@ +import Modifier from "ember-modifier"; +import { inject as service } from "@ember/service"; +import { registerDestructor } from "@ember/destroyable"; + +export default class TrackMessageVisibility extends Modifier { + @service chatMessageVisibilityObserver; + + element = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.element = element; + this.chatMessageVisibilityObserver.observe(element); + } + + cleanup() { + this.chatMessageVisibilityObserver.unobserve(this.element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js b/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js new file mode 100644 index 0000000000..3a6801ea0b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js @@ -0,0 +1,37 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + addChatMessageDecorator, + resetChatMessageDecorators, +} from "discourse/plugins/chat/discourse/components/chat-message"; +import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +export default { + name: "chat-plugin-api", + after: "inject-discourse-objects", + + initialize() { + withPluginApi("1.2.0", (api) => { + const apiPrototype = Object.getPrototypeOf(api); + + if (!apiPrototype.hasOwnProperty("decorateChatMessage")) { + Object.defineProperty(apiPrototype, "decorateChatMessage", { + value(decorator) { + addChatMessageDecorator(decorator); + }, + }); + } + + if (!apiPrototype.hasOwnProperty("registerChatComposerButton")) { + Object.defineProperty(apiPrototype, "registerChatComposerButton", { + value(button) { + registerChatComposerButton(button); + }, + }); + } + }); + }, + + teardown() { + resetChatMessageDecorators(); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js b/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js new file mode 100644 index 0000000000..bc35fd6059 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js @@ -0,0 +1,7 @@ +export default { + resource: "user.preferences", + + map() { + this.route("chat"); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js b/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js new file mode 100644 index 0000000000..7255df34f1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js @@ -0,0 +1,24 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import EmberObject from "@ember/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { ajax } from "discourse/lib/ajax"; + +export default class AdminPluginsChatRoute extends DiscourseRoute { + model() { + if (!this.currentUser?.admin) { + return { model: null }; + } + + return ajax("/admin/plugins/chat.json").then((model) => { + model.incoming_chat_webhooks = model.incoming_chat_webhooks.map( + (webhook) => EmberObject.create(webhook) + ); + + model.chat_channels = model.chat_channels.map((channel) => { + return ChatChannel.create(channel); + }); + + return model; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js new file mode 100644 index 0000000000..367f4adadb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js @@ -0,0 +1,14 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatBrowseIndexRoute extends DiscourseRoute { + @service chat; + + activate() { + this.chat.setActiveChannel(null); + } + + afterModel() { + this.replaceWith("chat.browse.open"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js new file mode 100644 index 0000000000..7516fe58bb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js @@ -0,0 +1,28 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelByNameRoute extends DiscourseRoute { + @service chat; + + async model(params) { + return ajax( + `/chat/chat_channels/${encodeURIComponent(params.channelName)}.json` + ) + .then((response) => { + this.transitionTo( + "chat.channel", + response.chat_channel.id, + response.chat_channel.title + ); + }) + .catch(() => this.replaceWith("/404")); + } + + beforeModel() { + if (!this.chat.userCanChat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js new file mode 100644 index 0000000000..181c9ffb69 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js @@ -0,0 +1,9 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoAboutRoute extends DiscourseRoute { + afterModel(model) { + if (model.chatChannel.isDirectMessageChannel) { + this.replaceWith("chat.channel.info.index"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js new file mode 100644 index 0000000000..ffc3bc589b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js @@ -0,0 +1,15 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoIndexRoute extends DiscourseRoute { + afterModel(model) { + if (model.chatChannel.isDirectMessageChannel) { + if (model.chatChannel.isOpen && model.chatChannel.membershipsCount >= 1) { + this.replaceWith("chat.channel.info.members"); + } else { + this.replaceWith("chat.channel.info.settings"); + } + } else { + this.replaceWith("chat.channel.info.about"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js new file mode 100644 index 0000000000..25d2e4eb93 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js @@ -0,0 +1,9 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoMembersRoute extends DiscourseRoute { + afterModel(model) { + if (!model.chatChannel.isOpen) { + this.replaceWith("chat.channel.info.settings"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js new file mode 100644 index 0000000000..3a167c4890 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js @@ -0,0 +1,22 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; + +export default class ChatChannelInfoRoute extends DiscourseRoute { + @service chatChannelInfoRouteOriginManager; + + activate(transition) { + const name = transition?.from?.name; + if (name) { + this.chatChannelInfoRouteOriginManager.origin = name.startsWith( + "chat.browse" + ) + ? ORIGINS.browse + : ORIGINS.channel; + } + } + + deactivate() { + this.chatChannelInfoRouteOriginManager.origin = null; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js new file mode 100644 index 0000000000..17e6a2f745 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -0,0 +1,63 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import Promise from "rsvp"; +import EmberObject, { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +export default class ChatChannelRoute extends DiscourseRoute { + @service chat; + @service router; + + async model(params) { + let [chatChannel, channels] = await Promise.all([ + this.getChannel(params.channelId), + this.chat.getChannels(), + ]); + + return EmberObject.create({ + chatChannel, + channels, + }); + } + + async getChannel(id) { + let channel = await this.chat.getChannelBy("id", id); + if (!channel || this.forceRefetchChannel) { + channel = await this.getChannelFromServer(id); + } + return channel; + } + + async getChannelFromServer(id) { + return ajax(`/chat/chat_channels/${id}`) + .then((response) => ChatChannel.create(response)) + .catch(() => this.replaceWith("/404")); + } + + afterModel(model) { + this.chat.setActiveChannel(model?.chatChannel); + + const queryParams = this.paramsFor(this.routeName); + const slug = slugifyChannel(model.chatChannel); + if (queryParams?.channelTitle !== slug) { + this.router.replaceWith("chat.channel.index", model.chatChannel.id, slug); + } + } + + setupController(controller) { + super.setupController(...arguments); + + if (controller.messageId) { + this.chat.set("messageId", controller.messageId); + this.controller.set("messageId", null); + } + } + + @action + refreshModel(forceRefetchChannel = false) { + this.forceRefetchChannel = forceRefetchChannel; + this.refresh(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js new file mode 100644 index 0000000000..a5fd5df9df --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js @@ -0,0 +1,16 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatDraftChannelRoute extends DiscourseRoute { + @service chat; + + beforeModel() { + if (!this.chat.userCanDirectMessage) { + this.transitionTo("chat"); + } + } + + activate() { + this.chat.setActiveChannel(null); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js new file mode 100644 index 0000000000..fbf8cd0a3c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js @@ -0,0 +1,38 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatIndexRoute extends DiscourseRoute { + @service chat; + @service router; + + redirect() { + if (this.site.mobileView) { + return; // Always want the channel index on mobile. + } + + // We are on desktop. Check for a channel to enter and transition if so. + // Otherwise, `setupController` will fetch all available + return this.chat.getIdealFirstChannelIdAndTitle().then((channelInfo) => { + if (channelInfo) { + return this.chat.getChannelBy("id", channelInfo.id).then((c) => { + return this.chat.openChannel(c); + }); + } else { + return this.router.transitionTo("chat.browse"); + } + }); + } + + model() { + if (this.site.mobileView) { + return this.chat.getChannels().then((channels) => { + if ( + channels.publicChannels.length || + channels.directMessageChannels.length + ) { + return channels; + } + }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-message.js b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js new file mode 100644 index 0000000000..c96d913c09 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js @@ -0,0 +1,29 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { ajax } from "discourse/lib/ajax"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageRoute extends DiscourseRoute { + @service chat; + + async model(params) { + return ajax(`/chat/message/${params.messageId}.json`) + .then((response) => { + this.transitionTo( + "chat.channel", + response.chat_channel_id, + response.chat_channel_title, + { + queryParams: { messageId: params.messageId }, + } + ); + }) + .catch(() => this.replaceWith("/404")); + } + + beforeModel() { + if (!this.chat.userCanChat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js new file mode 100644 index 0000000000..04310b7a2c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -0,0 +1,84 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; +import { scrollTop } from "discourse/mixins/scroll-top"; +import { schedule } from "@ember/runloop"; +import { action } from "@ember/object"; + +export default class ChatRoute extends DiscourseRoute { + @service chat; + @service router; + @service chatStateManager; + + titleToken() { + return I18n.t("chat.title_capitalized"); + } + + beforeModel(transition) { + if (!this.chat.userCanChat) { + return this.router.transitionTo(`discovery.${defaultHomepage()}`); + } + + const INTERCEPTABLE_ROUTES = [ + "chat.channel.index", + "chat.channel", + "chat", + "chat.index", + "chat.draft-channel", + ]; + + if ( + transition.from && // don't intercept when directly loading chat + this.chatStateManager.isDrawerPreferred && + INTERCEPTABLE_ROUTES.includes(transition.targetName) + ) { + transition.abort(); + + let URL = transition.intent.url; + if ( + transition.targetName === "chat.channel.index" || + transition.targetName === "chat.channel" + ) { + URL ??= this.router.urlFor( + transition.targetName, + ...transition.intent.contexts + ); + } else { + URL ??= this.router.urlFor(transition.targetName); + } + + this.appEvents.trigger("chat:open-url", URL); + return; + } + + this.appEvents.trigger("chat:toggle-close"); + } + + activate() { + this.chatStateManager.storeAppURL(); + this.chat.updatePresence(); + + schedule("afterRender", () => { + document.body.classList.add("has-full-page-chat"); + document.documentElement.classList.add("has-full-page-chat"); + }); + } + + deactivate() { + this.chat.setActiveChannel(null); + + schedule("afterRender", () => { + document.body.classList.remove("has-full-page-chat"); + document.documentElement.classList.remove("has-full-page-chat"); + scrollTop(); + }); + } + + @action + willTransition(transition) { + if (!transition?.to?.name?.startsWith("chat.")) { + this.chatStateManager.storeChatURL(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js new file mode 100644 index 0000000000..31e92c27fc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js @@ -0,0 +1,16 @@ +import RestrictedUserRoute from "discourse/routes/restricted-user"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; + +export default class PreferencesChatRoute extends RestrictedUserRoute { + @service chat; + + showFooter = true; + + setupController(controller, user) { + if (!user?.can_chat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + controller.set("model", user); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-audio-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-audio-manager.js new file mode 100644 index 0000000000..8a2d339267 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-audio-manager.js @@ -0,0 +1,68 @@ +import Service from "@ember/service"; +import { debounce } from "discourse-common/utils/decorators"; + +const AUDIO_DEBOUNCE_DELAY = 3000; + +export const CHAT_SOUNDS = { + bell: [{ src: "/plugins/chat/audio/bell.mp3", type: "audio/mpeg" }], + ding: [{ src: "/plugins/chat/audio/ding.mp3", type: "audio/mpeg" }], +}; + +const DEFAULT_SOUND_NAME = "bell"; + +const createAudioCache = (sources) => { + const audio = new Audio(); + sources.forEach(({ type, src }) => { + const source = document.createElement("source"); + source.type = type; + source.src = src; + audio.appendChild(source); + }); + return audio; +}; + +export default class ChatAudioManager extends Service { + _audioCache = {}; + + setup() { + Object.keys(CHAT_SOUNDS).forEach((soundName) => { + this._audioCache[soundName] = createAudioCache(CHAT_SOUNDS[soundName]); + }); + } + + willDestroy() { + this._super(...arguments); + + this._audioCache = {}; + } + + playImmediately(soundName) { + return this._play(soundName); + } + + @debounce(AUDIO_DEBOUNCE_DELAY, true) + play(soundName) { + return this._play(soundName); + } + + _play(soundName) { + const audio = + this._audioCache[soundName] || this._audioCache[DEFAULT_SOUND_NAME]; + + if (!audio.paused) { + audio.pause(); + if (typeof audio.fastSeek === "function") { + audio.fastSeek(0); + } else { + audio.currentTime = 0; + } + } + + return audio.play().catch(() => { + // eslint-disable-next-line no-console + console.info( + "[chat] User needs to interact with DOM before we can play notification sounds." + ); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js new file mode 100644 index 0000000000..7ed6c2376a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js @@ -0,0 +1,34 @@ +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; +import { isEmpty } from "@ember/utils"; + +export const BACK_KEY = "back"; +export const INFO_ROUTE_NAMESPACE = "discourse_chat_info_route"; +export const ORIGINS = { + channel: "channel", + browse: "browse", +}; + +export default class ChatChannelInfoRouteOriginManager extends Service { + store = new KeyValueStore(INFO_ROUTE_NAMESPACE); + + get origin() { + const origin = this.store.getObject(BACK_KEY); + + if (origin) { + return ORIGINS[origin]; + } + } + + set origin(value) { + this.store.setObject({ key: BACK_KEY, value }); + } + + get isBrowse() { + return this.origin === ORIGINS.browse; + } + + get isChannel() { + return this.origin === ORIGINS.channel || isEmpty(this.origin); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js new file mode 100644 index 0000000000..57ddd682db --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js @@ -0,0 +1,56 @@ +import Service, { inject as service } from "@ember/service"; +import { cancel, debounce } from "@ember/runloop"; +import { isTesting } from "discourse-common/config/environment"; + +const CHAT_PRESENCE_CHANNEL_PREFIX = "/chat-reply"; +const KEEP_ALIVE_DURATION_SECONDS = 10; + +// This service is loosely based on discourse-presence's ComposerPresenceManager service +// It is a singleton which receives notifications each time the value of the chat composer changes +// This service ensures that a single browser can only be 'replying' to a single chatChannel at +// one time, and automatically 'leaves' the channel if the composer value hasn't changed for 10 seconds +export default class ChatComposerPresenceManager extends Service { + @service presence; + + willDestroy() { + this.leave(); + } + + notifyState(chatChannelId, replying) { + if (!replying) { + this.leave(); + return; + } + + if (this._chatChannelId !== chatChannelId) { + this._enter(chatChannelId); + this._chatChannelId = chatChannelId; + } + + if (!isTesting()) { + this._autoLeaveTimer = debounce( + this, + this.leave, + KEEP_ALIVE_DURATION_SECONDS * 1000 + ); + } + } + + leave() { + this._presentChannel?.leave(); + this._presentChannel = null; + this._chatChannelId = null; + if (this._autoLeaveTimer) { + cancel(this._autoLeaveTimer); + this._autoLeaveTimer = null; + } + } + + _enter(chatChannelId) { + this.leave(); + + let channelName = `${CHAT_PRESENCE_CHANNEL_PREFIX}/${chatChannelId}`; + this._presentChannel = this.presence.getChannel(channelName); + this._presentChannel.enter(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js new file mode 100644 index 0000000000..b19d53b61a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js @@ -0,0 +1,149 @@ +import { headerOffset } from "discourse/lib/offset-calculator"; +import { createPopper } from "@popperjs/core"; +import Service from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse-common/utils/decorators"; +import { later, schedule } from "@ember/runloop"; +import { makeArray } from "discourse-common/lib/helpers"; +import { Promise } from "rsvp"; +import { computed } from "@ember/object"; +import { isTesting } from "discourse-common/config/environment"; + +const TRANSITION_TIME = isTesting() ? 0 : 125; // CSS transition time +const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"]; +const DEFAULT_LAST_SECTION = "favorites"; + +export default class ChatEmojiPickerManager extends Service { + @tracked opened = false; + @tracked closing = false; + @tracked loading = false; + @tracked context = null; + @tracked emojis = null; + @tracked visibleSections = DEFAULT_VISIBLE_SECTIONS; + @tracked lastVisibleSection = DEFAULT_LAST_SECTION; + @tracked element = null; + @tracked callback; + + @computed("emojis.[]", "loading") + get sections() { + return !this.loading && this.emojis ? Object.keys(this.emojis) : []; + } + + @bind + closeExisting() { + this.callback = null; + this.opened = false; + this.visibleSections = DEFAULT_VISIBLE_SECTIONS; + this.lastVisibleSection = DEFAULT_LAST_SECTION; + } + + @bind + close() { + this.callback = null; + this.closing = true; + + later(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.visibleSections = DEFAULT_VISIBLE_SECTIONS; + this.lastVisibleSection = DEFAULT_LAST_SECTION; + this.closing = false; + this.opened = false; + }, TRANSITION_TIME); + } + + addVisibleSections(sections) { + this.visibleSections = makeArray(this.visibleSections) + .concat(makeArray(sections)) + .uniq(); + } + + didSelectEmoji(emoji) { + this?.callback(emoji); + this.callback = null; + this.close(); + } + + startFromMessageReactionList(message, isDesktop, callback) { + const trigger = document.querySelector( + `.chat-message-container[data-id="${message.id}"] .chat-message-react-btn` + ); + this.startFromMessage(callback, isDesktop, trigger); + } + + startFromMessageActions(message, isDesktop, callback) { + const trigger = document.querySelector( + `.chat-message-actions-container[data-id="${message.id}"] .chat-message-actions` + ); + this.startFromMessage(callback, isDesktop, trigger); + } + + startFromMessage(callback, isDesktop, trigger) { + this.context = "chat-message"; + this.element = document.querySelector(".chat-message-emoji-picker-anchor"); + this.open(callback); + this._popper?.destroy(); + + if (isDesktop) { + schedule("afterRender", () => { + this._popper = createPopper(trigger, this.element, { + placement: "top", + modifiers: [ + { + name: "eventListeners", + options: { + scroll: false, + resize: false, + }, + }, + { + name: "flip", + options: { + padding: { top: headerOffset() }, + }, + }, + ], + }); + }); + } + } + + startFromComposer(callback) { + this.context = "chat-composer"; + this.element = document.querySelector(".chat-composer-emoji-picker-anchor"); + this.open(callback); + } + + open(callback) { + if (this.opened) { + this.closeExisting(); + } + + this._loadEmojisData(); + + this.callback = callback; + this.opened = true; + } + + _loadEmojisData() { + if (this.emojis) { + return Promise.resolve(); + } + + this.loading = true; + + return ajax("/chat/emojis.json") + .then((emojis) => { + this.emojis = emojis; + }) + + .catch(popupAjaxError) + .finally(() => { + this.loading = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js new file mode 100644 index 0000000000..fbadb19b8d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js @@ -0,0 +1,88 @@ +// This class is adapted from emoji-store class in core. We want to maintain separate emoji store for reactions in chat plugin. +// https://github.com/discourse/discourse/blob/892f7e0506f3a4d40d9a59a4c926ff0a2aa0947e/app/assets/javascripts/discourse/app/services/emoji-store.js + +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; + +export default class ChatEmojiReactionStore extends Service { + STORE_NAMESPACE = "discourse_chat_emoji_reaction_"; + MAX_DISPLAYED_EMOJIS = 20; + MAX_TRACKED_EMOJIS = this.MAX_DISPLAYED_EMOJIS * 2; + SKIN_TONE_STORE_KEY = "emojiSelectedDiversity"; + USER_EMOJIS_STORE_KEY = "emojiUsage"; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + constructor() { + super(...arguments); + + if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) { + this.storedFavorites = []; + } + } + + get diversity() { + return this.store.getObject(this.SKIN_TONE_STORE_KEY) || 1; + } + + set diversity(value = 1) { + this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value }); + this.notifyPropertyChange("diversity"); + } + + get storedFavorites() { + let value = this.store.getObject(this.USER_EMOJIS_STORE_KEY) || []; + + if (value.length < 1) { + if (!this.siteSettings.default_emoji_reactions) { + value = []; + } else { + value = this.siteSettings.default_emoji_reactions + .split("|") + .filter(Boolean); + } + + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + } + + return value; + } + + set storedFavorites(value) { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + this.notifyPropertyChange("favorites"); + } + + get favorites() { + const computedStored = [ + ...new Set(this._frequencySort(this.storedFavorites)), + ]; + + return computedStored.slice(0, this.MAX_DISPLAYED_EMOJIS); + } + + set favorites(value = []) { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + } + + track(code) { + const normalizedCode = code.replace(/(^:)|(:$)/g, ""); + let recent = this.storedFavorites; + recent.unshift(normalizedCode); + recent.length = Math.min(recent.length, this.MAX_TRACKED_EMOJIS); + this.storedFavorites = recent; + } + + reset() { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value: [] }); + this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value: 1 }); + } + + _frequencySort(array = []) { + const counters = array.reduce((obj, val) => { + obj[val] = (obj[val] || 0) + 1; + return obj; + }, {}); + return Object.keys(counters).sort((a, b) => counters[b] - counters[a]); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js b/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js new file mode 100644 index 0000000000..791b06938e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js @@ -0,0 +1,22 @@ +import Service from "@ember/service"; + +export default class ChatGuardian extends Service { + canEditChatChannel() { + return this.canUseChat() && this.currentUser.staff; + } + + canArchiveChannel(channel) { + return ( + this.canEditChatChannel() && + this.siteSettings.chat_allow_archiving_channels && + !channel.isArchived && + !channel.isReadOnly + ); + } + + canUseChat() { + return ( + this.currentUser?.has_chat_enabled && this.siteSettings?.chat_enabled + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js new file mode 100644 index 0000000000..b76f8420e4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js @@ -0,0 +1,35 @@ +import Service, { inject as service } from "@ember/service"; +import { isTesting } from "discourse-common/config/environment"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatMessageVisibilityObserver extends Service { + @service chat; + + observer = new IntersectionObserver(this._observerCallback, { + root: document, + rootMargin: "-10px", + }); + + willDestroy() { + this.observer.disconnect(); + } + + @bind + _observerCallback(entries) { + entries.forEach((entry) => { + entry.target.dataset.visible = entry.isIntersecting; + + if (entry.isIntersecting && !isTesting()) { + this.chat.updateLastReadMessage(); + } + }); + } + + observe(element) { + this.observer.observe(element); + } + + unobserve(element) { + this.observer.unobserve(element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js new file mode 100644 index 0000000000..da8b500caf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js @@ -0,0 +1,135 @@ +import Service, { inject as service } from "@ember/service"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { isTesting } from "discourse-common/config/environment"; +import { + alertChannel, + onNotification, +} from "discourse/lib/desktop-notifications"; +import { bind, observes } from "discourse-common/utils/decorators"; + +export default class ChatNotificationManager extends Service { + @service presence; + @service chat; + + _inChat = false; + _subscribedToCore = true; + _subscribedToChat = false; + _countChatInDocTitle = true; + + start() { + if (!this._shouldRun()) { + return; + } + + this.set( + "_chatPresenceChannel", + this.presence.getChannel(`/chat-user/chat/${this.currentUser.id}`) + ); + this.set( + "_corePresenceChannel", + this.presence.getChannel(`/chat-user/core/${this.currentUser.id}`) + ); + this._chatPresenceChannel.subscribe(); + this._corePresenceChannel.subscribe(); + + withPluginApi("0.12.1", (api) => { + api.onPageChange(this._pageChanged); + }); + } + + willDestroy() { + super.willDestroy(...arguments); + + if (!this._shouldRun()) { + return; + } + + this._chatPresenceChannel.unsubscribe(); + this._chatPresenceChannel.leave(); + this._corePresenceChannel.unsubscribe(); + this._corePresenceChannel.leave(); + } + + shouldCountChatInDocTitle() { + return this._countChatInDocTitle; + } + + @bind + _pageChanged(path) { + this.set("_inChat", path.startsWith("/chat/channel/")); + if (this._inChat) { + this._chatPresenceChannel.enter({ onlyWhileActive: false }); + this._corePresenceChannel.leave(); + } else { + this._chatPresenceChannel.leave(); + this._corePresenceChannel.enter({ onlyWhileActive: false }); + } + } + + @observes("_chatPresenceChannel.count", "_corePresenceChannel.count") + _channelCountsChanged() { + discourseDebounce(this, this._subscribeToCorrectNotifications, 2000); + } + + _coreAlertChannel() { + return alertChannel(this.currentUser); + } + + _chatAlertChannel() { + return `/chat${alertChannel(this.currentUser)}`; + } + + _subscribeToCorrectNotifications() { + const oneTabForEachOpen = + this._chatPresenceChannel.count > 0 && + this._corePresenceChannel.count > 0; + if (oneTabForEachOpen) { + this._inChat + ? this._subscribeToChat({ only: true }) + : this._subscribeToCore({ only: true }); + } else { + this._subscribeToBoth(); + } + } + + _subscribeToBoth() { + this._subscribeToChat(); + this._subscribeToCore(); + } + + _subscribeToChat(opts = { only: false }) { + this.set("_countChatInDocTitle", true); + + if (!this._subscribedToChat) { + this.messageBus.subscribe(this._chatAlertChannel(), (data) => + onNotification(data, this.siteSettings, this.currentUser) + ); + } + + if (opts.only && this._subscribedToCore) { + this.messageBus.unsubscribe(this._coreAlertChannel()); + this.set("_subscribedToCore", false); + } + } + + _subscribeToCore(opts = { only: false }) { + if (opts.only) { + this.set("_countChatInDocTitle", false); + } + if (!this._subscribedToCore) { + this.messageBus.subscribe(this._coreAlertChannel(), (data) => + onNotification(data, this.siteSettings, this.currentUser) + ); + } + + if (this.only && this._subscribedToChat) { + this.messageBus.unsubscribe(this._chatAlertChannel()); + this.set("_subscribedToChat", false); + } + } + + _shouldRun() { + return this.chat.userCanChat && !isTesting(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js new file mode 100644 index 0000000000..38fe1af7e5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js @@ -0,0 +1,73 @@ +import Service, { inject as service } from "@ember/service"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { tracked } from "@glimmer/tracking"; +import KeyValueStore from "discourse/lib/key-value-store"; +import Site from "discourse/models/site"; + +const PREFERRED_MODE_KEY = "preferred_mode"; +const PREFERRED_MODE_STORE_NAMESPACE = "discourse_chat_"; +const FULL_PAGE_CHAT = "FULL_PAGE_CHAT"; +const DRAWER_CHAT = "DRAWER_CHAT"; + +export default class ChatStateManager extends Service { + @service router; + @tracked _chatURL = null; + @tracked _appURL = null; + + _store = new KeyValueStore(PREFERRED_MODE_STORE_NAMESPACE); + + reset() { + this._store.remove(PREFERRED_MODE_KEY); + this._chatURL = null; + this._appURL = null; + } + + prefersFullPage() { + this._store.setObject({ key: PREFERRED_MODE_KEY, value: FULL_PAGE_CHAT }); + } + + prefersDrawer() { + this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT }); + } + + get isFullPagePreferred() { + return !!( + Site.currentProp("mobileView") || + this._store.getObject(PREFERRED_MODE_KEY) === FULL_PAGE_CHAT + ); + } + + get isDrawerPreferred() { + return !!( + !this.isFullPagePreferred || + (!Site.currentProp("mobileView") && + (!this._store.getObject(PREFERRED_MODE_KEY) || + this._store.getObject(PREFERRED_MODE_KEY) === DRAWER_CHAT)) + ); + } + + get isFullPage() { + return this.router.currentRouteName?.startsWith("chat"); + } + + storeAppURL(URL = null) { + this._appURL = URL || this.router.currentURL; + } + + storeChatURL(URL = null) { + this._chatURL = URL || this.router.currentURL; + } + + get lastKnownAppURL() { + let url = this._appURL; + if (!url || url === "/") { + url = this.router.urlFor(`discovery.${defaultHomepage()}`); + } + + return url; + } + + get lastKnownChatURL() { + return this._chatURL || "/chat"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js new file mode 100644 index 0000000000..bf8a59b839 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -0,0 +1,995 @@ +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import deprecated from "discourse-common/lib/deprecated"; +import userSearch from "discourse/lib/user-search"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import Service, { inject as service } from "@ember/service"; +import Site from "discourse/models/site"; +import { ajax } from "discourse/lib/ajax"; +import { A } from "@ember/array"; +import { generateCookFunction } from "discourse/lib/text"; +import { cancel, next } from "@ember/runloop"; +import { and } from "@ember/object/computed"; +import { Promise } from "rsvp"; +import ChatChannel, { + CHANNEL_STATUSES, + CHATABLE_TYPES, +} from "discourse/plugins/chat/discourse/models/chat-channel"; +import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; +import discourseDebounce from "discourse-common/lib/debounce"; +import EmberObject, { computed } from "@ember/object"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseLater from "discourse-common/lib/later"; +import userPresent from "discourse/lib/user-presence"; + +export const LIST_VIEW = "list_view"; +export const CHAT_VIEW = "chat_view"; +export const DRAFT_CHANNEL_VIEW = "draft_channel_view"; + +const CHAT_ONLINE_OPTIONS = { + userUnseenTime: 300000, // 5 minutes seconds with no interaction + browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes +}; + +const READ_INTERVAL = 1000; + +export default class Chat extends Service { + @service appEvents; + @service chatNotificationManager; + @service chatStateManager; + @service presence; + @service router; + @service site; + + activeChannel = null; + allChannels = null; + cook = null; + directMessageChannels = null; + hasFetchedChannels = false; + hasUnreadMessages = false; + idToTitleMap = null; + lastUserTrackingMessageId = null; + messageId = null; + presenceChannel = null; + publicChannels = null; + sidebarActive = false; + unreadUrgentCount = null; + directMessagesLimit = 20; + isNetworkUnreliable = false; + @and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat; + _chatOpen = false; + _fetchingChannels = null; + + @computed("currentUser.staff", "currentUser.groups.[]") + get userCanDirectMessage() { + if (!this.currentUser) { + return false; + } + + return ( + this.currentUser.staff || + this.currentUser.isInAnyGroups( + (this.siteSettings.direct_message_enabled_groups || "11") // trust level 1 auto group + .split("|") + .map((groupId) => parseInt(groupId, 10)) + ) + ); + } + + init() { + super.init(...arguments); + + if (this.userCanChat) { + this.set("allChannels", []); + this._subscribeToNewChannelUpdates(); + this._subscribeToUserTrackingChannel(); + this._subscribeToChannelEdits(); + this._subscribeToChannelMetadata(); + this._subscribeToChannelStatusChange(); + this.presenceChannel = this.presence.getChannel("/chat/online"); + this.draftStore = {}; + + if (this.currentUser.chat_drafts) { + this.currentUser.chat_drafts.forEach((draft) => { + this.draftStore[draft.channel_id] = JSON.parse(draft.data); + }); + } + } + } + + markNetworkAsUnreliable() { + cancel(this._networkCheckHandler); + + this.set("isNetworkUnreliable", true); + + this._networkCheckHandler = discourseLater(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.markNetworkAsReliable(); + }, 30000); + } + + markNetworkAsReliable() { + cancel(this._networkCheckHandler); + + this.set("isNetworkUnreliable", false); + } + + setupWithPreloadedChannels(channels) { + this.currentUser.set("chat_channel_tracking_state", {}); + this._processChannels(channels || {}); + this.userChatChannelTrackingStateChanged(); + this.appEvents.trigger("chat:refresh-channels"); + } + + willDestroy() { + super.willDestroy(...arguments); + + if (this.userCanChat) { + this.set("allChannels", null); + this._unsubscribeFromNewDmChannelUpdates(); + this._unsubscribeFromUserTrackingChannel(); + this._unsubscribeFromChannelEdits(); + this._unsubscribeFromChannelMetadata(); + this._unsubscribeFromChannelStatusChange(); + this._unsubscribeFromAllChatChannels(); + } + } + + setActiveChannel(channel) { + this.set("activeChannel", channel); + } + + loadCookFunction(categories) { + if (this.cook) { + return Promise.resolve(this.cook); + } + + const prettyTextFeatures = { + featuresOverride: Site.currentProp( + "markdown_additional_options.chat.limited_pretty_text_features" + ), + markdownItRules: Site.currentProp( + "markdown_additional_options.chat.limited_pretty_text_markdown_rules" + ), + }; + + return generateCookFunction(prettyTextFeatures).then((cookFunction) => { + return this.set("cook", (raw) => { + return simpleCategoryHashMentionTransform( + cookFunction(raw), + categories + ); + }); + }); + } + + get chatOpen() { + return this._chatOpen; + } + + set chatOpen(status) { + this.set("_chatOpen", status); + this.updatePresence(); + } + + updatePresence() { + next(() => { + if (this.chatStateManager.isFullPage || this.chatOpen) { + this.presenceChannel.enter({ activeOptions: CHAT_ONLINE_OPTIONS }); + } else { + this.presenceChannel.leave(); + } + }); + } + + getDocumentTitleCount() { + return this.chatNotificationManager.shouldCountChatInDocTitle() + ? this.unreadUrgentCount + : 0; + } + + _channelObject() { + return { + publicChannels: this.publicChannels, + directMessageChannels: this.directMessageChannels, + }; + } + + truncateDirectMessageChannels(channels) { + return channels.slice(0, this.directMessagesLimit); + } + + getActiveChannel() { + let channelId; + if (this.router.currentRouteName === "chat.channel") { + channelId = this.router.currentRoute.params.channelId; + } else { + channelId = document.querySelector(".topic-chat-container.visible") + ?.dataset?.chatChannelId; + } + return channelId + ? this.allChannels.findBy("id", parseInt(channelId, 10)) + : null; + } + + async getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) { + let sortedChannels = this.allChannels.sort((a, b) => { + return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at) + ? -1 + : 1; + }); + + const trimmedFilter = filter.trim(); + const lowerCasedFilter = filter.toLowerCase(); + const { activeChannel } = this; + + return sortedChannels.filter((channel) => { + if ( + opts.excludeActiveChannel && + activeChannel && + activeChannel.id === channel.id + ) { + return false; + } + if (!trimmedFilter.length) { + return true; + } + + if (channel.isDirectMessageChannel) { + let userFound = false; + channel.chatable.users.forEach((user) => { + if ( + user.username.toLowerCase().includes(lowerCasedFilter) || + user.name?.toLowerCase().includes(lowerCasedFilter) + ) { + return (userFound = true); + } + }); + return userFound; + } else { + return channel.title.toLowerCase().includes(lowerCasedFilter); + } + }); + } + + switchChannelUpOrDown(direction) { + const { activeChannel } = this; + if (!activeChannel) { + return; // Chat isn't open. Return and do nothing! + } + + let currentList, otherList; + if (activeChannel.isDirectMessageChannel) { + currentList = this.truncateDirectMessageChannels( + this.directMessageChannels + ); + otherList = this.publicChannels; + } else { + currentList = this.publicChannels; + otherList = this.truncateDirectMessageChannels( + this.directMessageChannels + ); + } + + const directionUp = direction === "up"; + const currentChannelIndex = currentList.findIndex( + (c) => c.id === activeChannel.id + ); + + let nextChannelInSameList = + currentList[currentChannelIndex + (directionUp ? -1 : 1)]; + if (nextChannelInSameList) { + // You're navigating in the same list of channels, just use index +- 1 + return this.openChannel(nextChannelInSameList); + } + + // You need to go to the next list of channels, if it exists. + const nextList = otherList.length ? otherList : currentList; + const nextChannel = directionUp + ? nextList[nextList.length - 1] + : nextList[0]; + + if (nextChannel.id !== activeChannel.id) { + return this.openChannel(nextChannel); + } + } + + getChannels() { + return new Promise((resolve) => { + if (this.hasFetchedChannels) { + return resolve(this._channelObject()); + } + + if (!this._fetchingChannels) { + this._fetchingChannels = this._refreshChannels(); + } + + this._fetchingChannels + .then(() => resolve(this._channelObject())) + .finally(() => (this._fetchingChannels = null)); + }); + } + + forceRefreshChannels() { + this.set("hasFetchedChannels", false); + this._unsubscribeFromAllChatChannels(); + return this.getChannels(); + } + + refreshTrackingState() { + if (!this.currentUser) { + return; + } + + return ajax("/chat/chat_channels.json") + .then((response) => { + this.currentUser.set("chat_channel_tracking_state", {}); + (response.direct_message_channels || []).forEach((channel) => { + this._updateUserTrackingState(channel); + }); + (response.public_channels || []).forEach((channel) => { + this._updateUserTrackingState(channel); + }); + }) + .finally(() => { + this.userChatChannelTrackingStateChanged(); + }); + } + + _refreshChannels() { + return new Promise((resolve) => { + this.setProperties({ + loading: true, + allChannels: [], + }); + this.currentUser.set("chat_channel_tracking_state", {}); + ajax("/chat/chat_channels.json").then((channels) => { + this._processChannels(channels); + this.userChatChannelTrackingStateChanged(); + this.appEvents.trigger("chat:refresh-channels"); + resolve(this._channelObject()); + }); + }); + } + + _processChannels(channels) { + this.setProperties({ + publicChannels: A( + this.sortPublicChannels( + (channels.public_channels || []).map((channel) => + this.processChannel(channel) + ) + ) + ), + directMessageChannels: A( + this.sortDirectMessageChannels( + (channels.direct_message_channels || []).map((channel) => + this.processChannel(channel) + ) + ) + ), + hasFetchedChannels: true, + loading: false, + }); + const idToTitleMap = {}; + this.allChannels.forEach((c) => { + idToTitleMap[c.id] = c.title; + }); + this.set("idToTitleMap", idToTitleMap); + this.presenceChannel.subscribe(channels.global_presence_channel_state); + } + + reSortDirectMessageChannels() { + this.set( + "directMessageChannels", + this.sortDirectMessageChannels(this.directMessageChannels) + ); + } + + async getChannelBy(key, value) { + return this.getChannels().then(() => { + if (!isNaN(value)) { + value = parseInt(value, 10); + } + return (this.allChannels || []).findBy(key, value); + }); + } + + searchPossibleDirectMessageUsers(options) { + // TODO: implement a chat specific user search function + return userSearch(options); + } + + getIdealFirstChannelId() { + // When user opens chat we need to give them the 'best' channel when they enter. + // + // Look for public channels with mentions. If one exists, enter that. + // Next best is a DM channel with unread messages. + // Next best is a public channel with unread messages. + // Then we fall back to the chat_default_channel_id site setting + // if that is present and in the list of channels the user can access. + // If none of these options exist, then we get the first public channel, + // or failing that the first DM channel. + return this.getChannels().then(() => { + // Defined in order of significance. + let publicChannelWithMention, + dmChannelWithUnread, + publicChannelWithUnread, + publicChannel, + dmChannel, + defaultChannel; + + for (const [channel, state] of Object.entries( + this.currentUser.chat_channel_tracking_state + )) { + if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) { + if (!dmChannelWithUnread && state.unread_count > 0) { + dmChannelWithUnread = channel; + } else if (!dmChannel) { + dmChannel = channel; + } + } else { + if (state.unread_mentions > 0) { + publicChannelWithMention = channel; + break; // <- We have a public channel with a mention. Break and return this. + } else if (!publicChannelWithUnread && state.unread_count > 0) { + publicChannelWithUnread = channel; + } else if ( + !defaultChannel && + parseInt(this.siteSettings.chat_default_channel_id || 0, 10) === + parseInt(channel, 10) + ) { + defaultChannel = channel; + } else if (!publicChannel) { + publicChannel = channel; + } + } + } + return ( + publicChannelWithMention || + dmChannelWithUnread || + publicChannelWithUnread || + defaultChannel || + publicChannel || + dmChannel + ); + }); + } + + sortPublicChannels(channels) { + return channels.sort((a, b) => a.title.localeCompare(b.title)); + } + + sortDirectMessageChannels(channels) { + return channels.sort((a, b) => { + const unreadCountA = + this.currentUser.chat_channel_tracking_state[a.id]?.unread_count || 0; + const unreadCountB = + this.currentUser.chat_channel_tracking_state[b.id]?.unread_count || 0; + if (unreadCountA === unreadCountB) { + return new Date(a.last_message_sent_at) > + new Date(b.last_message_sent_at) + ? -1 + : 1; + } else { + return unreadCountA > unreadCountB ? -1 : 1; + } + }); + } + + getIdealFirstChannelIdAndTitle() { + return this.getIdealFirstChannelId().then((channelId) => { + if (!channelId) { + return; + } + return { + id: channelId, + title: this.idToTitleMap[channelId], + }; + }); + } + + async openChannelAtMessage(channelId, messageId = null) { + let channel = await this.getChannelBy("id", channelId); + if (channel) { + return this._openFoundChannelAtMessage(channel, messageId); + } + + return ajax(`/chat/chat_channels/${channelId}`).then((response) => { + const queryParams = messageId ? { messageId } : {}; + return this.router.transitionTo( + "chat.channel", + response.id, + slugifyChannel(response), + { queryParams } + ); + }); + } + + async openChannel(channel) { + return this._openFoundChannelAtMessage(channel); + } + + async _openFoundChannelAtMessage(channel, messageId = null) { + if ( + this.router.currentRouteName === "chat.channel.index" && + this.activeChannel?.id === channel.id + ) { + this.setActiveChannel(channel); + this._fireOpenMessageAppEvent(messageId); + return Promise.resolve(); + } + + this.setActiveChannel(channel); + + if ( + this.chatStateManager.isFullPage || + this.site.mobileView || + this.chatStateManager.isFullPagePreferred + ) { + const queryParams = messageId ? { messageId } : {}; + + return this.router.transitionTo( + "chat.channel", + channel.id, + slugifyChannel(channel), + { queryParams } + ); + } else { + this._fireOpenFloatAppEvent(channel, messageId); + return Promise.resolve(); + } + } + + _fireOpenFloatAppEvent(channel, messageId = null) { + messageId + ? this.appEvents.trigger( + "chat:open-channel-at-message", + channel, + messageId + ) + : this.appEvents.trigger("chat:open-channel", channel); + } + + _fireOpenMessageAppEvent(messageId) { + this.appEvents.trigger("chat-live-pane:highlight-message", messageId); + } + + async startTrackingChannel(channel) { + if (!channel.current_user_membership.following) { + return; + } + + let existingChannel = await this.getChannelBy("id", channel.id); + if (existingChannel) { + return existingChannel; // User is already tracking this channel. return! + } + + const existingChannels = channel.isDirectMessageChannel + ? this.directMessageChannels + : this.publicChannels; + + // this check shouldn't be needed given the previous check to existingChannel + // this is a safety net, to ensure we never track duplicated channels + existingChannel = existingChannels.findBy("id", channel.id); + if (existingChannel) { + return existingChannel; + } + + const newChannel = this.processChannel(channel); + existingChannels.pushObject(newChannel); + this.currentUser.chat_channel_tracking_state[channel.id] = + EmberObject.create({ + unread_count: 1, + unread_mentions: 0, + chatable_type: channel.chatable_type, + }); + this.userChatChannelTrackingStateChanged(); + if (channel.isDirectMessageChannel) { + this.reSortDirectMessageChannels(); + } + if (channel.isPublicChannel) { + this.set("publicChannels", this.sortPublicChannels(this.publicChannels)); + } + this.appEvents.trigger("chat:refresh-channels"); + return newChannel; + } + + async stopTrackingChannel(channel) { + return this.getChannelBy("id", channel.id).then((existingChannel) => { + if (existingChannel) { + return this.forceRefreshChannels(); + } + }); + } + + _subscribeToChannelMetadata() { + this.messageBus.subscribe("/chat/channel-metadata", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (channel) { + channel.setProperties({ + memberships_count: busData.memberships_count, + }); + this.appEvents.trigger("chat:refresh-channel-members"); + } + }); + }); + } + + _subscribeToChannelEdits() { + this.messageBus.subscribe("/chat/channel-edits", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (channel) { + channel.setProperties({ + title: busData.name, + description: busData.description, + }); + } + }); + }); + } + + _subscribeToChannelStatusChange() { + this.messageBus.subscribe("/chat/channel-status", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (!channel) { + return; + } + + channel.set("status", busData.status); + + // it is not possible for the user to set their last read message id + // if the channel has been archived, because all the messages have + // been deleted. we don't want them seeing the blue dot anymore so + // just completely reset the unreads + if (busData.status === CHANNEL_STATUSES.archived) { + this.currentUser.chat_channel_tracking_state[channel.id] = { + unread_count: 0, + unread_mentions: 0, + chatable_type: channel.chatable_type, + }; + this.userChatChannelTrackingStateChanged(); + } + + this.appEvents.trigger("chat:refresh-channel", channel.id); + }); + }); + } + + _unsubscribeFromChannelStatusChange() { + this.messageBus.unsubscribe("/chat/channel-status"); + } + + _unsubscribeFromChannelEdits() { + this.messageBus.unsubscribe("/chat/channel-edits"); + } + + _unsubscribeFromChannelMetadata() { + this.messageBus.unsubscribe("/chat/channel-metadata"); + } + + _subscribeToNewChannelUpdates() { + this.messageBus.subscribe("/chat/new-channel", (busData) => { + this.startTrackingChannel(ChatChannel.create(busData.chat_channel)); + }); + } + + _unsubscribeFromNewDmChannelUpdates() { + this.messageBus.unsubscribe("/chat/new-channel"); + } + + _subscribeToSingleUpdateChannel(channel) { + if (channel.current_user_membership.muted) { + return; + } + + if (!channel.isDirectMessageChannel) { + this._subscribeToMentionChannel(channel); + } + + this.messageBus.subscribe(`/chat/${channel.id}/new-messages`, (busData) => { + const trackingState = + this.currentUser.chat_channel_tracking_state[channel.id]; + + if (busData.user_id === this.currentUser.id) { + // User sent message, update tracking state to no unread + trackingState.set("chat_message_id", busData.message_id); + } else { + // Ignored user sent message, update tracking state to no unread + if (this.currentUser.ignored_users.includes(busData.username)) { + trackingState.set("chat_message_id", busData.message_id); + } else { + // Message from other user. Increment trackings state + if (busData.message_id > (trackingState.chat_message_id || 0)) { + trackingState.set("unread_count", trackingState.unread_count + 1); + } + } + } + this.userChatChannelTrackingStateChanged(); + + // Update last_message_sent_at timestamp for channel if direct message + const dmChatChannel = (this.directMessageChannels || []).findBy( + "id", + parseInt(channel.id, 10) + ); + if (dmChatChannel) { + dmChatChannel.set("last_message_sent_at", new Date()); + this.reSortDirectMessageChannels(); + } + }); + } + + _subscribeToMentionChannel(channel) { + this.messageBus.subscribe(`/chat/${channel.id}/new-mentions`, () => { + const trackingState = + this.currentUser.chat_channel_tracking_state[channel.id]; + if (trackingState) { + trackingState.set( + "unread_mentions", + (trackingState.unread_mentions || 0) + 1 + ); + this.userChatChannelTrackingStateChanged(); + } + }); + } + + async followChannel(channel) { + return ChatApi.followChatChannel(channel).then(() => { + this.startTrackingChannel(channel); + this._subscribeToSingleUpdateChannel(channel); + }); + } + + async unfollowChannel(channel) { + return ChatApi.unfollowChatChannel(channel).then(() => { + this._unsubscribeFromChatChannel(channel); + this.stopTrackingChannel(channel); + + if (channel === this.activeChannel && channel.isDirectMessageChannel) { + this.router.transitionTo("chat"); + } + }); + } + + _unsubscribeFromAllChatChannels() { + (this.allChannels || []).forEach((channel) => { + this._unsubscribeFromChatChannel(channel); + }); + } + + _unsubscribeFromChatChannel(channel) { + this.messageBus.unsubscribe(`/chat/${channel.id}/new-messages`); + if (!channel.isDirectMessageChannel) { + this.messageBus.unsubscribe(`/chat/${channel.id}/new-mentions`); + } + } + + _subscribeToUserTrackingChannel() { + this.messageBus.subscribe( + `/chat/user-tracking-state/${this.currentUser.id}`, + (busData, _, messageId) => { + const lastId = this.lastUserTrackingMessageId; + + // we don't want this state to go backwards, only catch + // up if messages from messagebus were missed + if (!lastId || messageId > lastId) { + this.lastUserTrackingMessageId = messageId; + } + + // we are too far out of sync, we should resync everything. + // this will trigger a route transition and blur the chat input + if (lastId && messageId > lastId + 1) { + return this.forceRefreshChannels(); + } + + const trackingState = + this.currentUser.chat_channel_tracking_state[busData.chat_channel_id]; + if (trackingState) { + trackingState.set("chat_message_id", busData.chat_message_id); + trackingState.set("unread_count", 0); + trackingState.set("unread_mentions", 0); + this.userChatChannelTrackingStateChanged(); + } + } + ); + } + + _unsubscribeFromUserTrackingChannel() { + this.messageBus.unsubscribe( + `/chat/user-tracking-state/${this.currentUser.id}` + ); + } + + resetTrackingStateForChannel(channelId) { + const trackingState = + this.currentUser.chat_channel_tracking_state[channelId]; + if (trackingState) { + trackingState.set("unread_count", 0); + this.userChatChannelTrackingStateChanged(); + } + } + + userChatChannelTrackingStateChanged() { + this._recalculateUnreadMessages(); + this.appEvents.trigger("chat:user-tracking-state-changed"); + } + + _recalculateUnreadMessages() { + let unreadPublicCount = 0; + let unreadUrgentCount = 0; + let headerNeedsRerender = false; + + Object.values(this.currentUser.chat_channel_tracking_state).forEach( + (state) => { + if (state.muted) { + return; + } + + if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) { + unreadUrgentCount += state.unread_count || 0; + } else { + unreadUrgentCount += state.unread_mentions || 0; + unreadPublicCount += state.unread_count || 0; + } + } + ); + + let hasUnreadPublic = unreadPublicCount > 0; + if (hasUnreadPublic !== this.hasUnreadMessages) { + headerNeedsRerender = true; + this.set("hasUnreadMessages", hasUnreadPublic); + } + + if (unreadUrgentCount !== this.unreadUrgentCount) { + headerNeedsRerender = true; + this.set("unreadUrgentCount", unreadUrgentCount); + } + + this.currentUser.notifyPropertyChange("chat_channel_tracking_state"); + if (headerNeedsRerender) { + this.appEvents.trigger("chat:rerender-header"); + this.appEvents.trigger("notifications:changed"); + } + } + + processChannel(channel) { + channel = ChatChannel.create(channel); + this._subscribeToSingleUpdateChannel(channel); + this._updateUserTrackingState(channel); + this.allChannels.push(channel); + return channel; + } + + _updateUserTrackingState(channel) { + this.currentUser.chat_channel_tracking_state[channel.id] = + EmberObject.create({ + chatable_type: channel.chatable_type, + muted: channel.current_user_membership.muted, + unread_count: channel.current_user_membership.unread_count, + unread_mentions: channel.current_user_membership.unread_mentions, + chat_message_id: channel.current_user_membership.last_read_message_id, + }); + } + + upsertDmChannelForUser(channel, user) { + const usernames = (channel.chatable.users || []) + .mapBy("username") + .concat(user.username) + .uniq(); + + return this.upsertDmChannelForUsernames(usernames); + } + + // @param {array} usernames - The usernames to create or fetch the direct message + // channel for. The current user will automatically be included in the channel + // when it is created. + upsertDmChannelForUsernames(usernames) { + return ajax("/chat/direct_messages/create.json", { + method: "POST", + data: { usernames: usernames.uniq() }, + }) + .then((response) => { + const chatChannel = ChatChannel.create(response.chat_channel); + this.startTrackingChannel(chatChannel); + return chatChannel; + }) + .catch(popupAjaxError); + } + + // @param {array} usernames - The usernames to fetch the direct message + // channel for. The current user will automatically be included as a + // participant to fetch the channel for. + getDmChannelForUsernames(usernames) { + return ajax("/chat/direct_messages.json", { + data: { usernames: usernames.uniq().join(",") }, + }); + } + + _saveDraft(channelId, draft) { + const data = { chat_channel_id: channelId }; + if (draft) { + data.data = JSON.stringify(draft); + } + + ajax("/chat/drafts", { type: "POST", data, ignoreUnsent: false }) + .then(() => { + this.markNetworkAsReliable(); + }) + .catch((error) => { + if (!error.jqXHR?.responseJSON?.errors?.length) { + this.markNetworkAsUnreliable(); + } + }); + } + + setDraftForChannel(channel, draft) { + if ( + draft && + (draft.value || draft.uploads.length > 0 || draft.replyToMsg) + ) { + this.draftStore[channel.id] = draft; + } else { + delete this.draftStore[channel.id]; + draft = null; // _saveDraft will destroy draft + } + + discourseDebounce(this, this._saveDraft, channel.id, draft, 2000); + } + + getDraftForChannel(channelId) { + return ( + this.draftStore[channelId] || { + value: "", + uploads: [], + replyToMsg: null, + } + ); + } + + updateLastReadMessage() { + discourseDebounce(this, this._queuedReadMessageUpdate, READ_INTERVAL); + } + + _queuedReadMessageUpdate() { + const visibleMessages = document.querySelectorAll( + ".chat-message-container[data-visible=true]" + ); + const channel = this.activeChannel; + + if ( + !channel?.isFollowing || + visibleMessages?.length === 0 || + !userPresent() + ) { + return; + } + + const latestUnreadMsgId = parseInt( + visibleMessages[visibleMessages.length - 1].dataset.id, + 10 + ); + + const hasUnreadMessages = latestUnreadMsgId > channel.lastSendReadMessageId; + + if ( + !hasUnreadMessages && + this.currentUser.chat_channel_tracking_state[this.activeChannel.id] + ?.unread_count > 0 + ) { + // Weird state here where the chat_channel_tracking_state is wrong. Need to reset it. + this.resetTrackingStateForChannel(this.activeChannel.id); + } + + if (hasUnreadMessages) { + channel.updateLastReadMessage(latestUnreadMsgId); + } + } + + addToolbarButton() { + deprecated( + "Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`" + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js b/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js new file mode 100644 index 0000000000..9e0c594eb8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js @@ -0,0 +1,70 @@ +import Service, { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; + +export default class EmojiPickerScrollObserver extends Service { + @service chatEmojiPickerManager; + + @tracked enabled = true; + direction = "up"; + prevYPosition = 0; + + @bind + _observerCallback(event) { + if (!this.enabled) { + return; + } + + this._setScrollDirection(event.target); + + const visibleSections = [ + ...document.querySelectorAll(".chat-emoji-picker__section"), + ].filter((sectionElement) => + this._isSectionVisibleInPicker(sectionElement, event.target) + ); + + if (visibleSections?.length) { + let sectionElement; + + if (this.direction === "up" || this.prevYPosition < 50) { + sectionElement = visibleSections.firstObject; + } else { + sectionElement = visibleSections.lastObject; + } + + this.chatEmojiPickerManager.lastVisibleSection = + sectionElement.dataset.section; + + this.chatEmojiPickerManager.addVisibleSections( + visibleSections.map((s) => s.dataset.section) + ); + } + } + + observe(element) { + element.addEventListener("scroll", this._observerCallback); + } + + unobserve(element) { + element.removeEventListener("scroll", this._observerCallback); + } + + _setScrollDirection(target) { + if (target.scrollTop > this.prevYPosition) { + this.direction = "down"; + } else { + this.direction = "up"; + } + + this.prevYPosition = target.scrollTop; + } + + _isSectionVisibleInPicker(section, picker) { + const { bottom, height, top } = section.getBoundingClientRect(); + const containerRect = picker.getBoundingClientRect(); + + return top <= containerRect.top + ? containerRect.top - top <= height + : bottom - containerRect.bottom <= height; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs new file mode 100644 index 0000000000..728db2658e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs @@ -0,0 +1,125 @@ +{{#if this.selectedWebhook}} + + +
    +
    + + +
    + +
    + + + + {{i18n "chat.channel_edit_description_modal.description"}} + + + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs new file mode 100644 index 0000000000..1fb76bd822 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs @@ -0,0 +1,15 @@ + + + + {{i18n "chat.channel_edit_title_modal.description"}} + + + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs new file mode 100644 index 0000000000..656fbf91f0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs new file mode 100644 index 0000000000..e06c378ec8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs new file mode 100644 index 0000000000..d35d8c4e24 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs new file mode 100644 index 0000000000..e39cc9a9d5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs @@ -0,0 +1,33 @@ + + + + + {{#if this.categoryPermissionsHint}} +
    + {{this.categoryPermissionsHint}} +
    + {{/if}} + + {{#if this.autoJoinAvailable}} + + {{/if}} + + + + + + +
    + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs new file mode 100644 index 0000000000..f8f46baa0f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs @@ -0,0 +1,47 @@ + + +
    + +
    + +
    + + + {{i18n "chat.only_chat_push_notifications.description"}} + +
    + +
    + + + {{i18n "chat.ignore_channel_wide_mention.description"}} + +
    + +
    + + +
    + +
    + + + {{#if (eq this.model.user_option.chat_email_frequency "when_away")}} +
    + {{i18n "chat.email_frequency.description"}} +
    + {{/if}} +
    + + diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js new file mode 100644 index 0000000000..39e1b768d3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js @@ -0,0 +1,76 @@ +import getURL from "discourse-common/lib/get-url"; +import { createWidget } from "discourse/widgets/widget"; +import { h } from "virtual-dom"; +import { iconNode } from "discourse-common/lib/icon-library"; + +export default createWidget("header-chat-link", { + buildKey: () => "header-chat-link", + chat: null, + tagName: "li.header-dropdown-toggle.open-chat", + title: "chat.title", + services: ["chat", "router", "chatStateManager"], + + html() { + if (!this.chat.userCanChat) { + return; + } + + if (this.currentUser.isInDoNotDisturb()) { + return this.chatLinkHtml(); + } + + let indicator; + if (this.chat.unreadUrgentCount) { + indicator = h( + "div.chat-channel-unread-indicator.urgent", + {}, + h( + "div.number-wrap", + {}, + h("div.number", {}, this.chat.unreadUrgentCount) + ) + ); + } else if (this.chat.hasUnreadMessages) { + indicator = h("div.chat-channel-unread-indicator"); + } + + return this.chatLinkHtml(indicator); + }, + + chatLinkHtml(indicatorNode) { + return h( + `a.icon${ + this.chatStateManager.isFullPage || this.chat.chatOpen ? ".active" : "" + }`, + { attributes: { tabindex: 0 } }, + [iconNode("comment"), indicatorNode].filter(Boolean) + ); + }, + + mouseUp(e) { + if (e.which === 2) { + // Middle mouse click + window.open(getURL("/chat"), "_blank").focus(); + } + }, + + keyUp(e) { + if (e.code === "Enter") { + return this.click(); + } + }, + + click() { + if (this.chatStateManager.isFullPage && !this.site.mobileView) { + return; + } + + return this.router.transitionTo( + this.chatStateManager.lastKnownChatURL || "chat" + ); + }, + + chatRerenderHeader() { + this.scheduleRerender(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js new file mode 100644 index 0000000000..f9367adec4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js @@ -0,0 +1,45 @@ +import I18n from "I18n"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; +import { h } from "virtual-dom"; +import { formatUsername } from "discourse/lib/utilities"; +import { iconNode } from "discourse-common/lib/icon-library"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +createWidgetFrom(DefaultNotificationItem, "chat-invitation-notification-item", { + services: ["chat", "router"], + + text(data) { + const username = formatUsername(data.invited_by_username); + return I18n.t("notifications.chat_invitation_html", { username }); + }, + + html(attrs) { + const notificationType = attrs.notification_type; + const lookup = this.site.get("notificationLookup"); + const notificationName = lookup[notificationType]; + const { data } = attrs; + const text = this.text(data); + const title = this.notificationTitle(notificationName, data); + const html = new RawHtml({ html: `
    ${text}
    ` }); + const contents = [iconNode("link"), html]; + const href = this.url(data); + + return h( + "a", + { attributes: { title, href, "data-auto-route": true } }, + contents + ); + }, + + url(data) { + const slug = slugifyChannel({ + title: data.chat_channel_title, + slug: data.chat_channel_slug, + }); + return `/chat/channel/${data.chat_channel_id}/${slug || "-"}?messageId=${ + data.chat_message_id + }`; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js new file mode 100644 index 0000000000..7d194879dc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js @@ -0,0 +1,66 @@ +import I18n from "I18n"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; +import { h } from "virtual-dom"; +import { formatUsername } from "discourse/lib/utilities"; +import { iconNode } from "discourse-common/lib/icon-library"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +const chatNotificationItem = { + services: ["chat", "router"], + + text(notificationName, data) { + const username = formatUsername(data.mentioned_by_username); + const identifier = data.identifier ? `@${data.identifier}` : null; + const i18nPrefix = data.is_direct_message_channel + ? "notifications.popup.direct_message_chat_mention" + : "notifications.popup.chat_mention"; + const i18nSuffix = identifier ? "other_html" : "direct_html"; + + return I18n.t(`${i18nPrefix}.${i18nSuffix}`, { + username, + identifier, + channel: data.chat_channel_title, + }); + }, + + html(attrs) { + const notificationType = attrs.notification_type; + const lookup = this.site.get("notificationLookup"); + const notificationName = lookup[notificationType]; + const { data } = attrs; + const title = this.notificationTitle(notificationName, data); + const text = this.text(notificationName, data); + const html = new RawHtml({ html: `
    ${text}
    ` }); + const contents = [iconNode("comment"), html]; + const href = this.url(data); + + return h( + "a", + { attributes: { title, href, "data-auto-route": true } }, + contents + ); + }, + + url(data) { + const slug = slugifyChannel({ + title: data.chat_channel_title, + slug: data.chat_channel_slug, + }); + return `/chat/channel/${data.chat_channel_id}/${slug || "-"}?messageId=${ + data.chat_message_id + }`; + }, +}; + +createWidgetFrom( + DefaultNotificationItem, + "chat-mention-notification-item", + chatNotificationItem +); +createWidgetFrom( + DefaultNotificationItem, + "chat-group-mention-notification-item", + chatNotificationItem +); diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js new file mode 100644 index 0000000000..1f1d00fa87 --- /dev/null +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js @@ -0,0 +1,255 @@ +import I18n from "I18n"; +import { performEmojiUnescape } from "pretty-text/emoji"; + +let customMarkdownCookFn; + +const chatTranscriptRule = { + tag: "chat", + + replace: function (state, tagInfo, content) { + // shouldn't really happen but we don't want to break rendering if it does + if (!customMarkdownCookFn) { + return; + } + + const options = state.md.options.discourse; + const [username, messageIdStart, messageTimeStart] = + (tagInfo.attrs.quote && tagInfo.attrs.quote.split(";")) || []; + const reactions = tagInfo.attrs.reactions; + const multiQuote = !!tagInfo.attrs.multiQuote; + const noLink = !!tagInfo.attrs.noLink; + const channelName = tagInfo.attrs.channel; + const channelId = tagInfo.attrs.channelId; + const channelLink = channelId + ? options.getURL(`/chat/channel/${channelId}/-`) + : null; + + if (!username || !messageIdStart || !messageTimeStart) { + return; + } + + let wrapperDivToken = state.push("div_chat_transcript_wrap", "div", 1); + let wrapperClasses = ["chat-transcript"]; + + if (!!tagInfo.attrs.chained) { + wrapperClasses.push("chat-transcript-chained"); + } + + wrapperDivToken.attrs = [["class", wrapperClasses.join(" ")]]; + wrapperDivToken.attrs.push(["data-message-id", messageIdStart]); + wrapperDivToken.attrs.push(["data-username", username]); + wrapperDivToken.attrs.push(["data-datetime", messageTimeStart]); + + if (reactions) { + wrapperDivToken.attrs.push(["data-reactions", reactions]); + } + + if (channelName) { + wrapperDivToken.attrs.push(["data-channel-name", channelName]); + + if (multiQuote) { + let metaDivToken = state.push("div_chat_transcript_meta", "div", 1); + metaDivToken.attrs = [["class", "chat-transcript-meta"]]; + const channelToken = state.push("html_inline", "", 0); + channelToken.content = I18n.t("chat.quote.original_channel", { + channel: channelName, + channelLink, + }); + state.push("div_chat_transcript_meta", "div", -1); + } + } + + if (channelId) { + wrapperDivToken.attrs.push(["data-channel-id", channelId]); + } + + let userDivToken = state.push("div_chat_transcript_user", "div", 1); + userDivToken.attrs = [["class", "chat-transcript-user"]]; + + // start: user avatar + let avatarDivToken = state.push( + "div_chat_transcript_user_avatar", + "div", + 1 + ); + avatarDivToken.attrs = [["class", "chat-transcript-user-avatar"]]; + + // server-side, we need to lookup the avatar from the username + let avatarImg; + if (options.lookupAvatar) { + avatarImg = options.lookupAvatar(username); + } + if (avatarImg) { + const avatarImgToken = state.push("html_inline", "", 0); + avatarImgToken.content = avatarImg; + } + + state.push("div_chat_transcript_user_avatar", "div", -1); + // end: user avatar + + // start: username + let usernameDivToken = state.push("div_chat_transcript_username", "div", 1); + usernameDivToken.attrs = [["class", "chat-transcript-username"]]; + + let displayName; + if (options.formatUsername) { + displayName = options.formatUsername(username); + } else { + displayName = username; + } + + const usernameToken = state.push("html_inline", "", 0); + usernameToken.content = displayName; + + state.push("div_chat_transcript_username", "div", -1); + // end: username + + // start: time + link to message + let datetimeDivToken = state.push("div_chat_transcript_datetime", "div", 1); + datetimeDivToken.attrs = [["class", "chat-transcript-datetime"]]; + + // for some cases, like archiving, we don't want the link to the + // chat message because it will just result in a 404 + // also handles the case where the quote doesn’t contain + // enough data to build a valid channel/message link + if (noLink || !channelLink) { + let spanToken = state.push("span_open", "span", 1); + spanToken.attrs = [["title", messageTimeStart]]; + + spanToken.block = false; + spanToken = state.push("span_close", "span", -1); + spanToken.block = false; + } else { + let linkToken = state.push("link_open", "a", 1); + linkToken.attrs = [ + ["href", `${channelLink}?messageId=${messageIdStart}`], + ["title", messageTimeStart], + ]; + + linkToken.block = false; + linkToken = state.push("link_close", "a", -1); + linkToken.block = false; + } + + state.push("div_chat_transcript_datetime", "div", -1); + // end: time + link to message + + // start: channel link for !multiQuote + if (channelName && !multiQuote) { + let channelLinkToken = state.push("link_open", "a", 1); + channelLinkToken.attrs = [ + ["class", "chat-transcript-channel"], + ["href", channelLink], + ]; + let inlineTextToken = state.push("html_inline", "", 0); + inlineTextToken.content = `#${channelName}`; + channelLinkToken = state.push("link_close", "a", -1); + channelLinkToken.block = false; + } + // end: channel link for !multiQuote + + state.push("div_chat_transcript_user", "div", -1); + + let messagesToken = state.push("div_chat_transcript_messages", "div", 1); + messagesToken.attrs = [["class", "chat-transcript-messages"]]; + + // rendering chat message content with limited markdown rule subset + const token = state.push("html_raw", "", 1); + token.content = customMarkdownCookFn(content); + state.push("html_raw", "", -1); + + if (reactions) { + let emojiHtmlCache = {}; + let reactionsToken = state.push( + "div_chat_transcript_reactions", + "div", + 1 + ); + reactionsToken.attrs = [["class", "chat-transcript-reactions"]]; + + reactions.split(";").forEach((reaction) => { + const split = reaction.split(":"); + const emoji = split[0]; + const usernames = split[1].split(","); + + const reactToken = state.push("div_chat_transcript_reaction", "div", 1); + reactToken.attrs = [["class", "chat-transcript-reaction"]]; + const emojiToken = state.push("html_inline", "", 0); + if (!emojiHtmlCache[emoji]) { + emojiHtmlCache[emoji] = performEmojiUnescape(`:${emoji}:`, { + getURL: options.getURL, + emojiSet: options.emojiSet, + emojiCDNUrl: options.emojiCDNUrl, + enableEmojiShortcuts: options.enableEmojiShortcuts, + inlineEmoji: options.inlineEmoji, + lazy: true, + }); + } + emojiToken.content = `${ + emojiHtmlCache[emoji] + } ${usernames.length.toString()}`; + state.push("div_chat_transcript_reaction", "div", -1); + }); + state.push("div_chat_transcript_reactions", "div", -1); + } + + state.push("div_chat_transcript_messages", "div", -1); + state.push("div_chat_transcript_wrap", "div", -1); + return true; + }, +}; + +export function setup(helper) { + helper.allowList([ + "div[class=chat-transcript]", + "div[class=chat-transcript chat-transcript-chained]", + "div.chat-transcript-meta", + "div.chat-transcript-user", + "div.chat-transcript-username", + "div.chat-transcript-user-avatar", + "div.chat-transcript-messages", + "div.chat-transcript-datetime", + "div.chat-transcript-reactions", + "div.chat-transcript-reaction", + "span[title]", + "div[data-message-id]", + "div[data-channel-name]", + "div[data-channel-id]", + "div[data-username]", + "div[data-datetime]", + "a.chat-transcript-channel", + ]); + + helper.registerOptions((opts, siteSettings) => { + opts.features["chat-transcript"] = !!siteSettings.chat_enabled; + }); + + helper.registerPlugin((md) => { + if (md.options.discourse.features["chat-transcript"]) { + md.block.bbcode.ruler.push("chat-transcript", chatTranscriptRule); + } + }); + + helper.buildCookFunction((opts, generateCookFunction) => { + if (!opts.discourse.additionalOptions) { + return; + } + + const chatAdditionalOpts = opts.discourse.additionalOptions.chat; + + // we need to be able to quote images from chat, but the image rule is usually + // banned for chat messages + const markdownItRules = + chatAdditionalOpts.limited_pretty_text_markdown_rules.concat("image"); + + generateCookFunction( + { + featuresOverride: chatAdditionalOpts.limited_pretty_text_features, + markdownItRules, + }, + (customCookFn) => { + customMarkdownCookFn = customCookFn; + } + ); + }); +} diff --git a/plugins/chat/assets/stylesheets/colors.scss b/plugins/chat/assets/stylesheets/colors.scss new file mode 100644 index 0000000000..e0e79e7d11 --- /dev/null +++ b/plugins/chat/assets/stylesheets/colors.scss @@ -0,0 +1,3 @@ +:root { + --chat-skeleton-animation-rgb: #{hexToRGB($primary-50)}; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-browse.scss b/plugins/chat/assets/stylesheets/common/chat-browse.scss new file mode 100644 index 0000000000..cb529249ba --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-browse.scss @@ -0,0 +1,117 @@ +.chat-browse-view { + position: relative; + height: calc(100vh - var(--header-offset)); + overflow-y: scroll; + @include chat-scrollbar(var(--secondary)); + + @include breakpoint(mobile-large) { + padding-right: 1rem; //fix for different scroll behaviour on mobile where overflow-y:scroll acts like auto + } + + &__header { + display: flex; + align-items: center; + justify-content: flex-start; + margin-bottom: 1em; + + .new-channel-btn { + margin-left: auto; + } + } + + &__title { + box-sizing: border-box; + margin-bottom: 0; + } + + &__content_wrapper { + margin: 2rem 0 1rem 1rem; + box-sizing: border-box; + + @include breakpoint(tablet) { + margin-top: 1rem; + } + } + + &__cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 2.5rem; + + @include breakpoint(tablet) { + grid-template-columns: repeat(1, 1fr); + grid-gap: 1.5rem; + } + } + + &__actions { + display: flex; + justify-content: space-between; + align-items: end; + margin: 0 0 0 1rem; + + @include breakpoint(tablet) { + flex-direction: column; + + .dc-filter-input-container { + margin-top: 1rem; + } + + .dc-filter-input-container, + nav { + width: 100%; + } + } + } + + &__filters { + display: flex; + align-items: center; + margin: 0; + &:before { + content: none; //there is a strange thing applied on nav-pills and this resets it + } + + @include breakpoint(mobile-large) { + justify-content: space-between; + } + } + + &__filter { + display: inline; + margin-right: 1em; + + &:last-of-type { + margin-right: 0; + } + + @include breakpoint(mobile-large) { + margin: 0; + } + } + + &__filter-link, + &__filter-link:visited { + color: var(--primary); + font-size: var(--font-up-2); + padding: 0 0.25rem; + + @include breakpoint(tablet) { + font-size: var(--font-up-1); + } + } + + .chat-channel-card { + .chat-channel-card__leave-btn { + padding: 0; + &:hover, + &:focus { + background: none; + } + + &:focus { + @include default-focus; + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss new file mode 100644 index 0000000000..d8b47c3760 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss @@ -0,0 +1,116 @@ +.chat-channel-card { + display: flex; + flex-direction: column; + position: relative; + padding: 1.25rem; + background-color: var(--primary-very-low); + border-radius: 5px; + min-height: 0; + min-width: 0; + border-left: 5px solid transparent; + + &__header { + align-items: center; + display: flex; + } + + &__header-actions { + align-items: center; + display: flex; + margin-left: auto; + } + + &__read-restricted { + color: var(--primary-medium); + font-size: var(--font-down-4); + padding: 0 0.25rem; + } + + &__description { + @include line-clamp(2); + color: var(--primary-medium); + padding-top: 1rem; + + .-closed &, + .-archived & { + opacity: 0.5; + } + } + + &__setting { + svg { + fill: var(--primary-medium); + } + + .-archived & { + opacity: 0.5; + } + } + + &__members { + margin-left: auto; + font-size: 0.875rem; + } + + &__name { + @include ellipsis; + } + + &__name-container { + display: flex; + align-items: center; + color: var(--primary); + font-size: 1.15rem; + text-decoration: none; + min-width: 0; + margin-right: 2rem; + + &:visited, + &:hover { + color: var(--primary); + } + + .-closed &, + .-archived & { + opacity: 0.5; + } + } + + &__tag { + border-radius: 10px; + margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + text-transform: uppercase; + font-size: 0.7rem; + font-weight: bold; + background-color: var(--secondary); + + &.-muted { + color: var(--primary-medium); + border: 1px solid var(--primary-low-mid); + + & + .chat-channel-card__setting { + margin-left: 0.5rem; + } + } + &.-joined { + color: var(--success); + border: 1px solid var(--success); + } + + &.-closed, + &.-archived { + display: inline-block; + padding-left: 0; + margin-bottom: 0.5rem; + } + } + + &__cta { + flex-grow: 1; + display: flex; + justify-content: space-between; + align-items: end; + margin-top: 1rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss new file mode 100644 index 0000000000..2a6c5c1fd1 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss @@ -0,0 +1,151 @@ +.channel-info { + display: flex; + flex-direction: column; + height: 100%; +} + +// Info header +.channel-info-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + box-sizing: border-box; +} + +.channel-info-header__title { + font-size: var(--font-up-2); + margin: 0; +} + +// About view +.channel-info-about-view__title-input { + width: 100%; +} + +.channel-info-about-view__description-input { + height: 150px; + width: 100%; +} + +.channel-info-about-view__description__helper-text { + color: var(--primary-medium); +} + +// Settings view +.channel-settings-view__saved { + color: var(--success); + padding-left: 0.5rem; + + .d-icon-check { + margin-right: 0.25rem; + } +} + +.channel-settings-view__desktop-notification-level-selector, +.channel-settings-view__mobile-notification-level-selector, +.channel-settings-view__muted-selector { + width: 220px; +} + +.chat-form__btn.delete-btn { + .d-icon { + color: var(--danger); + } +} + +// Members list +.chat-tabs__memberships-count { + margin-left: 0.25em; +} + +.channel-members-view-wrapper { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + padding: 0 1rem; +} + +.channel-members-view__search-input-container { + display: flex; + align-items: center; + border: 1px solid var(--primary-medium); + + &.is-focused { + border: 1px solid var(--tertiary); + } + + .d-icon { + padding: 0.5rem; + color: var(--primary-medium); + } +} + +input.channel-members-view__search-input { + border: 0; + margin: 0; + outline: 0; + width: 100%; + + &:focus { + border: 0; + outline: 0; + } +} + +.channel-members-view__status { + display: flex; + align-items: center; +} + +.channel-members-view__list-container { + display: flex; + flex-direction: column; + margin-top: 1em; + box-sizing: border-box; + min-height: 1px; + overflow-y: auto; + height: 100%; + @include chat-scrollbar(var(--secondary)); +} + +.channel-members-view__list-item { + display: flex; + align-items: center; + padding: 0.5rem 0 0.5rem 1px; + + &:hover { + background-color: var(--tertiary-very-low); + border-radius: 0.25rem; + } + + .chat-user-avatar { + margin-right: 0.5rem; + } +} + +// Channel info edit title modal +.chat-channel-edit-title-modal__title-input { + display: flex; + margin: 0; +} + +.chat-channel-edit-title-modal__description { + display: flex; + padding: 0.5rem 0; + color: var(--primary-medium); +} + +// Channel info edit description modal +.chat-channel-edit-description-modal__description-input { + display: flex; + margin: 0; + min-height: 200px; +} + +.chat-channel-edit-description-modal__description { + display: flex; + padding: 0.75rem 0 0.5rem; + color: var(--primary-medium); +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss new file mode 100644 index 0000000000..175f7402e2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss @@ -0,0 +1,39 @@ +.chat-channel-preview-card { + margin: 1rem 1rem 2rem 1rem; + padding: 1.5rem 1rem; + background-color: var(--primary-50); + display: flex; + flex-direction: column; + align-items: center; + + &.-no-description { + .chat-channel-title { + margin-bottom: 1.5rem; + } + } + + &__description { + color: var(--primary-600); + text-align: center; + } + + .chat-channel-title__name { + font-size: var(--font-up-2); + } + + &__join-channel-btn { + font-size: var(--font-up-2); + border: 1px solid transparent; + border-radius: 0.25rem; + line-height: normal; + box-sizing: border-box; + padding: 0.5em 0.65em; + font-weight: normal; + cursor: pointer; + } + + &__browse-all { + margin-top: 1rem; + font-size: var(--font-down-1); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss new file mode 100644 index 0000000000..c5935c249a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss @@ -0,0 +1,63 @@ +:root { + --chat-channel-selector-input-height: 40px; +} + +.chat-channel-selector-modal-modal.modal.in { + animation: none; +} + +#chat-channel-selector-modal-inner { + width: 500px; + height: 350px; + + .chat-channel-selector-input-container { + position: relative; + + .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--primary-high); + } + + #chat-channel-selector-input { + width: 100%; + height: var(--chat-channel-selector-input-height); + padding-left: 30px; + margin: 0 0 1px; + } + } + .channels { + height: calc(100% - var(--chat-channel-selector-input-height)); + overflow: auto; + + .no-channels-notice { + padding: 0.5em; + } + + .chat-channel-selection-row { + display: flex; + align-items: center; + height: 2.5em; + padding-left: 0.5em; + + &.focused { + background: var(--primary-low); + } + .username { + margin-left: 0.5em; + } + .chat-channel-title { + color: var(--primary-high); + } + + .chat-channel-unread-indicator { + border: none; + margin-left: 0.5em; + height: 12px; + width: 12px; + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-title.scss b/plugins/chat/assets/stylesheets/common/chat-channel-title.scss new file mode 100644 index 0000000000..f5043f4d79 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-title.scss @@ -0,0 +1,131 @@ +.chat-channel-title-wrapper { + display: flex; + align-items: center; +} + +.chat-channel-title { + display: flex; + align-items: center; + + .category-chat-private .d-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 5px; + padding: 2px 2px 3px; + color: var(--primary-high); + height: 0.5em; + width: 0.5em; + left: calc(0.6125em + 3px); + top: -4px; + } + + .chat-name, + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + &__usernames, + .dm-usernames { + @include ellipsis; + font-size: var(--font-0); + margin: 0; + + .emoji { + height: 1.2em; + vertical-align: text-bottom; + width: 1.2em; + } + } + + .d-icon-lock { + margin-right: 0.25em; + } + + .topic-chat-icon { + color: var(--primary-medium); + display: flex; + } + + .chat-unread-count { + display: inline-block; + color: var(--secondary); + background-color: var(--tertiary-med-or-tertiary); + font-size: var(--font-down-2); + border-radius: 100%; + min-width: 1.4em; + min-height: 1.4em; + height: 1.4em; + width: 1.4em; + padding: 1px; + margin-left: 0.5rem; + text-align: center; + } +} + +.chat-channel-title__users-count { + display: flex; + border-radius: 50%; + background: rgba(var(--primary-rgb), 0.1); + width: 22px; + height: 22px; + box-sizing: border-box; + text-align: center; + font-weight: 700; + font-size: var(--font-down-1); + align-items: center; + padding: 0.25rem 0.5rem; + + & + .chat-channel-title__name { + margin-left: 0.5rem; + } +} + +.chat-channel-title__category-badge { + color: var(--primary-medium); + display: flex; + font-size: var(--font-up-1); + position: relative; + + + .chat-channel-title__name { + margin-left: 0.5rem; + } +} + +.chat-channel-title .chat-user-avatar { + font-size: var(--font-up-1); + + + .chat-channel-title__usernames { + margin-left: 0.5rem; + } +} + +.chat-channel-title__restricted-category-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 50%; + padding: 2px 2px 3px; + color: var(--primary-high); + height: 0.5rem; + width: 0.5rem; + right: -0.5rem; + top: -0.1rem; +} + +.chat-channel-title__category-title { + .emoji { + height: 1.2em; + vertical-align: text-bottom; + width: 1.2em; + } +} + +.chat-channel-title__name { + @include ellipsis; + font-size: var(--font-0); + color: var(--primary); +} + +.channel-info { + .chat-channel-title__name { + max-width: 100%; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss new file mode 100644 index 0000000000..870202a1e2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss @@ -0,0 +1,55 @@ +.chat-composer-dropdown { + margin-left: 0.2rem; + + .tippy-content { + padding: 0; + } +} + +.chat-composer-dropdown__trigger-btn { + padding: 5px; + border-radius: 100%; + background: var(--primary-med-or-secondary-high); + border: 1px solid transparent; + display: flex; + + .d-icon { + color: var(--secondary-very-high); + } + + &:focus { + border-color: var(--tertiary); + } + + .discourse-no-touch &:hover { + background: var(--primary-high); + .d-icon { + color: var(--primary-low); + } + } +} + +.chat-composer-dropdown__list { + padding: 0; + margin: 0; + list-style: none; + padding: 0.5rem; +} + +.chat-composer-dropdown__item { + padding-bottom: 0.25rem; + + &:last-child { + padding-bottom: 0; + } +} + +.chat-composer-dropdown__action-btn { + background: none; + width: 100%; + justify-content: flex-start; + + .d-icon { + color: var(--primary); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss b/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss new file mode 100644 index 0000000000..e1b019a315 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss @@ -0,0 +1,9 @@ +.chat-composer-inline-button { + border-radius: 6px; + width: 32px; + height: 32px; + + & + .chat-composer-inline-button { + margin-left: 0.25rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss new file mode 100644 index 0000000000..5d4b36303d --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss @@ -0,0 +1,71 @@ +.chat-composer-upload { + display: inline-flex; + height: 50px; + padding: 0.5rem; + border: 1px solid var(--primary-low-mid); + margin-right: 0.5em; + + &:last-child { + margin-right: 0; + } + + .preview { + width: 50px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 1em 0 0; + border-radius: 8px; + + .d-icon { + font-size: var(--font-up-6); + } + + .preview-img { + max-width: 100%; + max-height: 100%; + } + } + + .data { + display: flex; + flex-direction: column; + justify-content: center; + line-height: var(--line-height-medium); + font-size: var(--font-down-1); + color: var(--primary-high); + + .top-data, + .bottom-data { + display: flex; + align-items: center; + } + + .file-name { + display: inline-block; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 0.5em; + } + + .uploading, + .processing { + font-size: var(--font-down-2); + margin-right: 0.75em; + } + + .upload-progress { + width: 110px; + } + + .extension-pill { + background: var(--primary-low); + border-radius: 5px; + font-size: var(--font-down-2-rem); + padding: 0.1em 0.4em; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss new file mode 100644 index 0000000000..8050774913 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss @@ -0,0 +1,8 @@ +.chat-composer-uploads { + .chat-composer-uploads-container { + padding: 0.5rem 10px; + display: flex; + white-space: nowrap; + overflow-x: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss new file mode 100644 index 0000000000..9ae341f247 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -0,0 +1,140 @@ +.chat-composer-container { + display: flex; + flex-direction: column; + + #chat-full-page-uploader, + #chat-widget-uploader { + display: none; + } + + .drop-a-file { + display: none; + } +} + +.chat-composer { + display: flex; + align-items: center; + background-color: var(--secondary); + border: 1px solid var(--primary-low-mid); + border-radius: 5px; + padding: 0.15rem 0.25rem; + margin-top: 0.5rem; + + &.is-disabled { + background-color: var(--primary-low); + border: 1px solid var(--primary-low-mid); + } + + .send-btn { + padding: 0.4rem 0.5rem; + border: 1px solid transparent; + border-radius: 5px; + display: flex; + align-items: center; + + .d-icon { + color: var(--tertiary); + } + + &:disabled { + cursor: not-allowed; + + .d-icon { + color: var(--primary-low); + } + } + + &:not(:disabled) { + &:hover, + &:focus { + background: var(--tertiary); + .d-icon { + color: var(--secondary); + } + } + } + } + + &__close-emoji-picker-btn { + margin-left: 0.2rem; + padding: 5px !important; + border-radius: 100%; + background: var(--primary-med-or-secondary-high); + border: 1px solid transparent; + display: flex; + + .d-icon { + color: var(--secondary-very-high); + } + + &:focus { + border-color: var(--tertiary); + } + + .discourse-no-touch &:hover { + background: var(--primary-high); + .d-icon { + color: var(--primary-low); + } + } + } + + .chat-composer-input { + overflow-x: hidden; + width: 100%; + appearance: none; + outline: none; + border: 0; + resize: none; + max-height: 125px; + scrollbar-color: var(--primary-low-mid) transparent; + transition: scrollbar-color 0.2s ease-in-out; + background: none; + margin: 0; + padding: 0.25rem 0.5rem; + text-overflow: ellipsis; + + &:placeholder-shown, + &::placeholder { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--primary-low-mid); + border-radius: 6px; + border: 3px solid var(--secondary); + } + &:hover { + scrollbar-color: var(--primary-low-mid) transparent; + &::-webkit-scrollbar-thumb { + background-color: var(--primary-low-mid); + } + } + &::-webkit-scrollbar { + width: 12px; + } + } + + &__unreliable-network { + color: var(--danger); + padding: 0 0.5em; + } +} + +.chat-composer-message-details { + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--primary-low); + display: flex; + align-items: center; + @include ellipsis; + position: relative; + height: 100%; + max-height: calc(2em - 5px); + + .cancel-message-action { + margin-left: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss new file mode 100644 index 0000000000..6f295468c6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss @@ -0,0 +1,43 @@ +.full-page-chat.teams-sidebar-on { + .chat-draft { + grid-template-columns: 1fr; + } +} + +.chat-draft { + height: 100%; + min-height: 1px; + width: 100%; + display: flex; + flex-direction: column; + flex: 1; + + &-header { + display: flex; + align-items: center; + padding: 0.75em 10px; + border-bottom: 1px solid var(--primary-low); + + &__title { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0; + margin-left: 0.5rem; + font-size: var(--font-0); + font-weight: normal; + color: var(--primary); + @include ellipsis; + + .d-icon { + height: 1.5em; + width: 1.5em; + color: var(--quaternary); + } + } + } + + .chat-composer-container { + padding-bottom: 0.5em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-drawer.scss b/plugins/chat/assets/stylesheets/common/chat-drawer.scss new file mode 100644 index 0000000000..e996f96790 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss @@ -0,0 +1,225 @@ +body.composer-open .topic-chat-float-container { + bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator +} + +.topic-chat-float-container { + font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + // higher than timeline, lower than composer, lower than user card (bump up below) + z-index: z("usercard"); + position: fixed; + right: var(--composer-right, 20px); + left: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + pointer-events: none !important; + bottom: 0; + + > * { + pointer-events: auto; + } + + .no-channel-title { + font-weight: bold; + margin-left: 0.5rem; + } + + &.composer-draft-collapsed { + bottom: 40px; + } + + box-sizing: border-box; + max-height: 90vh; + padding-bottom: var(--composer-height, 0); + transition: all 100ms ease-in; + transition-property: bottom, padding-bottom; + + .channels-list { + .chat-channel-row { + padding: 0 0 0 0.5rem; + margin: 0 0.5rem 0.125rem 0.5rem; + border-radius: 0.25em; + } + + .chat-channel-unread-indicator { + left: 3px; + min-width: 8px; + width: 8px; + height: 8px; + border-radius: 7px; + top: calc(50% - 5px); + } + + .chat-channel-title { + padding: 0.5rem; + } + } +} + +.chat-drawer { + align-self: flex-end; +} + +.topic-chat-container { + background: var(--secondary); + border: 1px solid var(--primary-low); + border-bottom: 0; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.125); + box-sizing: border-box; + display: flex; + flex-direction: column; + + &.expanded { + max-height: $float-height; + height: calc(85vh - var(--composer-height, 0px)); + } + + .chat-live-pane { + height: 100%; + } +} + +.topic-chat-drawer-header__left-actions { + display: flex; + height: 100%; +} + +.topic-chat-drawer-header__right-actions { + display: flex; + height: 100%; + margin-left: auto; +} + +.topic-chat-drawer-header__top-line { + height: 2.5rem; + display: flex; + align-items: center; +} + +.topic-chat-drawer-header__bottom-line { + height: 1.5rem; + display: flex; + align-items: start; +} + +.topic-chat-drawer-header__title { + @include ellipsis; + display: flex; + flex-direction: column; + width: 100%; + font-weight: 700; + padding: 0 0.5rem; + cursor: pointer; + + .chat-channel-title { + padding: 0; + } +} + +.topic-chat-drawer-header { + box-sizing: border-box; + border-bottom: solid 1px var(--primary-low); + border-radius: 8px 8px 0 0; + background: var(--primary-very-low); + width: 100%; + display: flex; + align-items: flex-start; + + .btn { + height: 100%; + } + + .chat-channel-title { + font-weight: 700; + width: 100%; + + .chat-name, + .topic-chat-name, + .category-chat-name, + .dm-usernames { + color: var(--primary); + } + .category-chat-badge, + .topic-chat-badge { + display: flex; + justify-content: center; + align-content: center; + .d-icon:not(.d-icon-lock) { + width: 1.25em; + height: 1.25em; + } + } + .category-chat-private .d-icon { + background-color: var(--primary-very-low); + } + .badge-wrapper.bullet { + margin-right: 0px; + } + .dm-usernames { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .d-icon:not(.d-icon-hashtag) { + color: var(--primary-high); + } + .category-hashtag { + padding: 2px 4px; + } + } + + &__close-btn, + &__return-to-channels-btn, + &__full-screen-btn, + &__expand-btn { + max-height: 2.5rem; + height: 100%; + min-width: 40px; + width: 40px; + display: flex; + justify-content: center; + align-items: center; + + .d-icon { + color: var(--primary-low-mid); + } + + &:visited { + .d-icon { + color: var(--primary-low-mid); + } + } + + &:focus { + outline: none; + background: none; + + .d-icon { + background: none; + color: var(--primary-low-mid); + } + } + + &:hover { + .d-icon { + color: var(--primary-high); + } + } + } +} + +.topic-chat-drawer-content { + box-sizing: border-box; + height: 100%; + min-height: 1px; + padding-bottom: 0.25em; + + .channels-list .chat-channel-divider { + padding: 0.25rem 0.5rem 0.25rem 1rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss new file mode 100644 index 0000000000..bc61941f3a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss @@ -0,0 +1,234 @@ +.chat-emoji-picker { + border-top: 1px solid var(--primary-low); + transition: height 125ms ease; + display: flex; + flex-direction: column; + height: 300px; + overflow: hidden; + background: var(--secondary); + + .emoji { + padding: 6px; + width: 32px; + height: 32px; + image-rendering: -webkit-optimize-contrast; + cursor: pointer; + + &:hover, + &:focus { + background: var(--primary-very-low); + border-radius: 5px; + transform: scale(1.25); + } + } + + &__filter-container { + top: 0; + position: sticky; + background: var(--secondary); + display: flex; + height: 50px; + } + + &__filter { + width: 100%; + padding: 0.5rem; + margin: 0.25rem; + + input { + background: none; + width: 100%; + } + + .d-icon { + color: var(--primary-medium); + } + + &.dc-filter-input-container { + border-color: transparent; + background: var(--primary-very-low); + } + } + + &__scrollable-content { + height: 100%; + overflow-y: scroll; + text-transform: capitalize; + } + + &__no-reults { + padding: 1em; + } + + &__sections-nav { + top: 0; + position: sticky; + background: var(--secondary); + border-bottom: 1px solid var(--primary-low); + height: 50px; + display: flex; + align-items: center; + + &__indicator { + background: var(--tertiary); + height: 4px; + transition: transform 0.3s cubic-bezier(0.1, 0.82, 0.25, 1); + position: absolute; + bottom: 0; + } + } + + &__section-btn { + padding: 0.25rem; + + &:hover { + .emoji { + background: none; + } + } + + &:focus, + &.active { + background: none; + } + + .emoji { + width: 21px; + height: 21px; + } + } + + &__section-emojis { + padding: 0.5rem; + } + + &__backdrop { + height: 100%; + background: rgba(0, 0, 0, 0.75); + bottom: 0; + top: 0; + left: 0; + right: 0; + } + + &__section-title { + margin: 0; + padding: 0.5rem; + color: var(--primary-very-high); + font-size: var(--font-up-0); + font-weight: 700; + background: rgba(var(--secondary-rgb), 0.95); + position: sticky; + top: 0; + z-index: 1; + width: 100%; + box-sizing: border-box; + } + + &__fitzpatrick-modifier-btn { + min-width: 21px; + width: 21px; + height: 21px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + margin-right: 0.5rem; + border: 0; + border-radius: 5px; + + .d-icon { + visibility: hidden; + } + + &.current { + min-width: 25px; + width: 25px; + height: 25px; + } + + &:not(.current):hover, + &:not(.current):focus { + .d-icon { + visibility: visible; + color: white; + filter: drop-shadow(0.5px 1.5px 0 rgba(0, 0, 0, 0.3)); + } + } + + &:last-child { + margin-right: 0; + } + + &.t1 { + background: #ffcc4d; + } + &.t2 { + background: #f7dece; + } + &.t3 { + background: #f3d2a2; + } + &.t4 { + background: #d5ab88; + } + &.t5 { + background: #af7e57; + } + &.t6 { + background: #7c533e; + } + + @media (forced-colors: active) { + forced-color-adjust: none; + } + } + + &__fitzpatrick-scale { + display: flex; + align-items: center; + } +} + +.chat-message-emoji-picker-anchor { + z-index: z("header") + 1; + + .chat-emoji-picker { + border: 1px solid var(--primary-low); + width: 320px; + + .emoji { + width: 22px; + height: 22px; + } + } +} + +.mobile-view { + .chat-message-emoji-picker-anchor.-opened { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-shadow: shadowcreatePopper("card"); + + .chat-emoji-picker { + height: 50vh; + width: 100%; + } + } +} + +.chat-composer-container.with-emoji-picker { + background: var(--primary-very-low); + + .chat-emoji-picker { + border-bottom: 1px solid var(--primary-low); + + &.closing { + height: 0; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-form.scss b/plugins/chat/assets/stylesheets/common/chat-form.scss new file mode 100644 index 0000000000..2e3dd0ba5a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-form.scss @@ -0,0 +1,43 @@ +.chat-form__section { + padding: 1.5rem 1rem; + border-bottom: 1px solid var(--primary-low); + + &:first-child { + padding-top: 0; + } + + &:last-child { + margin-bottom: 0; + border-bottom: none; + } +} + +.chat-form__field { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.chat-form__btn { + border: 0; + background: none; + padding: 0.25rem 0; + margin: 0; +} + +.chat-form__label { + font-weight: 700; + display: flex; + align-items: center; +} + +.chat-form__label-actions { + margin-left: auto; + + .btn-text { + color: var(--tertiary); + font-size: var(--font-down-1); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss new file mode 100644 index 0000000000..356cf39dfa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -0,0 +1,176 @@ +.chat-message-actions-desktop-anchor { + position: relative; + z-index: z("dropdown"); +} + +.chat-message-actions { + .chat-message-reaction { + @include chat-reaction; + + &:not(.show) { + display: none; + } + } +} + +.chat-message-actions-container { + @include unselectable; + position: relative; +} + +.chat-message-actions { + border-radius: 0.25em; + background-color: var(--secondary); + display: flex; + box-shadow: 0 0.75px 0px rgba(0, 0, 0, 0.15); + + .emoji-picker-anchor { + position: absolute; + height: 34px; + } + + .link-to-message-btn { + .d-icon { + transition: all 0.25s ease-in-out; + } + + &.copied { + .d-icon { + transform: scale(1.1); + color: var(--tertiary); + } + } + } + + .react-btn, + .reply-btn, + .bookmark-btn { + margin-right: -1px; + padding: 0.5em 0; + width: 2.5em; + transition: background 0.2s, border-color 0.2s; + + &:focus { + .d-icon { + color: var(--primary); + } + } + + &:first-child { + border-bottom-left-radius: 0.25em; + border-top-left-radius: 0.25em; + } + + &:first-child:not(:hover) { + border-color: var(--primary-low); + border-right-color: transparent; + } + + .d-icon { + color: var(--primary-medium); + + &.bookmark-icon__bookmarked { + color: var(--tertiary); + } + } + } + + .more-buttons.dropdown-select-box { + .select-kit-header { + background: none; + border: 1px solid var(--primary-low); + border-left-color: transparent; + border-radius: 0 0.25em 0.25em 0; + padding: 0.5em 0; + width: 2.5em; + transition: background 0.2s, border-color 0.2s; + + &:focus { + border-color: var(--primary-low); + border-left-color: transparent; + + .select-kit-header-wrapper .d-icon { + color: var(--primary); + } + } + + .select-kit-header-wrapper { + justify-content: center; + + .d-icon { + color: var(--primary-medium); + margin: 0; + } + } + + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + + .select-kit-header-wrapper { + .d-icon { + color: var(--primary); + } + } + } + } + + .select-kit-body { + padding: 0.5rem; + box-shadow: shadow("card"); + border: 1px solid var(--primary-low); + } + + .select-kit-row { + .texts .name { + font-size: var(--font-0); + font-weight: 500; + } + + .icons .d-icon { + font-size: var(--font-0); + color: var(--primary-medium); + } + } + } + + .chat-message-reaction { + align-items: center; + border-radius: 0; + border-left-color: transparent; + border-right-color: transparent; + box-sizing: border-box; + font-size: var(--font-0); + justify-content: center; + margin: 0; + margin-right: -1px; + padding: 0.5em 0; + width: 2.5em; + + &:focus { + background: var(--primary-low); + outline: none; + } + + &:first-child { + border-bottom-left-radius: 0.25em; + border-left-color: var(--primary-low); + border-top-left-radius: 0.25em; + } + + &.reacted { + border-left-color: var(--tertiary-medium); + z-index: 1; + + &:focus { + background: var(--tertiary-low); + } + } + + .emoji { + height: 15px; + width: auto; + margin: 0; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss new file mode 100644 index 0000000000..c18d6aa83a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss @@ -0,0 +1,46 @@ +$max_video_height: 150px; + +.chat-message-collapser { + .chat-message-collapser-header { + display: flex; + align-items: center; + } + + .chat-message-collapser-header + div p { + margin: 0; + } + + .chat-img-upload, + .chat-other-upload, + .chat-video-upload, + .chat-message-collapser-header + div p img { + margin-top: 0.25em; + margin-bottom: 0.5em; + } + + .chat-video-upload { + height: $max_video_height; + width: calc(#{$max_video_height} / 9 * 16); + } + + .chat-message-collapser-link-small { + font-size: 0.75em; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .chat-message-collapser-button { + background: none; + padding: unset; + margin-left: 0.5em; + + &:hover { + background: none; + + .d-icon { + color: var(--primary); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-images.scss b/plugins/chat/assets/stylesheets/common/chat-message-images.scss new file mode 100644 index 0000000000..2805ed5cac --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-images.scss @@ -0,0 +1,27 @@ +$max_image_height: 150px; + +.chat-message { + // append selectors to set images to a + // max height of $max_image_height + .chat-message-collapser .onebox img:not(.ytp-thumbnail-image), + .chat-message-collapser img.onebox, + .chat-message-collapser .chat-uploads img, + .chat-message-collapser p img, + aside.onebox .onebox-body .aspect-image-full-size, + aside.onebox .onebox-body .aspect-image-full-size img { + object-fit: contain; + max-height: $max_image_height; + max-width: 100%; + width: unset; + overflow: hidden; + } + + .chat-message-collapser + .chat-message-collapser-header + + div + .chat-message-collapser-youtube { + object-fit: contain; + height: $max_image_height; + width: calc(#{$max_image_height} / 9 * 16); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-info.scss b/plugins/chat/assets/stylesheets/common/chat-message-info.scss new file mode 100644 index 0000000000..fb82db5baa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-info.scss @@ -0,0 +1,74 @@ +.chat-message-info { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.chat-message-info__username { + display: inline-flex; + align-items: center; + + & + .chat-message-info__bot-indicator, + & + .chat-message-info__date { + margin-left: 0.25em; + } +} + +.chat-message-info__username__name { + color: var(--secondary-low); + font-weight: 700; + @include ellipsis; + max-width: 180px; +} + +.chat-message-info__bot-indicator { + text-transform: uppercase; + padding: 0.25em; + background: var(--primary-low); + border-radius: 3px; + font-size: var(--font-down-2); + + & + .chat-message-info__date { + margin-left: 0.25em; + } +} + +.chat-message-info__date { + color: var(--primary-high); + font-size: var(--font-down-1); + + &:hover, + &:focus { + .chat-time { + color: var(--primary); + } + } + + & + .chat-message-info__flag { + margin-left: 0.25em; + } +} + +.chat-message-info__flag { + color: var(--secondary-medium); +} + +.chat-message-info__bookmark { + .d-icon-discourse-bookmark-clock, + .d-icon-bookmark { + color: var(--primary-low-mid); + font-size: var(--font-down-2); + margin-left: 0.5em; + } +} + +.chat-message-info__status { + display: flex; + margin-left: 0.2em; + margin-right: 0.2em; + + .emoji { + width: 16px; + height: 16px; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss new file mode 100644 index 0000000000..955082fe5f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss @@ -0,0 +1,33 @@ +.chat-message-left-gutter { + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + width: var(--message-left-width); +} + +.chat-message-left-gutter__date { + color: var(--primary-high); + font-size: var(--font-down-1); + + &:hover, + &:focus { + .chat-time { + color: var(--primary); + } + } +} + +.chat-message-left-gutter__flag { + color: var(--secondary-medium); + padding-left: calc(50% - 15px); +} + +.chat-message-left-gutter__bookmark { + .d-icon-discourse-bookmark-clock, + .d-icon-bookmark { + color: var(--primary-low-mid); + font-size: var(--font-down-2); + margin-left: 0.5em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss new file mode 100644 index 0000000000..e918d0c850 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss @@ -0,0 +1,42 @@ +.chat-message-separator { + @include unselectable; + margin: 0.25rem 0 0.25rem 1rem; + display: flex; + font-size: var(--font-down-1); + position: relative; + transform: translateZ(0); + position: relative; + + &.new-message { + color: var(--danger-medium); + + .divider { + background-color: var(--danger-medium); + } + } + + &.first-daily-message { + .text { + color: var(--secondary-low); + font-weight: 600; + } + + .divider { + background-color: var(--secondary-high); + } + } + + .text { + margin: 0 auto; + padding: 0 0.75rem; + z-index: 1; + background: var(--secondary); + } + + .divider { + position: absolute; + width: 100%; + height: 1px; + top: 50%; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss new file mode 100644 index 0000000000..2778ce8190 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -0,0 +1,385 @@ +.chat-message-deleted, +.chat-message-hidden { + margin-left: calc(var(--message-left-width) + 0.75em); + padding: 0; + + .chat-message-expand { + color: var(--primary-low-mid); + padding: 0.25em; + + &:hover { + background: inherit; + color: inherit; + } + } +} + +@mixin chat-reaction { + align-items: center; + display: inline-flex; + padding: 0.3em 0.6em; + margin: 1px 0.25em 1px 0; + font-size: var(--font-down-2); + border-radius: 4px; + border: 1px solid var(--primary-low); + background: transparent; + cursor: pointer; + user-select: none; + transition: background 0.2s, border-color 0.2s; + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + + &:not(.reacted) { + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + } + } + + .emoji { + height: 15px; + margin-right: 4px; + width: auto; + } +} + +.chat-message { + align-items: flex-start; + padding: 0.25em 0.5em 0.25em 0.75em; + background-color: var(--secondary); + display: flex; + min-width: 0; + + .chat-message-reaction { + @include chat-reaction; + + &:not(.show) { + display: none; + } + } + + &.chat-action { + background-color: var(--highlight-medium); + } + + &.errored { + color: var(--primary-medium); + } + + &.deleted { + background-color: var(--danger-low); + } + + .not-mobile-device &.deleted:hover { + background-color: var(--danger-hover); + } + + &.transition-slow { + transition: 2s linear background-color; + } + + &.user-info-hidden { + .chat-time { + color: var(--secondary-medium); + flex-shrink: 0; + font-size: var(--font-down-2); + margin-top: 0.4em; + display: none; + width: var(--message-left-width); + } + } + + &.is-reply { + display: grid; + grid-template-columns: var(--message-left-width) 1fr; + grid-template-rows: 30px auto; + grid-template-areas: + "replyto replyto" + "avatar message"; + + .chat-user-avatar { + grid-area: avatar; + } + + .chat-message-content { + grid-area: message; + } + } + + .chat-message-content { + display: flex; + flex-direction: column; + flex-grow: 1; + word-break: break-word; + overflow-wrap: break-word; + min-width: 0; + } + + .chat-message-text { + min-width: 0; + width: 100%; + + code { + box-sizing: border-box; + font-size: var(--font-down-1); + width: 100%; + } + + .mention.highlighted { + background: var(--tertiary-low); + color: var(--primary); + } + + .valid-mention { + padding: 0 4px 1px; + border-radius: 8px; + display: inline-block; + } + + img.ytp-thumbnail-image { + height: 100%; + max-height: unset; + + &:hover { + border-radius: 0; + } + } + + // Automatic aspect-ratio mapping https://developer.mozilla.org/en-US/docs/Web/Media/images/aspect_ratio_mapping + p img:not(.emoji) { + max-width: 100%; + height: auto; + } + + ul, + ol { + padding-left: 1.25em; + } + } + + .chat-message-edited { + display: inline-block; + color: var(--primary-medium); + font-size: var(--font-down-2); + } + + .chat-message-reaction-list, + .chat-transcript-reactions { + @include unselectable; + margin-top: 0.25em; + display: flex; + flex-wrap: wrap; + + .reaction-users-list { + position: absolute; + top: -2px; + transform: translateY(-100%); + border: 1px solid var(--primary-low); + border-radius: 6px; + padding: 0.5em; + background: var(--primary-very-low); + max-width: 300px; + z-index: 3; + } + + .chat-message-react-btn { + vertical-align: top; + padding: 0em 0.25em; + background: none; + border: none; + + .d-icon { + color: var(--primary-high); + } + + &:hover { + .d-icon { + color: var(--primary); + } + } + } + } + + .chat-send-error { + color: var(--danger-medium); + } + + .chat-message-mention-warning { + position: relative; + margin-top: 0.25em; + font-size: var(--font-down-1); + + .dismiss-mention-warning { + position: absolute; + top: 5px; + right: 5px; + cursor: pointer; + } + + .cannot-see, + .without-membership { + margin: 0.25em 0; + } + + .invite-link { + color: var(--tertiary); + cursor: pointer; + } + } + + .chat-message-avatar .chat-user-avatar .chat-user-avatar-container .avatar, + .chat-emoji-avatar .chat-emoji-avatar-container { + width: 28px; + height: 28px; + } +} + +.chat-message-container.highlighted .chat-message { + background-color: var(--tertiary-low) !important; +} + +.chat-messages-container { + .not-mobile-device & .chat-message:hover, + .chat-message.chat-message-selected { + background: var(--primary-very-low); + } + + .chat-message.chat-message-bookmarked { + background: var(--highlight-low); + } + + .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { + display: none; + } + + .not-mobile-device & .chat-message:hover { + .chat-message-reaction-list .chat-message-react-btn { + display: inline-block; + } + } +} + +.chat-message-flagged { + display: inline-block; + color: var(--danger); + height: 100%; + padding: 0 0.3em; + cursor: pointer; + + .flag-count, + .d-icon { + color: var(--danger); + } +} + +.chat-action-text { + font-style: italic; +} + +.chat-message-container.is-hovered, +.chat-message.chat-message-selected { + background: var(--primary-very-low); +} + +.chat-message.chat-message-bookmarked { + background: var(--highlight-low); +} + +.has-full-page-chat .chat-message .onebox:not(img), +.topic-chat-float-container .chat-message .onebox { + margin: 0.5em 0; + border-width: 2px; + + header { + margin-bottom: 0.5em; + } + + h3 a, + h4 a { + font-size: 14px; + } + + pre { + display: flex; + max-height: 150px; + } + + p { + overflow: hidden; + } +} + +.topic-chat-float-container .chat-message .onebox { + width: 85%; + border: 2px solid var(--primary-low); + + header { + margin-bottom: 0.5em; + } + + .onebox-body { + grid-template-rows: auto auto auto; + overflow: auto; + } + + h3 { + @include line-clamp(2); + font-weight: 500; + font-size: var(--font-down-1); + } + + p { + display: none; + } +} + +.chat-message-reaction { + > * { + pointer-events: none; + } +} + +.retry-staged-message-btn { + padding: 0.5em 0; + background: none; + + &:hover, + &:focus, + &:active { + background: none !important; + } + + &:focus .retry-staged-message-btn__action { + text-decoration: underline; + } + + .d-icon, + &__title, + &:hover .d-icon { + color: var(--danger) !important; + font-size: var(--font-down-1); + } + + .d-icon { + margin-right: 0.25em !important; + } + + &__action { + color: var(--tertiary); + font-size: var(--font-down-1); + margin-left: 0.25em; + + &:hover { + color: var(--tertiary-high); + text-decoration: underline; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-onebox.scss b/plugins/chat/assets/stylesheets/common/chat-onebox.scss new file mode 100644 index 0000000000..39250f62b2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-onebox.scss @@ -0,0 +1,34 @@ +.chat-onebox { + .chat-onebox-body { + .chat-onebox-title { + margin-bottom: 3px; + } + + .chat-onebox-description { + color: var(--primary-medium); + } + + .chat-onebox-members-count { + color: var(--primary-medium); + margin-top: 1em; + margin-bottom: 3px; + } + + .chat-onebox-members { + align-items: center; + color: var(--primary-medium); + display: flex; + + .avatar { + aspect-ratio: 30 / 30; + margin-right: 0.25rem; + } + } + } +} + +.chat-transcript { + .chat-transcript-user-avatar .avatar { + aspect-ratio: 20 / 20; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-reply.scss b/plugins/chat/assets/stylesheets/common/chat-reply.scss new file mode 100644 index 0000000000..765a04d957 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-reply.scss @@ -0,0 +1,59 @@ +.chat-reply { + display: contents; + align-items: center; + box-sizing: border-box; + font-size: var(--font-down-1); + padding-left: 0.5em; + height: 100%; + width: 100%; + white-space: nowrap; + + .d-icon { + color: var(--primary-low-mid); + } + + .chat-user-presence-flair { + width: 8px; + height: 8px; + right: -1px; + bottom: -1px; + } + + .avatar { + width: 20px; + height: 20px; + } + + .chat-user-avatar { + padding: 0 0.5rem; + } + + .d-icon { + color: var(--primary-low-mid); + } + + &.is-direct-reply { + display: flex; + cursor: pointer; + grid-area: replyto; + } +} + +.chat-reply__excerpt { + @include ellipsis; + color: var(--primary-high); + + > * { + margin-top: 0; + display: inline-block; + } + > p { + margin-top: 0.35em; + } +} + +.chat-reply__username { + @include ellipsis; + font-weight: 700; + padding: 0 0.5em 0 0; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss new file mode 100644 index 0000000000..3d93fc4457 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss @@ -0,0 +1,47 @@ +.chat-replying-indicator-container { + padding: 0 0.5rem; +} + +.chat-replying-indicator { + color: var(--primary-medium); + display: inline-flex; + font-size: var(--font-down-2); + padding-bottom: unquote("max(0px, 0.5rem - env(safe-area-inset-bottom, 0))"); + + &:before { + // unicode zero width space character + // Ensures the span height is consistent even when empty + content: "\200b"; + } + + .chat-replying-indicator__text { + display: inline-flex; + } + + .chat-replying-indicator__wave { + flex: 0 0 auto; + display: inline-flex; + + .chat-replying-indicator__dot { + display: inline-block; + animation: chat-replying-indicator__wave 1.8s linear infinite; + &:nth-child(2) { + animation-delay: -1.6s; + } + &:nth-child(3) { + animation-delay: -1.4s; + } + } + + @keyframes chat-replying-indicator__wave { + 0%, + 60%, + 100% { + transform: initial; + } + 30% { + transform: translateY(-0.2em); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss b/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss new file mode 100644 index 0000000000..d1aeaaf3a9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss @@ -0,0 +1,35 @@ +.chat-retention-reminder { + display: flex; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + align-items: center; + justify-content: space-between; + background: var(--tertiary-low); + padding: 0.5em 0 0.5em 1em; + font-size: var(--font-down-1); + color: var(--primary); + z-index: 10; + min-width: 280px; + + .btn-flat.dismiss-btn { + margin-left: 0.25em; + color: var(--primary-medium); + + &:hover, + &:focus { + background-color: transparent; + .d-icon { + color: var(--primary); + } + } + .d-icon { + color: var(--primary-medium); + } + } +} + +.full-page-chat .chat-retention-reminder { + top: 4rem; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss new file mode 100644 index 0000000000..2349f67e10 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss @@ -0,0 +1,39 @@ +.chat-selection-management { + border-top: 1px solid var(--primary-low); + display: flex; + gap: 0.5rem; + padding: 0.5rem; + + .topic-chat-drawer-content & { + flex-direction: column; + } + + .chat-selection-management-buttons { + display: flex; + gap: 0.5rem; + + .topic-chat-drawer-content & { + flex-direction: column; + width: 100%; + } + } + + .chat-selection-message { + animation: chat-quote-message-background-fade-highlight 2s ease-out 3s; + animation-fill-mode: forwards; + background-color: var(--success-low); + color: var(--primary); + flex: 1; + line-height: normal; + padding: 0.5rem 0.65rem; + } + + @keyframes chat-quote-message-background-fade-highlight { + 0% { + } + 100% { + background-color: transparent; + color: transparent; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss new file mode 100644 index 0000000000..36beaa4667 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss @@ -0,0 +1,140 @@ +$radius: 10px; + +.chat-skeleton { + height: auto; + + &__header { + display: flex; + align-items: center; + width: 100%; + padding: 1em; + border-bottom: 1px solid var(--primary-100); + box-sizing: border-box; + } + + &__header-img { + background-color: var(--primary-100); + border-radius: 50%; + width: 20px; + height: 20px; + margin-right: 0.5rem; + } + + &__header-name { + background-color: var(--primary-100); + width: 70px; + height: 18px; + border-radius: $radius; + } + + &__body { + padding: 1em; + } + + &__message { + display: grid; + grid-template: + "avatar poster" + "avatar content" + ". content"; + grid-template-columns: auto 1fr; + + &:not(:first-of-type):not(:last-of-type) { + margin-top: 1.5em; + margin-bottom: 1.5em; + } + } + + &__message-avatar { + grid-area: avatar; + width: 30px; + height: 30px; + border-radius: 50%; + margin-right: 0.5rem; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + } + + &__message-poster { + grid-area: poster; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + width: 70px; + height: 20px; + border-radius: $radius; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + } + + &__message-content { + grid-area: content; + width: 100%; + } + &__message-msg { + height: 13px; + border-radius: $radius; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + + &.-line1 { + margin-top: 0.5rem; + margin-bottom: 0.5em; + } + + &.-small { + width: 35%; + } + + &.-medium { + width: 60%; + } + + &.-large { + width: 85%; + } + } + + &.-animation { + position: relative; + overflow: hidden; + + &::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + rgba(var(--chat-skeleton-animation-rgb), 0) 0, + rgba(var(--chat-skeleton-animation-rgb), 0.2) 20%, + rgba(var(--chat-skeleton-animation-rgb), 0.5) 60%, + rgba(var(--chat-skeleton-animation-rgb), 0) + ); + animation: shimmer 1.5s infinite; + content: ""; + } + + @keyframes shimmer { + 100% { + transform: translateX(100%); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-tabs.scss b/plugins/chat/assets/stylesheets/common/chat-tabs.scss new file mode 100644 index 0000000000..fe49815231 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-tabs.scss @@ -0,0 +1,15 @@ +.chat-tabs { + display: flex; + flex-direction: column; + height: 100%; + min-height: 1px; +} + +.chat-tabs__tabpanel { + height: 100%; + min-height: 1px; +} + +.chat-tabs-list { + margin: 1.5rem 0 2rem 1rem; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-transcript.scss b/plugins/chat/assets/stylesheets/common/chat-transcript.scss new file mode 100644 index 0000000000..b66d72fcd6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-transcript.scss @@ -0,0 +1,78 @@ +.chat-transcript { + @extend .chat-message-container; + min-height: 50px; + padding: 12px; + margin: 1rem 0; + + @include post-aside; + + .chat-messages-container & { + display: block; + } + + &.chat-transcript-chained { + margin: 0; + border-top: 0; + border-bottom: 0; + } + + .chat-transcript-meta { + color: var(--primary-high); + font-size: var(--font-down-2-rem); + border-bottom: 1px solid var(--primary-low); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + } + + .chat-transcript-channel { + font-size: var(--font-down-1-rem); + } + + .chat-transcript-username { + color: var(--primary-high-or-secondary-low); + font-weight: bold; + } + + .chat-transcript-datetime { + color: var(--primary-high); + font-size: var(--font-down-2-rem); + padding: 0 0.5rem; + + a { + color: var(--primary-high); + } + } + + .chat-transcript-messages { + p { + margin: 0.5rem 0; + } + + p:last-of-type { + margin-bottom: 0; + } + } + + .chat-transcript-user { + display: flex; + flex-wrap: wrap-reverse; + gap: 0.25rem 0; + align-items: baseline; + + .chat-transcript-user-avatar { + padding-right: 0.5rem; + } + } + + .chat-transcript-reactions { + margin-top: 0.5em; + + .chat-transcript-reaction { + @include chat-reaction; + } + } + + pre code { + box-sizing: border-box; + } +} diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss new file mode 100644 index 0000000000..a12e0b21bb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -0,0 +1,988 @@ +$float-height: 530px; + +:root { + --message-left-width: 42px; + --full-page-border-radius: 12px; + --full-page-sidebar-width: 275px; +} + +.chat-message-move-to-channel-modal-modal { + .modal-inner-container { + .chat-move-message-channel-chooser { + width: 100%; + .category-chat-badge { + .d-icon { + color: inherit; + } + } + } + } +} + +.uppy-is-drag-over .chat-composer .drop-a-file { + display: flex; + position: absolute; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.75); + z-index: z("header"); + &-content { + width: max-content; + display: flex; + flex-direction: column; + align-items: center; + padding: 2em; + background-color: #1d1d1d; + border-radius: 0.25em; + &-images { + .d-icon { + height: 3em; + width: 3em; + color: var(--secondary-or-primary); + &:first-of-type { + transform: rotate(-5deg); + } + &:nth-of-type(2) { + height: 4em; + width: 4em; + } + &:last-of-type { + transform: rotate(5deg); + } + } + } + &-text { + margin: 1.5em 0 0 0; + font-size: var(--font-up-1); + color: var(--secondary-or-primary); + .d-icon-upload { + padding-right: 0.25em; + position: relative; + bottom: 2px; + color: var(--secondary-or-primary); + } + } + } +} + +.chat-channel-unread-indicator { + @include unselectable; + + width: 14px; + height: 14px; + border-radius: 100%; + box-sizing: content-box; + border: 2px solid var(--secondary); + -webkit-touch-callout: none; + background: var(--tertiary-med-or-tertiary); + font-size: var(--font-down-2); + + &.urgent { + background: var(--success); + color: var(--secondary); + + .number-wrap { + position: relative; + width: 100%; + height: 100%; + + .number { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + } +} + +.header-dropdown-toggle.open-chat { + .icon { + &.active { + .d-icon-comment { + color: var(--primary-medium); + } + } + + &:hover { + .chat-channel-unread-indicator { + border-color: var(--primary-low); + } + } + + .chat-channel-unread-indicator { + border-color: var(--header_background); + position: absolute; + right: 2px; + bottom: 2px; + transition: border-color linear 0.15s; + } + } +} + +.channels-list { + overflow-y: auto; + height: 100%; + padding-bottom: env(safe-area-inset-bottom); + position: relative; + @include chat-scrollbar(var(--secondary)); + + @include breakpoint(mobile-large) { + @include chat-scrollbar(); + } + + .chat-channel-unread-indicator { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 10px; + border: 0; + right: 7px; + top: calc(50% - 5px); + + &.urgent .number-wrap { + display: none; + } + } + + .open-browse-page-btn, + .open-draft-channel-page-btn, + .chat-channel-leave-btn { + background: transparent; + color: var(--primary-medium); + font-size: var(--font-0-rem); + padding: 0.5rem; + + &:hover { + background: transparent; + + .d-icon { + color: var(--primary); + } + } + } + + .public-channel-empty-message { + margin: 0 0.5em 0.5em 0.5em; + } + + .chat-channel-divider { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: bold; + padding: 0.25rem 0.5rem 0.25rem 2rem; + font-family: var(--heading-font-family); + font-size: var(--font-0); + + .channel-title { + line-height: var(--line-height-medium); + } + } + + .edit-channels-dropdown { + .select-kit-header { + background: none; + border: none; + font-size: var(--font-0-rem); + padding: 0.5rem; + + .d-icon { + color: var(--primary-medium); + margin: 0; + } + + &:focus .d-icon, + &:hover .d-icon { + color: var(--primary); + } + } + } + .open-browse-page-btn { + &:hover { + background: none; + } + } + + .chat-channel-title { + padding: 0.5rem; + } +} + +.chat-messages-container { + word-wrap: break-word; + white-space: normal; + + .chat-message-container { + display: grid; + + &.selecting-messages { + grid-template-columns: 1.5em 1fr; + } + + .chat-message-selector { + align-self: center; + justify-self: end; + margin: 0; + } + } + + .chat-time { + color: var(--primary-high); + font-size: var(--font-down-2); + } + + .emoji-picker { + position: fixed; + } + + &:hover { + .chat-.chat-message-react-btn { + display: inline-block; + } + } +} + +.chat-emoji-avatar { + width: var(--message-left-width); + align-items: center; + + img { + display: block; + margin-left: auto; + margin-right: auto; + } +} + +.chat-user-avatar { + @include unselectable; + display: flex; + align-items: center; + + .chat-message:not(.is-reply) & { + width: var(--message-left-width); + flex-shrink: 0; + } + + &.is-online { + .chat-user-avatar-container .avatar { + box-shadow: 0px 0px 0px 1px var(--success); + border: 1px solid var(--secondary); + padding: 0; + } + } + + .chat-user-avatar-container { + position: relative; + + .avatar { + padding: 1px; + } + + .chat-user-presence-flair { + box-sizing: border-box; + position: absolute; + background-color: var(--success); + border: 1px solid var(--secondary); + border-radius: 50%; + + .chat-message & { + width: 10px; + height: 10px; + right: 0px; + bottom: 0px; + } + + .chat-channel-title & { + width: 8px; + height: 8px; + right: -1px; + bottom: -1px; + } + } + } + + .chat-channel-title & { + width: auto; + } +} + +.chat-live-pane { + display: flex; + flex-direction: column; + width: 100%; + min-height: 1px; + position: relative; + + .open-drawer-btn { + color: var(--primary-low-mid); + + &:visited { + color: var(--primary-low-mid); + } + + &:hover { + color: var(--primary); + } + + > * { + pointer-events: none; + } + } + + .chat-messages-scroll { + flex-grow: 1; + overflow-y: scroll; + scrollbar-color: var(--primary-low) transparent; + transition: scrollbar-color 0.2s ease-in-out; + display: flex; + flex-direction: column-reverse; + z-index: 1; + + &::-webkit-scrollbar { + width: 15px; + } + &::-webkit-scrollbar-thumb { + background: var(--primary-low); + border-radius: 8px; + border: 3px solid var(--secondary); + } + &::-webkit-scrollbar-track { + background-color: transparent; + } + &:hover { + scrollbar-color: var(--primary-low-mid) transparent; + &::-webkit-scrollbar-thumb { + background: var(--primary-low-mid); + } + } + + .join-channel-btn.in-float { + position: absolute; + transform: translateX(-50%); + left: 50%; + top: 10px; + z-index: 10; + } + + .all-loaded-message { + text-align: center; + color: var(--primary-medium); + font-size: var(--font-down-1); + padding: 0.5em 0.25em 0.25em; + } + } + + .scroll-stick-wrap { + position: relative; + } + + .chat-scroll-to-bottom { + background: var(--primary-medium); + bottom: 1em; + border-radius: 100%; + left: 50%; + opacity: 50%; + padding: 0.5em; + position: absolute; + transform: translateX(-50%); + z-index: 2; + + &:hover { + background: var(--primary-medium); + opacity: 100%; + } + + .d-icon { + color: var(--primary); + margin: 0; + } + + &.unread-messages { + opacity: 85%; + border-radius: 0; + transition: border-radius 0.1s linear; + + &:hover { + opacity: 100%; + } + + .d-icon { + margin: 0 0 0 0.5em; + } + } + } +} + +.topic-title-chat-icon { + display: inline-block; + * { + display: inline-block; + } +} + +.chat-channel-row { + align-items: center; + box-sizing: border-box; + display: flex; + position: relative; + cursor: pointer; + color: var(--primary-high); + transition: opacity 50ms ease-in; + opacity: 1; + + .chat-user-avatar { + pointer-events: none; + } + + .chat-channel-unread-indicator { + margin-left: 0.5rem; + } + + &.unfollowing { + opacity: 0; + } + + .toggle-channel-membership-button.-leave { + visibility: hidden; + margin-left: auto; + } + + &:hover { + .toggle-channel-membership-button.-leave { + visibility: visible; + + > * { + pointer-events: auto; + } + } + } + + .discourse-no-touch &:hover, + &.active { + background: var(--primary-low); + } + + &:hover, + &.active { + .topic-chat-badge .topic-chat-icon .d-icon { + background: transparent; + } + &.active { + font-weight: 600; + } + + .chat-channel-unread-indicator { + border-color: var(--primary-low); + } + + .chat-channel-title { + &, + .category-chat-name, + .dm-usernames { + color: var(--primary); + } + + .d-icon-lock { + background-color: var(--primary-low); + } + } + } + + &.muted { + opacity: 0.65; + } + .badge-wrapper { + align-items: center; + margin-right: 0; + } + + .chat-channel-unread-indicator { + background: var(--tertiary-med-or-tertiary); + + &.urgent { + background: var(--success); + } + } + + .chat-channel-row-unread-count { + display: inline-block; + margin-left: 5px; + font-size: var(--font-down-1); + color: var(--primary-high); + } + + .emoji { + margin-left: 0.3em; + } +} + +.chat-channel-settings-row { + display: flex; + padding: 0.5em; + align-items: center; + background: var(--secondary); + border-bottom: 1px solid var(--primary-low); + .chat-channel-info { + .channel-title-container { + position: relative; + .channel-title { + display: flex; + align-items: center; + font-weight: 500; + .edit-btn { + border: none; + background-color: var(--secondary); + &:hover { + .d-icon { + color: var(--primary-very-high); + } + } + .d-icon { + color: var(--primary-medium); + } + .select-kit-header { + background-color: var(--secondary); + } + } + } + } + .chat-channel-data { + display: flex; + align-items: center; + font-size: var(--font-down-1); + .d-icon-check { + font-size: var(--font-down-3); + margin-right: 0.5em; + color: var(--success); + } + .channel-joined { + margin: 0 0.5em 0 0; + font-weight: 500; + color: var(--success); + } + .chat-channel-description { + color: var(--primary-high); + } + } + } + .btn-container { + margin-left: auto; + } +} + +.chat-channel-settings-row { + .channel-name-edit { + display: flex; + align-items: center; + margin-bottom: 9px; + + .name-input { + margin: 0; + } + + .save-btn, + .cancel-btn { + margin-left: 0.25em; + } + } +} + +body.has-sidebar-page.has-full-page-chat #main-outlet-wrapper { + gap: 0; +} + +body.has-full-page-chat { + .alert-error, + .alert-info, + .alert-success, + .alert-warning { + margin: 0; + border-bottom: 1px solid var(--primary-low); + } +} + +.full-page-chat { + font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + display: grid; + grid-template-columns: var(--full-page-sidebar-width) 1fr; + + .channels-list { + height: 100%; + border-right: 1px solid var(--primary-low); + + .chat-channel-row { + padding: 0 0 0 0.5rem; + margin: 0 0.5rem 0.125rem 0.5rem; + border-radius: 0.25em; + + .category-chat-private .d-icon { + background-color: var(--primary-very-low); + } + + &:hover, + &.active { + background-color: var(--primary-low); + .chat-channel-title { + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + .chat-name, + .dm-usernames { + color: var(--primary); + } + } + .category-chat-private .d-icon { + background-color: var(--primary-low); + } + } + } + } + + .chat-full-page-header { + border-top: 1px solid var(--primary-low); + border-bottom: 1px solid var(--primary-low); + background: var(--secondary); + z-index: 3; + display: flex; + align-items: center; + + &__back-btn { + width: 40px; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; + } + + .chat-channel-title { + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + .chat-name, + .dm-usernames { + color: var(--primary); + display: inline; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .-not-following { + .chat-channel-title { + max-width: calc(100% - 50px); + } + .join-channel-btn { + margin-left: auto; + } + } + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-live-pane { + box-sizing: border-box; + height: 100%; + } +} + +.chat-full-page-header__left-actions { + display: flex; + align-items: stretch; +} + +.chat-full-page-header__title { + display: flex; + align-items: stretch; +} + +.chat-full-page-header__right-actions { + align-items: stretch; + display: flex; + flex-grow: 1; + font-size: var(--font-up-1); + justify-content: flex-end; +} + +.chat-full-page-header { + box-sizing: border-box; + + .chat-channel-header-details { + display: flex; + align-items: stretch; + flex: 1; + + .chat-channel-archive-status { + text-align: right; + padding-right: 1em; + } + } + + .chat-channel-title { + margin: 0; + max-width: 100%; + + .d-icon:not(.d-icon-lock) { + height: 1.25em; + width: 1.25em; + } + + .category-chat-name, + .dm-username, + .topic-chat-name { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } + + .dm-usernames { + overflow: hidden; + text-overflow: ellipsis; + } + } + .chat-channel-retry-archive { + display: flex; + margin-top: 1em; + } +} + +.channels-list { + .tag-chat-badge, + .category-chat-badge, + .topic-chat-badge { + color: var(--primary-low-mid); + display: flex; + align-items: center; + justify-content: center; + + .d-icon { + height: 1.25em; + width: 1.25em; + margin: 0; + } + } + + .topic-chat-badge { + .d-icon { + z-index: 1; + } + } + + .category-chat-private .d-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 5px; + padding: 3px 2px; + color: var(--primary-high); + height: 0.5em; + width: 0.5em; + left: calc(0.6125em + 6px); + top: -4px; + } +} + +.chat-channel-archive-modal-inner { + .chat-to-topic-selector { + width: 500px; + height: 300px; + } + + .radios { + margin-bottom: 10px; + display: flex; + flex-direction: row; + + .radio-label { + margin-right: 10px; + } + } + + details { + margin-bottom: 9px; + } + + input[type="text"], + .select-kit.combo-box.category-chooser { + width: 100%; + } +} + +.chat-channel-archive-modal-inner { + .chat-to-topic-selector { + width: auto; + } +} + +.user-preferences .chat-setting .controls { + margin-bottom: 0; +} + +.create-channel-modal { + .modal-inner-container { + width: 500px; + } + .choose-topic-results-list { + max-height: 200px; + overflow-y: scroll; + } + .select-kit.combo-box, + .create-channel-name-input, + .create-channel-description-input, + #choose-topic-title { + width: 100%; + margin-bottom: 0; + } + .category-chooser { + .select-kit-selected-name.selected-name.choice { + color: var( + --primary-high + ); // Make consistent with color of placeholder text when choosing topic + } + } + + .create-channel-hint { + font-size: 0.8em; + margin-top: 0.2em; + } + + .create-channel-label, + label[for="choose-topic-title"] { + margin: 1em 0 0.35em; + } + .chat-channel-title { + margin: 1em 0 0 0; + } +} + +.small-action { + .open-chat { + text-transform: uppercase; + font-weight: 700; + font-size-adjust: var(--font-down-0); + } +} + +.chat-message-collapser, +.chat-message-text { + > p { + margin: 0.5em 0 0.5em; + } + + > p:first-of-type { + margin-top: 0.1em; + } + + > p:last-of-type { + margin-bottom: 0.1em; + } +} + +.reviewable-chat-message { + .chat-channel-title { + max-width: 100%; + } +} + +.chat-channel-dm-title { + display: flex; + align-items: center; + justify-content: space-between; + + .channel-name { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } +} + +.chat-channel-status { + padding-top: 1rem; + font-weight: 500; +} + +html.has-full-page-chat { + height: 100%; + width: 100%; + + &.keyboard-visible body #main-outlet .full-page-chat { + padding-bottom: 0.2rem; + } + + body { + height: 100%; + width: 100%; + + #main-outlet { + display: flex; + flex-direction: column; + max-height: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - + var(--composer-height, 0px) + ); + + .full-page-chat { + height: 100%; + min-height: 0; + padding-bottom: env(safe-area-inset-bottom); + } + + #main-chat-outlet { + min-height: 0; + } + } + } + + &.mobile-view { + #main-outlet-wrapper { + padding: 0; + } + } + + // these need to apply to desktop too, because iPads + &.discourse-touch { + // iPad web + #main-outlet-wrapper { + // restrict the row height, including when virtual keyboard is open + grid-template-rows: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset) + ); + .sidebar-wrapper { + // prevents sidebar from overflowing behind the virtual keyboard + height: 100%; + } + } + + // iPad webview + .footer-nav-ipad { + #main-outlet-wrapper { + // restrict the row height, including when virtual keyboard is open + grid-template-rows: calc( + var(--chat-vh, 1vh) * 100 - calc(var(--header-offset)) + ); + } + } + + .full-page-chat, + .chat-live-pane, + #main-outlet { + // allows containers to shrink to fit + min-height: 0; + } + + #main-outlet { + // limits height for iPad + max-height: calc( + 100vh - calc(var(--header-offset) + var(--composer-ipad-padding)) + ); + } + } +} + +[data-popper-reference-hidden] { + visibility: hidden; +} diff --git a/plugins/chat/assets/stylesheets/common/core-extensions.scss b/plugins/chat/assets/stylesheets/common/core-extensions.scss new file mode 100644 index 0000000000..71c68a54dc --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/core-extensions.scss @@ -0,0 +1,12 @@ +.has-full-page-chat { + .create-topics-notice, + .bootstrap-mode-notice { + display: none; + } +} + +.admin-plugins { + [data-plugin-name="chat"] { + display: none; + } +} diff --git a/plugins/chat/assets/stylesheets/common/d-progress-bar.scss b/plugins/chat/assets/stylesheets/common/d-progress-bar.scss new file mode 100644 index 0000000000..37ade41970 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/d-progress-bar.scss @@ -0,0 +1,51 @@ +// temporary stuff to be moved in core with discourse-loading-slider + +.d-progress-bar-container { + --loading-width: 80%; + --still-loading-width: 90%; + + --still-loading-duration: 10s; + --done-duration: 0.4s; + --fade-out-duration: 0.4s; + + position: absolute; + top: 0; + left: 0; + z-index: z("header") + 2000; + height: 3px; + width: 100%; + opacity: 0; + transition: opacity var(--fade-out-duration) ease var(--done-duration); + background-color: var(--primary-low); + + .d-progress-bar { + height: 100%; + width: 0%; + background-color: var(--tertiary); + } + + &.loading, + &.still-loading { + opacity: 1; + transition: opacity 0s; + } + + &.loading .d-progress-bar { + transition: width var(--loading-duration) ease-in; + width: var(--loading-width); + } + + &.still-loading .d-progress-bar { + transition: width var(--still-loading-duration) linear; + width: var(--still-loading-width); + } + + &.done .d-progress-bar { + transition: width var(--done-duration) ease-out; + width: 100%; + } + + body.footer-nav-ipad & { + top: 49px; // TODO: Share $footer-nav-height from footer-nav.scss + } +} diff --git a/plugins/chat/assets/stylesheets/common/dc-filter-input.scss b/plugins/chat/assets/stylesheets/common/dc-filter-input.scss new file mode 100644 index 0000000000..711cff93f3 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/dc-filter-input.scss @@ -0,0 +1,23 @@ +.dc-filter-input-container { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--primary-medium); + box-sizing: border-box; + + &.is-focused { + border: 1px solid var(--tertiary); + } + + .dc-filter-input, + .dc-filter-input:focus { + width: 100%; + margin: 0; + border: none; + outline: none; + } + + .d-icon { + margin: 0 0.5rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/direct-message-creator.scss b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss new file mode 100644 index 0000000000..02444815ad --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss @@ -0,0 +1,196 @@ +.direct-message-creator { + display: flex; + flex-direction: column; + + .title-area { + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--primary-low); + + .title { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } + } + + .filter-area { + padding: 1rem; + display: flex; + align-items: flex-start; + border-bottom: 1px solid var(--primary-low); + cursor: text; + position: relative; + + &.is-focused { + background: var(--primary-very-low); + } + } + + .prefix { + line-height: 34px; + padding-right: 0.25rem; + } + + .selected-user { + list-style: none; + padding: 0; + margin: 1px 0.25rem 0.25rem 1px; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + background: var(--primary-very-low); + border-radius: 8px; + border: 1px solid var(--primary-300); + align-items: center; + display: flex; + + &:last-child { + margin-right: 0; + } + + &.is-highlighted { + border-color: var(--tertiary); + + .d-icon { + color: var(--danger); + } + } + + .username { + margin: 0 0.5em; + } + + & * { + pointer-events: none; + } + + &:hover, + &:focus { + background: var(--primary-very-low); + color: var(--primary); + + &:not(.is-highlighted) { + border-color: var(--tertiary); + } + + .d-icon { + color: var(--danger); + } + } + } + + .recipients { + display: flex; + flex-wrap: wrap; + margin-bottom: -0.25rem; + flex: 1; + min-width: 0; + align-items: center; + + & + .btn { + margin-left: 1em; + } + + .filter-usernames { + flex: 1 0 auto; + min-width: 80px; + margin: 1px 0 0 0; + appearance: none; + border: 0; + outline: 0; + background: none; + width: unset; + } + } + + .results-container { + display: flex; + position: relative; + } + + .results { + display: flex; + margin: 0; + flex-wrap: wrap; + border-bottom: 1px solid var(--primary-low); + box-shadow: shadow("card"); + position: absolute; + width: 100%; + z-index: z("dropdown"); + background: var(--secondary); + + .user { + display: flex; + width: 100%; + list-style: none; + cursor: pointer; + outline: 0; + padding: 0.25em 0.5em; + margin: 0.25rem; + align-items: center; + border-radius: 4px; + + .user-info { + margin: 0; + width: 100%; + } + + &.is-focused { + background: var(--tertiary-very-low); + } + + * { + pointer-events: none; + } + + .username { + margin-left: 0.25em; + color: var(--primary-high); + font-size: var(--font-up-1); + } + + & + .user { + margin-top: 0.25em; + } + + .user-status-message { + margin-left: 0.3em; + + .emoji { + margin-bottom: 0.2em; + } + } + } + + .btn { + padding: 0.25em; + &:last-child { + margin: 0; + } + } + } + + .no-results-container { + position: relative; + } + + .no-results { + text-align: center; + padding: 1rem; + width: 100%; + box-shadow: shadow("card"); + background: var(--secondary); + margin: 0; + box-sizing: border-box; + } + + .fetching-preview-message { + padding: 1rem; + text-align: center; + } + + .join-existing-channel { + margin: 1rem auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss b/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss new file mode 100644 index 0000000000..dd2b4c720b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss @@ -0,0 +1,46 @@ +.full-page-chat-header { + display: flex; + padding: 0.25rem; + border-bottom: 1px solid var(--primary-low); + justify-content: space-between; + @include ellipsis; + flex-direction: column; + + .chat-channel-info-link { + justify-self: flex-end; + } +} + +.full-page-chat-header__about-link { + @include ellipsis; + padding-right: 0.25rem; + + .chat-channel-title__name { + font-weight: 700; + } + .chat-channel-title { + padding: 0.5rem 0.5rem 0.25rem 0.5rem; + } +} + +.full-page-chat-header__members-link { + padding: 0 0.5rem 0.5rem 0.5rem; + font-size: var(--font-down-1); + color: var(--primary-medium); + + &:visited { + color: var(--primary-medium); + } +} + +.full-page-chat-header__first-row { + display: flex; + height: 45px; + align-items: center; +} + +.full-page-chat-header__second-row { + display: flex; + height: 32px; + align-items: center; +} diff --git a/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss new file mode 100644 index 0000000000..4b75238fcf --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss @@ -0,0 +1,53 @@ +.incoming-chat-webhooks { + margin-top: 1em; + + &--row { + display: flex; + justify-content: space-between; + background-color: var(--primary-very-low); + padding: 1em; + border-radius: 6px; + margin-bottom: 1em; + + &--details { + display: inline-block; + vertical-align: top; + max-width: calc(100% - 120px - 1em); + + &--name { + font-weight: bold; + font-size: var(--font-up-1); + } + } + &--controls { + display: inline-block; + vertical-align: top; + } + } +} + +.incoming-chat-webhooks-back { + margin-bottom: 1em; +} + +.incoming-chat-webhooks-current-emoji { + padding-left: 0.5em; +} + +.new-incoming-webhook-container { + display: flex; + align-items: center; + + input { + margin: 0; + } + + input, + details { + margin-right: 0.5em; + } + + .create-new-incoming-webhook-btn { + margin-right: 0.25em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss b/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss new file mode 100644 index 0000000000..223f015bf8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss @@ -0,0 +1,5 @@ +.reviewable-chat-message { + .transcript { + margin: 0 0 1em 0; + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss new file mode 100644 index 0000000000..5c79ad4b61 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss @@ -0,0 +1,21 @@ +.chat-channel-title-wrapper { + padding: 0.25rem; + + &:hover { + background: var(--primary-very-low); + border-radius: 5px; + } +} + +.channels-list { + .chat-channel-title { + max-width: 100%; + box-sizing: border-box; + } + + .chat-channel-row:hover { + .chat-channel-title { + overflow: hidden; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss new file mode 100644 index 0000000000..c2211a38e2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss @@ -0,0 +1,8 @@ +.chat-composer-uploads { + .chat-composer-uploads-container { + .full-page-chat & { + flex-wrap: wrap; + row-gap: 0.5rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss new file mode 100644 index 0000000000..9ab578cbf8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss @@ -0,0 +1,5 @@ +.chat-composer-container { + .chat-composer { + margin: 0.25rem 10px 0 10px; + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss b/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss new file mode 100644 index 0000000000..3d015632e7 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss @@ -0,0 +1,5 @@ +.chat-message-actions[data-popper-reference-hidden], +.chat-message-actions[data-popper-escaped] { + visibility: hidden; + pointer-events: none; +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message.scss b/plugins/chat/assets/stylesheets/desktop/chat-message.scss new file mode 100644 index 0000000000..8ea9c38357 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message.scss @@ -0,0 +1,17 @@ +.chat-message-actions { + .react-btn, + .reply-btn, + .bookmark-btn { + border: 1px solid transparent; + border-bottom-color: var(--primary-low); + border-radius: 0; + border-top-color: var(--primary-low); + + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + color: var(--primary-medium); + z-index: 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/desktop.scss new file mode 100644 index 0000000000..f5175f9638 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/desktop.scss @@ -0,0 +1,160 @@ +.chat-drawer { + width: 400px; + max-width: 100vw; +} + +.user-card, +.group-card { + z-index: z("usercard") + 1; // bump up user card +} + +.full-page-chat { + &.teams-sidebar-on { + grid-template-columns: 1fr; + + .chat-live-pane { + border-radius: var(--full-page-border-radius); + } + } + + .chat-full-page-header { + padding: 0 1rem; + height: 65px; + min-height: 65px; + flex-shrink: 0; + } + + .chat-live-pane { + .chat-messages-container { + .chat-message { + &.is-reply { + grid-template-columns: var(--message-left-width) 1fr; + } + + .chat-user { + width: var(--message-left-width); + } + } + } + } +} + +.chat-message:not(.user-info-hidden) { + padding: 0.65em 1em 0.15em; +} + +.chat-message-text { + img:not(.emoji):not(.avatar) { + transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + + &:hover { + cursor: pointer; + border-radius: 5px; + box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1), + 0 2px 10px 0 rgba(var(--always-black-rgb), 0.1); + } + } +} + +.chat-message.user-info-hidden { + padding: 0.15em 1em; +} + +// Full Page Styling in Core +.has-full-page-chat:not(.discourse-sidebar) { + --max-chat-width: 1200px; + + #main-outlet { + max-width: var(--max-chat-width); + padding: 0; + } + + .full-page-chat { + border-right: 1px solid var(--primary-low); + border-left: 1px solid var(--primary-low); + + .channels-list { + background: var(--primary-very-low); + + .chat-channel-divider { + padding: 0.5rem 0.5rem 0 1rem; + } + + .loading-container { + padding-bottom: 1em; + } + } + + .chat-live-pane { + border-radius: unset; + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked) { + background-color: transparent; + } + + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked):hover { + background-color: var(--primary-very-low); + } + } + + @media screen and (max-width: var(--max-chat-width)) { + #main-outlet { + max-width: 100%; + padding: 0; + } + + .full-page-chat { + border: none; + grid-template-columns: 250px 1fr; + } + } +} + +// Full page styling with sidebar enabled +.discourse-sidebar.has-full-page-chat { + #main-outlet { + padding: 2em 0 0 0; + } + + .full-page-chat.teams-sidebar-on { + .chat-live-pane { + border-radius: 0; + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked) { + background: transparent; + } + + .chat-message { + padding-left: 1em; + + &:hover { + background-color: var(--primary-very-low); + } + } + + .chat-messages-container .chat-message-deleted { + padding: 0.25em 1em; + } + } +} + +.chat-browse .chat-channel-settings-row { + .edit-btn, + .btn-container { + opacity: 0; + transition: opacity 0.1s; + } + + &:hover { + .edit-btn, + .btn-container { + opacity: 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss new file mode 100644 index 0000000000..34eda5b38e --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss @@ -0,0 +1,4 @@ +.full-page-chat.full-page-chat-sidebar-enabled { + grid-template-columns: 1fr; + overflow: inherit; +} diff --git a/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss new file mode 100644 index 0000000000..0fa99b9fb9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss @@ -0,0 +1,28 @@ +@mixin chat-scrollbar($border: var(--primary-very-low)) { + --scrollbarBg: transparent; + --scrollbarThumbBg: var(--primary-low); + --scrollbarWidth: 1.2rem; + + scrollbar-color: transparent var(--scrollbarBg); + transition: scrollbar-color 0.25s ease-in-out; + transition-delay: 0.5s; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: calc(var(--scrollbarWidth) / 2); + border: calc(var(--scrollbarWidth) / 4) solid transparent; + } + &:hover { + &::-webkit-scrollbar-thumb { + border: calc(var(--scrollbarWidth) / 4) solid $border; + } + scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); + &::-webkit-scrollbar-thumb { + background-color: var(--scrollbarThumbBg); + } + transition-delay: 0s; + } + &::-webkit-scrollbar { + width: var(--scrollbarWidth); + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-channel-info.scss b/plugins/chat/assets/stylesheets/mobile/chat-channel-info.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss new file mode 100644 index 0000000000..edbc974c9d --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss @@ -0,0 +1,7 @@ +.chat-composer-container { + padding: 0; + + .chat-composer { + margin: 0.5rem 10px 0 10px; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-index.scss b/plugins/chat/assets/stylesheets/mobile/chat-index.scss new file mode 100644 index 0000000000..2a037ac171 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-index.scss @@ -0,0 +1,122 @@ +@import "common/foundation/mixins"; +.full-page-chat { + overflow: hidden; //prevents double scroll + .channels-list { + overflow-y: overlay; + padding-bottom: 6rem; + box-sizing: border-box; + + .direct-message-channels { + .chat-channel-title { + padding: 0.6rem 0; //minor adjustment for visual consistency with channels which dont have avatars + } + } + + @media (hover: none) { + .chat-channel-row:hover { + background: transparent; + } + + .chat-channel-row:active { + background: var(--primary-low); + } + } + + .chat-channel-row { + margin: 0 1.5rem; + padding: 0; + border-radius: 0; + border-bottom: 1px solid var(--primary-low); + } + + .chat-channel-divider { + background-color: var(--secondary); + padding: 2.5rem 1.5rem 0.5rem 1.5rem; + font-size: var(--font-up-1); + + &:first-of-type { + padding-top: 1rem; + padding-bottom: 0; //visual compensation + } + + .channel-title { + color: var(--quaternary); + font-size: var(--font-down-1); + } + } + + .channels-list-container { + background: var(--secondary); + } + + .chat-user-avatar-container { + padding-left: 1px; //for is-online boxshadow effect + } + + .chat-user-avatar { + img { + width: calc(var(--chat-mobile-avatar-size) - 2px); + height: calc(var(--chat-mobile-avatar-size) - 2px); + } + + + .chat-channel-title__usernames { + margin-left: 1rem; + } + } + + .chat-channel-title { + padding: 1rem 0; + width: 100%; + overflow: hidden; + + &__users-count { + width: var(--chat-mobile-avatar-size); + height: var(--chat-mobile-avatar-size); + padding: 0; + font-size: var(--font-up-2); + font-weight: normal; + justify-content: center; + + & + .chat-channel-title__name { + margin-left: 1rem; + } + } + + &__name { + font-size: var(--font-up-1); + } + + &__category-badge { + font-size: var(--font-up-1); + } + } + } + + .btn-floating.open-draft-channel-page-btn { + position: absolute; + background: var(--tertiary); + bottom: 2.5rem; + right: 2.5rem; + border-radius: 50%; + font-size: var(--font-up-4); + padding: 1rem; + transition: transform 0.25s ease, box-shadow 0.25s ease; + z-index: z("usercard"); + box-shadow: 0px 5px 5px -1px rgba(0, 0, 0, 0.25); + + .d-icon { + color: var(--primary-very-low); + } + + &:active { + box-shadow: 0px 0px 5px -1px rgba(0, 0, 0, 0.25); + transform: scale(0.9); + } + + &:focus { + @include default-focus; + border-color: var(--quaternary); + outline-color: var(--quaternary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss new file mode 100644 index 0000000000..eac83541f8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss @@ -0,0 +1,160 @@ +.chat-message-actions { + position: absolute; + bottom: -100vh; + left: 0; + right: 0; + display: flex; + flex-direction: column; + border-radius: 8px 8px 0 0; + margin: 0 2px; + transition: bottom 0.2s ease; + + .selected-message-container { + padding: 0.5em 0.5em 1em 0.5em; + } + + .selected-message { + display: flex; + align-items: center; + padding: 0.5em; + border: 1px solid var(--primary-low); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.125); + border-radius: 8px; + + .selected-message-reply { + &:not(.is-expanded) { + @include ellipsis; + } + + &.is-expanded { + @include user-select(text); + max-height: 80px; + overflow-y: scroll; + } + } + } + + .main-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em 1em 1.5em 1em; + + .chat-message-reaction { + background: none; + border: 1px solid transparent; + + img.emoji { + width: 30px; + height: 30px; + object-fit: contain; + } + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + } + + .react-btn { + .d-icon { + color: var(--primary-medium); + font-size: var(--font-up-4); + } + } + + .chat-message-reaction, + .react-btn { + margin: 0; + } + + .chat-message-reaction, + .reply-btn, + .react-btn, + .bookmark-btn { + flex-grow: 1; + height: 42px; + } + + .bookmark-btn, + .react-btn { + > .svg-icon-title, + > .svg-icon { + font-size: var(--font-up-4); + } + } + + .reply-btn { + border-radius: 3px; + .d-icon { + font-size: var(--font-up-4); + } + } + } + + .secondary-actions { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + padding: 0.5em; + margin: 0; + + .chat-message-action-item { + border-bottom: 1px solid var(--primary-low); + width: 100%; + list-style: none; + padding-bottom: 0.25em; + margin-bottom: 0.25em; + display: flex; + + &:last-child { + border: 0; + margin: 0; + padding: 0; + } + + .chat-message-action { + justify-content: flex-start; + background: none; + width: 100%; + border: 0; + color: var(--primary); + + &:focus, + .d-icon { + color: var(--primary); + } + } + } + } +} + +.chat-message-actions-backdrop { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 100%; + width: 100%; + z-index: z("header") + 1; + transition: background-color 0.2s ease; + + .collapse-area { + width: 100%; + height: 100%; + } + + &.fade-in { + background-color: rgba(0, 0, 0, 0.35); + + .chat-message-actions { + bottom: 0; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss new file mode 100644 index 0000000000..c3517ab985 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -0,0 +1,10 @@ +.chat-message *, +.chat-composer-row, +.chat-reply, +.replying-text { + @include unselectable; +} + +.chat-message-container { + transform: translateZ(0); +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss new file mode 100644 index 0000000000..97f0643313 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss @@ -0,0 +1,15 @@ +.chat-selection-management { + .chat-selection-management-buttons { + display: flex; + flex-direction: column; + width: 100%; + + .cancel-btn { + margin-left: initial; + } + + .btn { + margin-bottom: 0.25em; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/mobile.scss new file mode 100644 index 0000000000..a01476f520 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/mobile.scss @@ -0,0 +1,146 @@ +:root { + --chat-mobile-avatar-size: 38px; +} + +.chat-message { + // 1px to account for .is-online box-shadow + padding: 0.1em 1px; +} + +.chat-message:not(.user-info-hidden) { + padding-top: 0.75em; +} + +body.has-full-page-chat { + .footer-nav { + display: none !important; + } + + #main-outlet { + padding: 0; + } +} + +.chat-channel-settings-modal .modal-inner-container { + max-width: 90vw; + .chat-channel-settings-row { + max-width: 100%; + + .chat-channel-preview { + display: none; + } + .chat-channel-title { + max-width: 80%; + } + .controls.save-container { + justify-content: end; + } + } +} + +.full-page-chat { + grid-template-columns: 100%; + overflow-x: hidden; + width: 100%; + + .btn:active, + .btn:hover { + background: var(--secondary-very-high); + + .d-icon { + color: var(--primary-medium); + } + } + + .chat-live-pane { + border-radius: 0; + padding: 0; + } + + .chat-drawer { + width: 100%; + } + + .chat-full-page-header { + background-color: var(--secondary); + padding: 0 10px; + height: 50px; + min-height: 50px; + } + + .chat-messages-scroll { + padding: 0 10px; + } +} + +.channels-list .chat-channel-row { + .category-chat-private .d-icon { + background-color: var(--secondary); + } + + .chat-channel-unread-indicator { + width: 6px; + height: 6px; + left: 5px; + top: calc(50% - 3px); + } +} + +.sidebar-container .channels-list .chat-channel-divider { + padding-left: 1em; +} + +.sidebar-container .channels-list .chat-channel-row { + padding: 0.5em; +} + +.create-channel-modal { + .modal-inner-container { + width: 95%; + } +} + +.chat-browse { + .chat-channel-settings-row { + font-size: var(--font-down-1); + .chat-channel-title { + grid-template-columns: 15px 1fr; + } + } +} + +.chat-full-page-header { + .chat-channel-header-details { + .chat-channel-retry-archive { + flex-direction: column; + + .chat-channel-archive-failed-retry { + margin-top: 0.5em; + } + } + } +} + +html.has-full-page-chat body { + #main-outlet-wrapper { + // restricts the height of the page + grid-template-rows: calc(var(--chat-vh, 1vh) * 100 - var(--header-offset)); + } +} + +.chat-message-separator { + margin-left: 0; +} + +.header-dropdown-toggle.open-chat { + .icon { + &.active { + border: 1px solid var(--primary-low); + background: var(--primary-very-low); + + .d-icon { + color: var(--primary-medium); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/sidebar-extensions.scss new file mode 100644 index 0000000000..ea504c0f18 --- /dev/null +++ b/plugins/chat/assets/stylesheets/sidebar-extensions.scss @@ -0,0 +1,182 @@ +// Styles to make channels list in sidebar match sidebar theme +.chat-enabled { + .has-sidebar { + .sidebar-header { + .d-header .menu-panel { + top: calc(3.4em - 2px) !important; + } + .d-header-icons .icon { + width: 2em; + height: 2em; + img.avatar, + #logo-small { + width: 2em; + height: 2em; + } + } + } + .header-dropdown-toggle.open-chat { + .chat-channel-unread-indicator { + border-color: var(--primary-very-low); + } + } + .sidebar-container { + .channels-list { + .chat-channel-divider { + padding: 0 0.5em 0 1.75rem; + } + .chat-channel-row { + padding-right: 0.75em; + } + .chat-channel-leave-btn { + padding: 0; + } + } + } + } + + .sidebar-container { + .channels-list { + color: var(--primary); + font-size: var(--font-down-1); + padding-bottom: 2em; + width: 100%; + overflow-x: hidden; + + .chat-channel-divider { + padding: 0 1.75rem; + + &:hover { + .title-caret { + opacity: 1; + } + } + } + + .channels-list-container { + margin-bottom: 1rem; + } + + .public-channel-empty-message { + margin: 0; + padding: 0em 2em 0.5em; + } + + .chat-channel-row:not(.active) { + &:hover { + .category-chat-private { + .d-icon { + background-color: var(--primary-low); + } + } + } + .category-chat-private { + .d-icon { + background-color: var(--primary-very-low); + } + } + } + + .open-draft-channel-page-btn, + .open-browse-page-btn, + .edit-channels-dropdown .select-kit-header, + .chat-channel-leave-btn { + display: flex; + padding: 0.25em; + border-radius: 0.25em; + + &:hover { + background-color: var(--primary-low); + + .d-icon { + color: var(--primary-medium); + } + } + + .d-icon { + color: var(--primary-medium); + font-size: var(--font-down-1); + padding: 0.25em; + } + } + + .chat-channel-leave-btn { + padding-top: 0; + padding-bottom: 0; + height: 100%; + border-radius: 0; + + &:hover { + .d-icon { + color: var(--primary-medium); + } + } + } + + .chat-channel-row { + padding-left: calc(1.8rem / 2); + margin-left: calc(1.8rem / 2); + border-radius: 0.25em; + padding-right: 1.8rem; + min-height: 28px; + margin-bottom: 0.125rem; + + &:hover { + background-color: var(--primary-low); + } + + .chat-channel-title { + padding: 0.25rem; + font-weight: unset; + margin: 0; + } + } + } + } +} + +.chat-enabled { + .sidebar-section-link-suffix.icon { + &.urgent svg { + color: var(--success); + } + + &.unread svg { + color: var(--tertiary-med-or-tertiary); + } + } + + .sidebar-section-link-prefix { + .prefix-image { + border: 1px solid transparent; + } + + &.active .prefix-image { + box-shadow: 0px 0px 0px 1px var(--success); + } + } + + .sidebar-section-link-content-text { + .user-status { + margin-left: 0.3em; + } + } + + .sidebar-section-link--active { + background: var(--primary-low); + } + + .sidebar-section-link--muted { + opacity: 0.5; + + .sidebar-section-link-prefix.icon .d-icon { + color: var(--primary-medium); + } + + &.active { + .sidebar-section-link-prefix.icon .d-icon { + color: var(--primary-high); + } + } + } +} diff --git a/plugins/chat/config/locales/client.ar.yml b/plugins/chat/config/locales/client.ar.yml new file mode 100644 index 0000000000..002b651c9f --- /dev/null +++ b/plugins/chat/config/locales/client.ar.yml @@ -0,0 +1,502 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "تم تغيير حالة قناة الدردشة" + chat_channel_delete: "تم حذف قناة الدردشة" + api: + scopes: + descriptions: + chat: + create_message: "أنشئ رسالة دردشة في قناة محدَّدة." + about: + chat_messages_count: "رسائل الدردشة" + chat_channels_count: "قنوات الدردشة" + chat_users_count: "مستخدمو الدردشة" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "يتم الآن عرض جميع الرسائل" + already_enabled: "الدردشة مفعَّلة في هذا الموضوع. يُرجى تحديث الصفحة." + disabled_for_topic: "الدردشة متوقفة في هذا الموضوع." + bot: "برنامج روبوت" + create: "إنشاء" + cancel: "إلغاء" + cancel_reply: "إلغاء الرد" + chat_channels: "القنوات" + browse_all_channels: "استعراض كل القنوات" + move_to_channel: + title: "نقل الرسائل إلى القناة" + instructions: + zero: "أنت تنقل %{count} رسائل. حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + one: "أنت تنقل رسالة واحدة (%{count}). حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + two: "أنت تنقل رسالتين (%{count}). حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + few: "أنت تنقل %{count} رسائل. حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + many: "أنت تنقل %{count} رسائل. حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + other: "أنت تنقل %{count} رسائل. حدِّد القناة المستهدفة. سيتم إنشاء رسالة كعنصر نائب في قناة %{channelTitle} للإشارة إلى أن هذه الرسالة قد تم نقلها." + confirm_move: "نقل الرسائل" + channel_settings: + title: "إعدادات القناة" + edit: "تعديل" + add: "إضافة" + close_channel: "إغلاق القناة" + open_channel: "فتح القناة" + archive_channel: "أرشفة القناة" + delete_channel: "حذف القناة" + join_channel: "الانضمام إلى القناة" + leave_channel: "مغادرة القناة" + join: "الانضمام" + leave: "مغادرة" + channel_archive: + title: "أرشفة القناة" + instructions: "

    تؤدي أرشفة القناة إلى وضعها في وضع القراءة فقط ونقل جميع الرسائل من القناة إلى موضوع جديد أو موجود. لا يمكن إرسال رسائل جديدة، ولا يمكن تعديل أو حذف أي رسائل حالية.

    هل أنت متأكد أنك تريد أرشفة قناة %{channelTitle}؟

    " + process_started: "لقد بدأت عملية الأرشفة. سيتم إغلاق هذا النموذج بعد قليل، وستتلقى رسالة شخصية عند اكتمال عملية الأرشفة." + retry: "إعادة المحاولة" + channel_open: + title: "فتح القناة" + instructions: "يعيد فتح القناة، وسيتمكن جميع المستخدمين من إرسال الرسائل وتعديل رسائلهم الحالية." + channel_close: + title: "إغلاق القناة" + instructions: "يمنع إغلاق القناة المستخدمين من غير فريق العمل من إرسال رسائل جديدة أو تعديل الرسائل الحالية. هل تريد بالتأكيد إغلاق هذه القناة؟" + channel_delete: + title: "حذف القناة" + instructions: "

    يحذف قناة %{name} وسجل الدردشة. سيتم حذف جميع الرسائل والبيانات ذات الصلة، مثل التفاعلات والتحميلات نهائيًا. إذا كنت ترغب في الحفاظ على سجل القناة وإيقاف تشغيلها، فقد ترغب في أرشفة القناة بدلًا من ذلك.

    هل تريد بالتأكيد المتابعة إلى الحذف النهائي للقناة؟ للتأكيد، اكتب اسم القناة في المربع أدناه.

    " + confirm: "أتفهم العواقب، احذف القناة" + confirm_channel_name: "أدخل اسم القناة" + process_started: "لقد بدأت عملية حذف القناة. سيتم إغلاق هذا النموذج بعد قليل، ولن يصبح بإمكانك رؤية القناة المحذوفة في أي مكان." + channels_list_popup: + browse: "استعراض القنوات" + create: "قناة جديدة" + click_to_join: "انقر هنا لعرض القنوات المتاحة" + close: "إغلاق" + collapse: "طي ساحب الدردشة" + confirm_flag: "هل تريد بالتأكيد الإبلاغ عن رسالة %{username}؟" + deleted: "تم حذف رسالة. [view]" + hidden: "تم حذف إحدى الرسائل. [view]" + delete: "حذف" + edited: "تم التعديل" + muted: "تم كتمه" + joined: "انضم" + empty_state: + direct_message_cta: "بدء دردشة شخصية" + direct_message: "يمكنك أيضًا بدء دردشة شخصية مع أحد المستخدمين أو أكثر." + title: "لم يتم العثور على أي قناة" + email_frequency: + description: "سنراسلك عبر البريد الإلكتروني فقط إذا لم نرك في آخر 15 دقيقة." + never: "أبدًا" + title: "إشعارات البريد الإلكتروني" + when_away: "عندما أكون غائبًا فقط" + enable: "تفعيل الدردشة" + flag: "الإبلاغ" + emoji: "إدراج رمز تعبيري" + flagged: "لقد تم الإبلاغ عن الرسالة للمراجعة" + invalid_access: "ليس لديك إذن الوصول لعرض قناة الدردشة هذه" + invitation_notification: "دعاك %{username} للانضمام إلى قناة دردشة" + in_reply_to: "ردًا على" + heading: "الدردشة" + join: "الانضمام" + new_messages: "رسائل جديدة" + mention_warning: + cannot_see: + zero: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + one: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليه." + two: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهما." + few: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + many: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + other: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + dismiss: "تجاهل" + invitations_sent: + zero: "تم إرسال دعوات" + one: "تم إرسال دعوة" + two: "تم إرسال دعوتين" + few: "تم إرسال دعوات" + many: "تم إرسال دعوات" + other: "تم إرسال دعوات" + invite: "دعوة إلى القناة" + without_membership: + zero: "لم ينضم %{usernames} إلى هذه القناة." + one: "لم ينضم %{usernames} إلى هذه القناة." + two: "لم ينضم %{usernames} إلى هذه القناة." + few: "لم ينضم %{usernames} إلى هذه القناة." + many: "لم ينضم %{usernames} إلى هذه القناة." + other: "لم ينضم %{usernames} إلى هذه القناة." + aria_roles: + header: "رأس الدردشة" + composer: "أداة إنشاء الدردشة" + channels_list: "قائمة قنوات الدردشة" + no_public_channels: "لم تنضم إلى أي قنوات." + only_chat_push_notifications: + title: "إرسال الإشعارات الفورية للدردشة فقط" + description: "حظر إرسال جميع الإشعارات الفورية غير المتعلقة بالدردشة" + ignore_channel_wide_mention: + title: "تجاهل الإشارات على مستوى القناة" + description: "عدم إرسال الإشعارات للإشارات على مستوى القناة (@here و@all)" + open: "فتح الدردشة" + open_full_page: "فتح الدردشة في شاشة كاملة" + close_full_page: "إغلاق الدردشة في وضع الشاشة الكاملة" + open_message: "فتح رسالة في الدردشة" + placeholder_self: "تدوين شيء ما" + placeholder_others: "الدردشة مع %{messageRecipient}" + placeholder_new_message_disallowed: "القناة %{status}، لا يمكنك إرسال رسائل جديدة الآن." + placeholder_silenced: "لا يمكنك إرسال رسائل في الوقت الحالي." + placeholder_start_conversation: ابدأ محادثة مع %{usernames} + remove_upload: "إزالة ملف" + react: "التفاعل برمز تعبيري" + reply: "رد" + edit: "تعديل" + copy_link: "نسخ الرابط" + rebake_message: "إعادة بناء HTML" + retry_staged_message: + title: "خطأ في الشبكة" + action: "هل تريد الإرسال مجددًا؟" + unreliable_network: "الشبكة غير موثوقة، وقد لا تعمل ميزتا إرسال الرسائل وحفظ المسودات" + bookmark_message: "إشارة مرجعية" + bookmark_message_edit: "تعديل الإشارة المرجعية" + restore: "استعادة الرسالة المحذوفة" + save: "حفظ" + select: "تحديد" + silence: "كتم المستخدم" + return_to_list: "العودة إلى قائمة القنوات" + scroll_to_bottom: "التمرير إلى الأسفل" + scroll_to_new_messages: "عرض الرسائل الجديدة" + sound: + title: "صوت إشعارات دردشة سطح المكتب" + sounds: + none: "لا يوجد" + bell: "جرس" + ding: "قرع" + title: "دردشة" + title_capitalized: "الدردشة" + upload: "إرفاق ملف" + uploaded_files: + zero: "%{count} ملفًا" + one: "ملف واحد (%{count})" + two: "ملفان (%{count})" + few: "%{count} ملفات" + many: "%{count} ملفًا" + other: "%{count} ملفًا" + you_flagged: "لقد أبلغت عن هذه الرسالة" + exit: "عودة" + channel_status: + read_only_header: "القناة للقراءة فقط" + read_only: "للقراءة فقط" + archived_header: "القناة مؤرشفة" + archived: "مؤرشفة" + archive_failed: "فشلت أرشفة القناة. تمت أرشفة %{completed}/%{total} من الرسائل في الموضوع المستهدف. اضغط على إعادة المحاولة لمحاولة إكمال الأرشيف." + archive_completed: "راجع موضوع الأرشيف" + closed_header: "القناة مغلقة" + closed: "مغلقة" + open_header: "القناة مفتوحة" + open: "مفتوحة" + browse: + back: "العودة" + title: القنوات + filter_all: الكل + filter_open: مفتوحة + filter_closed: مغلقة + filter_archived: مؤرشفة + filter_input_placeholder: البحث عن القناة بالاسم + chat_message_separator: + today: اليوم + yesterday: الأمس + members_view: + filter_placeholder: العثور على أعضاء + about_view: + associated_topic: الموضوع المرتبط + associated_category: الفئة المرتبطة + title: العنوان + description: الوصف + channel_info: + back_to_all_channels: "كل القنوات" + back_to_channel: "العودة" + tabs: + about: نبذة + members: الأعضاء + settings: الإعدادات + channel_edit_title_modal: + title: تعديل العنوان + input_placeholder: إضافة عنوان + description: أعط عنوانًا وصفيًا قصيرًا لقناتك + channel_edit_description_modal: + title: تعديل الوصف + input_placeholder: أضِف وصفًا + description: أخبر الناس عن محتوى هذه القناة + direct_message_creator: + title: رسالة جديدة + prefix: "إلى:" + no_results: لا توجد نتائج + selected_user_title: "إلغاء تحديد %{username}" + channel_selector: + title: "الانتقال إلى القناة" + no_channels: "لا توجد قنوات تطابق بحثك" + channel: + no_memberships: لا يوجد أعضاء في هذه القناة + no_memberships_found: لم يتم العثور على أعضاء + memberships_count: + zero: "%{count} عضوًا" + one: "عضو واحد (%{count})" + two: "عضوان (%{count})" + few: "%{count} أعضاء" + many: "%{count} عضوًا" + other: "%{count} عضوًا" + create_channel: + auto_join_users: + public_category_warning: "%{category} هي فئة عامة. هل تريد إضافة كل المستخدمين النشطين مؤخرًا إلى هذه القناة؟" + warning_groups: + zero: هل تريد إضافة %{members_count} مستخدمًا نشطًا مؤخرًا من %{group} و%{group_2}؟ + one: هل تريد إضافة مستخدم واحد (%{members_count}) نشط مؤخرًا من %{group}؟ + two: هل تريد إضافة مستخدمين (%{members_count}) نشطين مؤخرًا من %{group} و%{group_2}؟ + few: هل تريد إضافة %{members_count} مستخدمين نشطين مؤخرًا من %{group} و%{group_2}؟ + many: هل تريد إضافة %{members_count} مستخدمًا نشطًا مؤخرًا من %{group} و%{group_2}؟ + other: هل تريد إضافة %{members_count} مستخدمًا نشطًا مؤخرًا من %{group} و%{group_2}؟ + warning_multiple_groups: هل تريد إضافة %{members_count} من المستخدمين من %{group_1} و%{count} أخرى؟ + choose_category: + label: "اختيار فئة" + none: "اختر واحدة..." + default_hint: إدارة الوصول بالانتقال إلى %{category} إعدادات الأمان + hint_groups: + zero: سيحظى المستخدمون في %{hint} و%{hint_2} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + one: سيحظى المستخدمون في %{hint} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + two: سيحظى المستخدمون في %{hint} و%{hint_2} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + few: سيحظى المستخدمون في %{hint} و%{hint_2} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + many: سيحظى المستخدمون في %{hint} و%{hint_2} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + other: سيحظى المستخدمون في %{hint} و%{hint_2} بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + hint_multiple_groups: سيحظى المستخدمون في %{hint_1} و%{count} من المجموعات الأخرى بإمكانية الوصول إلى هذه القناة حسب إعدادات الأمان + create: "إنشاء قناة" + description: "الوصف (اختياري)" + name: "اسم القناة" + title: "قناة جديدة" + type: "النوع" + types: + category: "الفئة" + topic: "الموضوع" + reviewable: + type: "رسالة دردشة" + reactions: + only_you: "لقد تفاعلت باستخدام :%{emoji}:" + and_others: "لقد تفاعلت أنت و%{usernames} باستخدام :%{emoji}:" + only_others: "لقد تفاعل %{usernames} باستخدام :%{emoji}:" + others_and_more: "تفاعل %{usernames} و%{more} من المستخدمين باستخدام :%{emoji}:" + you_others_and_more: "لقد تفاعلت أنت و%{usernames} و%{more} من المستخدمين الآخرين باستخدام :%{emoji}:" + composer: + toggle_toolbar: "تشغيل شريط الأدوات" + italic_text: "نص بارز" + bold_text: "نص بارز" + code_text: "نص رمز برمجي" + quote: + original_channel: 'تم إرساله في الأساس في %{channel}' + copy_success: "تم نسخ اقتباس الدردشة إلى الحافظة" + notification_levels: + never: "أبدًا" + mention: "للإشارات فقط" + always: "للنشاط بأكمله" + settings: + enable_auto_join_users: "إضافة كل المستخدمين النشطين مؤخرًا تلقائيًا" + disable_auto_join_users: "إيقاف إضافة المستخدمين تلقائيًا" + auto_join_users_warning: "سينضم كل المستخدمين الذين ليسوا أعضاءً في هذه القناة ولديهم وصول إلى فئة %{category}. هل أنت متأكد؟" + desktop_notification_level: "إشعارات سطح المكتب" + follow: "الانضمام" + followed: "انضم" + mobile_notification_level: "الإشعارات الفورية للجوَّال" + mute: "كتم القناة" + muted_on: "تشغيل" + muted_off: "إيقاف" + notifications: "الإشعارات" + preview: "معاينة" + save: "حفظ" + saved: "تم الحفظ" + unfollow: "مغادرة" + admin: + title: "الدردشة" + direct_messages: + title: "الدردشة الشخصية" + new: "دردشة شخصية جديدة" + create: "إنشاء" + leave: "مغادرة هذه الدردشة الشخصية" + cannot_create: "عذرًا، لا يمكنك إرسال الرسائل المباشرة." + incoming_webhooks: + back: "العودة" + channel_placeholder: "اختيار قناة" + confirm_destroy: "هل تريد بالتأكيد حذف خطاف الويب الوارد هذا؟ لا يمكن التراجع عن هذا الإجراء." + current_emoji: "الرمز التعبيري الحالي" + description: "الوصف" + delete: "حذف" + emoji: "الرمز التعبيري" + emoji_instructions: "سيتم استخدام الصورة الرمزية للنظام إذا تم ترك الرمز التعبيري فارغًا." + name: "الاسم" + name_placeholder: "الاسم..." + new: "خطاف ويب وارد جديد" + none: "لم يتم إنشاء خطافات ويب واردة حالية." + no_emoji: "لم يتم تحديد رمز تعبيري" + post_to: "نشر إلى" + reset_emoji: "إعادة ضبط الرمز التعبيري" + save: "حفظ" + edit: "تعديل" + select_emoji: "اختيار الرمز التعبيري" + system: "النظام" + title: "خطافات الويب الواردة" + url: "عنوان URL" + url_instructions: "يحتوي عنوان URL هذا على قيمة سرية - احتفظ بها في مكانٍ آمن." + username: "اسم المستخدم" + username_instructions: "اسم المستخدم لبرنامج الروبوت الذي ينشر على القناة. يتم ضبطه افتراضيًا على \"النظام\" عند تركه فارغة." + instructions: "يمكن استخدام خطافات الويب الواردة بواسطة أنظمة خارجية لنشر الرسائل في قناة دردشة مخصَّصة كمستخدم برنامج روبوت عبر نقطة النهاية /hooks/:key. يتألف الحمل من معلمة نصية فردية، وهي مقيَّدة إلى 2000 حرف.

    نحن ندعم أيضًا المعلمات النصية بتنسيق Slack، مع استخراج الروابط والإشارات بناءً على التنسيق في https://api.slack.com/reference/surfaces/formatting، لكن يجب استخدام نقطة النهاية /hooks/:key/slack من أجل ذلك." + selection: + cancel: "إلغاء" + quote_selection: "اقتباس في الموضوع" + copy: "نسخ" + move_selection_to_channel: "النقل إلى القناة" + error: "حدث خطأ في أثناء نقل رسائل الدردشة" + title: "نقل الدردشة إلى الموضوع" + new_topic: + title: "النقل إلى موضوع جديد" + instructions: + zero: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + one: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالة الدردشة التي حدَّدتها." + two: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + few: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + many: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + other: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + instructions_channel_archive: "أنت على وشك إنشاء موضوع جديد وأٍِرشفة رسائل القناة إليه." + existing_topic: + title: "النقل إلى موضوع حالي" + instructions: + zero: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + one: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالة الدردشة إليه." + two: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + few: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + many: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + other: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + instructions_channel_archive: "يُرجى اختيار الموضوع الذي ترغب في أرشفة رسائل القناة إليه." + new_message: + title: "النقل إلى رسالة جديدة" + instructions: + zero: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + one: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالة الدردشة التي حدَّدتها." + two: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + few: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + many: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + other: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + replying_indicator: + single_user: "%{username} يكتب" + multiple_users: "%{commaSeparatedUsernames} و%{lastUsername} يكتبون" + many_users: + zero: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + one: "%{commaSeparatedUsernames} و%{count} آخر يكتبان" + two: "%{commaSeparatedUsernames} و%{count} آخران يكتبون" + few: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + many: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + other: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + retention_reminders: + public: "يتم الاحتفاظ بسجل القناة لمدة %{days} من الأيام." + dm: "يتم الاحتفاظ بسجل الدردشة الشخصية لمدة %{days} من الأيام." + topic_button_title: "الدردشة" + flags: + off_topic: "هذه الرسالة ليست ذات صلة بالمناقشة الحالية كما هو محدَّد في عنوان القناة، وربما ينبغي نقلها إلى مكانٍ آخر." + inappropriate: "تحتوي هذه الرسالة على محتوى قد يعتبره الشخص العاقل مسيئًا أو مهينًا أو ينتهك إرشادات المجتمع لدينا." + spam: "هذه الرسالة إعلانية أو تخريبية. إنها ليست مفيدة أو ذات صلة بالقناة الحالية." + notify_user: "أريد التحدث إلى هذا الشخص مباشرةً وشخصيًا بشأن رسالته." + notify_moderators: "تتطلب هذه الرسالة انتباه فريق العمل لسبب آخر غير مُدرَج أعلاه." + flagging: + action: "الإبلاغ عن الرسالة" + emoji_picker: + favorites: "المستخدمة بشكلٍ متكرر" + smileys_&_emotion: "الرموز التعبيرية" + objects: "الأشياء" + people_&_body: "الأشخاص والجسم" + travel_&_places: "السفر والأماكن" + animals_&_nature: "الحيوانات والطبيعة" + food_&_drink: "الطعام والشراب" + activities: "الأنشطة" + flags: "البلاغات" + symbols: "الرموز" + search_placeholder: "البحث باسم الرمز التعبيري والاسم المستعار..." + no_results: "لا توجد نتائج" + draft_channel_screen: + header: "رسالة جديدة" + cancel: "إلغاء" + notifications: + chat_invitation: "دعاك للانضمام إلى قناة دردشة" + chat_invitation_html: "دعاك %{username} للانضمام إلى قناة دردشة" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'أشار إليك في "%{channel}"' + direct_html: 'أشار %{username} إليك في "%{channel}"' + other_plain: 'أشار إلى %{identifier} في "%{channel}"' + other_html: 'أشار %{username} إلى %{identifier} في "%{channel}"' + direct_message_chat_mention: + direct: "أشار إليك في دردشة شخصية" + direct_html: "أشار %{username} إليك في دردشة شخصية" + other_plain: "أشار إلى %{identifier} في دردشة شخصية" + other_html: "أشار %{username} إلى %{identifier} في دردشة شخصية" + chat_message: "رسالة دردشة جديدة" + chat_quoted: "اقتبس %{username} رسالة الدردشة الخاصة بك" + titles: + chat_mention: "إشارة في الدردشة" + chat_invitation: "دعوة الدردشة" + chat_quoted: "تم اقتباس الدردشة" + action_codes: + chat: + enabled: 'فعَّل %{who} في %{when}' + disabled: "أغلق %{who} الدردشة في %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: إرسال رسالة دردشة + fields: + chat_channel_id: + label: معرِّف قناة الدردشة + message: + label: رسالة + sender: + label: المرسل + description: يتم ضبطه افتراضيًا على النظام + review: + transcript: + view: "عرض نص الرسائل السابقة" + types: + reviewable_chat_message: + title: "رسالة دردشة تم الإبلاغ عنها" + flagged_by: "تم الإبلاغ بواسطة" + keyboard_shortcuts_help: + chat: + title: "الدردشة" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} تبديل القناة" + open_quick_channel_selector: "%{shortcut} فتح محدِّد قناة سريع" + open_insert_link_modal: "%{shortcut} إدراج رابط تشعبي (أداة الإنشاء فقط)" + composer_bold: "%{shortcut} غامق (أداة الإنشاء فقط)" + composer_italic: "%{shortcut} مائل (أداة الإنشاء فقط)" + composer_code: "%{shortcut} رمز برمجي (أداة الإنشاء فقط)" + drawer_open: "%{shortcut} فتح درج الدردشة" + drawer_close: "%{shortcut} إغلاق درج الدردشة" + topic_statuses: + chat: + help: "الدردشة مفعَّلة لهذا الموضوع" + user: + allow_private_messages: "السماح للمستخدمين الآخرين بإرسال رسائل شخصية إليَّ ورسائل مباشرة في الدردشة" + muted_users_instructions: "منع كل الإشعارات والرسائل الشخصية والرسائل المباشرة في الدردشة من هؤلاء المستخدمين." + allowed_pm_users_instructions: "اسمح فقط بالرسائل الشخصية أو الرسائل المباشرة في الدردشة من هؤلاء المستخدمين." + allow_private_messages_from_specific_users: "السماح للمستخدمين المحدَّدين فقط بإرسال رسائل شخصية إليَّ أو رسائل مباشرة في الدردشة" + ignored_users_instructions: "منع كل المنشورات والرسائل والإشعارات والرسائل الشخصية والرسائل المباشرة في الدردشة من هؤلاء المستخدمين." + user_menu: + no_chat_notifications_title: "ليس لديك أي إشعارات دردشة حتى الآن" + no_chat_notifications_body: > + سيتم إرسال إشعار إليك في هذه اللوحة عندما يراسلك أحدهم مباشرةً أو يشير إليك @mention في الدردشة. سيتم أيضًا إرسال الإشعارات إلى بريدك الإلكتروني في حال عدم تسجيلك الدخول لفترة من الوقت.

    انقر على العنوان الموجود أعلى أي قناة دردشة لضبط التنبيهات التي تتلقاها في تلك القناة. للمزيد من المعلومات، راجع تفضيلات الإشعارات. + tabs: + chat_notifications: "إشعارات الدردشة" + chat_notifications_with_unread: + zero: "إشعارات الدردشة - %{count} إشعارًا غير مقروء" + one: "إشعارات الدردشة - إشعار واحد (%{count}) غير مقروء" + two: "إشعارات الدردشة - إشعاران (%{count}) غير مقروءين" + few: "إشعارات الدردشة - %{count} إشعارات غير مقروءة" + many: "إشعارات الدردشة - %{count} إشعارًا غير مقروء" + other: "إشعارات الدردشة - %{count} إشعارًا غير مقروء" diff --git a/plugins/chat/config/locales/client.be.yml b/plugins/chat/config/locales/client.be.yml new file mode 100644 index 0000000000..2ad6f3296a --- /dev/null +++ b/plugins/chat/config/locales/client.be.yml @@ -0,0 +1,92 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: + js: + chat: + create: "стварыць" + cancel: "адмяніць" + channel_settings: + edit: "рэдагаваць" + add: "дадаць" + close: "зачыніць" + delete: "выдаляць" + muted: "ігнаруемай" + joined: "рэгістрацыя" + email_frequency: + never: "ніколі" + flag: "Пазначыць" + reply: "Адказаць" + edit: "рэдагаваць" + bookmark_message: "закладка" + save: "захаваць" + exit: "назад" + channel_status: + closed: "Закрыта" + browse: + back: "Назад" + filter_all: Усё + filter_closed: Закрыта + chat_message_separator: + today: сёння + yesterday: учора + about_view: + title: Загаловак + description: Апісанне + channel_info: + back_to_channel: "Назад" + tabs: + about: Аб тэме + members: Удзельнікі + settings: Налады + direct_message_creator: + title: новае паведамленне + prefix: "да:" + create_channel: + type: "тып" + types: + category: "катэгорыя" + topic: "тэма" + composer: + italic_text: "выдзялення тэксту" + bold_text: "Моцнае вылучэнне тэксту" + notification_levels: + never: "ніколі" + settings: + followed: "рэгістрацыя" + notifications: "Натыфікацыі" + preview: "папярэдні прагляд" + save: "захаваць" + saved: "Захавана" + incoming_webhooks: + back: "Назад" + description: "Апісанне" + delete: "выдаляць" + emoji: "Emoji" + name: "імя" + save: "захаваць" + edit: "рэдагаваць" + system: "сістэма" + url: "URL спасылка" + username: "Імя карыстальніка" + selection: + cancel: "адмяніць" + copy: "капіяваць" + new_topic: + title: "Перанос новай тэмы" + existing_topic: + title: "Перанос наяўнай тэмы" + emoji_picker: + flags: "сцягі" + draft_channel_screen: + header: "новае паведамленне" + cancel: "адмяніць" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Паказаць выдаленыя паведамленні... diff --git a/plugins/chat/config/locales/client.bg.yml b/plugins/chat/config/locales/client.bg.yml new file mode 100644 index 0000000000..e4c01859e3 --- /dev/null +++ b/plugins/chat/config/locales/client.bg.yml @@ -0,0 +1,109 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: + js: + chat: + cancel: "Прекрати" + channel_settings: + edit: "Редактирай" + add: "Добави " + join: "Влизане" + leave: "Напусни" + close: "Затвори" + delete: "Изтрий" + muted: "заглуши" + joined: "присъединен" + email_frequency: + never: "Никога" + flag: "Сигнализиране" + join: "Влизане" + mention_warning: + dismiss: "отмени" + reply: "Отговорете" + edit: "Редактирай" + rebake_message: "Прегенерирай HTML " + bookmark_message: "Отметка" + bookmark_message_edit: "Редактиране на отметка" + save: "Запази " + sounds: + none: "Без" + exit: "назад" + channel_status: + closed: "Затворена" + open: "Отвори" + browse: + back: "Назад" + filter_all: Всички + filter_closed: Затворена + chat_message_separator: + today: Днес + yesterday: Вчера + about_view: + title: Заглавие + description: Описание + channel_info: + back_to_channel: "Назад" + tabs: + about: Относно + members: Членове + settings: Настройки + direct_message_creator: + title: Ново Съобщение + prefix: "До:" + create_channel: + type: "Тип" + types: + category: "Категория" + topic: "Тема" + composer: + italic_text: "Подчертан текст" + bold_text: "удебелен текст" + notification_levels: + never: "Никога" + settings: + follow: "Влизане" + followed: "Присъединен" + notifications: "Известия" + save: "Запази " + saved: "Запазено" + unfollow: "Напусни" + incoming_webhooks: + back: "Назад" + description: "Описание" + delete: "Изтрий" + emoji: "Емотикони" + name: "Име" + save: "Запази " + edit: "Редактирай" + system: "система" + url: "URL" + username: "Потребителско име" + selection: + cancel: "Прекрати" + copy: "Копирай" + new_topic: + title: "Премести в нова тема" + existing_topic: + title: "Преместете в съществуваща тема." + emoji_picker: + objects: "Обекти" + activities: "Дейности" + flags: "Сигнали" + symbols: "Символи" + draft_channel_screen: + header: "Ново Съобщение" + cancel: "Прекрати" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Съобщение + review: + types: + reviewable_chat_message: + flagged_by: "Означено от" diff --git a/plugins/chat/config/locales/client.bs_BA.yml b/plugins/chat/config/locales/client.bs_BA.yml new file mode 100644 index 0000000000..cd6aa603aa --- /dev/null +++ b/plugins/chat/config/locales/client.bs_BA.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: + js: + chat: + create: "napravi" + cancel: "Odustani" + channel_settings: + edit: "Edit" + add: "Add" + join: "Učlani se" + leave: "Napusti" + close: "Zatvori" + delete: "Delete" + edited: "promjenjeno" + muted: "utišani" + joined: "pridružio se" + email_frequency: + never: "Nikad" + flag: "Prijavi" + join: "Učlani se" + mention_warning: + dismiss: "odbaci" + reply: "Odgovori" + edit: "Edit" + rebake_message: "Popravi HTML" + bookmark_message: "Sačuvaj" + save: "Save" + sounds: + none: "Ništa" + exit: "prethodno" + channel_status: + closed: "Zatvoreno" + open: "Otvori" + browse: + back: "Prethodno" + filter_all: All + filter_closed: Zatvoreno + chat_message_separator: + today: Today + yesterday: Yesterday + about_view: + title: Naslov + description: Opis + channel_info: + back_to_channel: "Prethodno" + tabs: + about: O nama + members: Članovi + settings: Postavke + direct_message_creator: + title: Nova poruka + prefix: "Za:" + create_channel: + type: "Tip" + types: + category: "Kategorija" + topic: "Topic" + composer: + italic_text: "ukošen tekst" + bold_text: "bold tekst" + notification_levels: + never: "Nikad" + settings: + follow: "Učlani se" + followed: "Pridružio se" + notifications: "Obavijest" + preview: "Pregled" + save: "Save" + saved: "Spašeno" + unfollow: "Napusti" + incoming_webhooks: + back: "Prethodno" + description: "Opis" + delete: "Delete" + emoji: "Emoji" + name: "Ime" + save: "Save" + edit: "Edit" + system: "system" + url: "URL" + username: "Nadimak" + selection: + cancel: "Odustani" + copy: "Copy" + new_topic: + title: "Move to New Topic" + existing_topic: + title: "Move to Existing Topic" + new_message: + title: "Premjesti u novu poruku" + emoji_picker: + objects: "Objekti" + activities: "Aktivnosti" + flags: "Flags" + symbols: "Simboli" + draft_channel_screen: + header: "Nova poruka" + cancel: "Odustani" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Privatna Poruka + review: + types: + reviewable_chat_message: + flagged_by: "Kaznio je" diff --git a/plugins/chat/config/locales/client.ca.yml b/plugins/chat/config/locales/client.ca.yml new file mode 100644 index 0000000000..d5e9407c27 --- /dev/null +++ b/plugins/chat/config/locales/client.ca.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: + js: + chat: + create: "Crea" + cancel: "Cancel·la" + channel_settings: + edit: "Edita" + add: "Afegeix" + join: "Registre" + leave: "Abandona" + close: "Tanca" + delete: "Suprimeix" + edited: "editat" + muted: "silenciat" + joined: "registrat" + email_frequency: + never: "Mai" + flag: "Bandera" + join: "Registre" + mention_warning: + dismiss: "descarta-ho" + reply: "Respon" + edit: "Edita" + rebake_message: "Refés HTML" + bookmark_message: "Preferit" + bookmark_message_edit: "Edita el marcador" + save: "Desa" + sounds: + none: "Cap" + exit: "enrere" + channel_status: + closed: "Tancat" + open: "Obre" + browse: + back: "Enrere" + filter_all: Tot + filter_closed: Tancat + chat_message_separator: + today: Avui + yesterday: Ahir + about_view: + title: Títol + description: Descripció + channel_info: + back_to_channel: "Enrere" + tabs: + about: Quant a + members: Membres + settings: Configuració + direct_message_creator: + title: Missatge nou + prefix: "A:" + create_channel: + type: "Tipus" + types: + category: "Categoria" + topic: "Tema" + composer: + italic_text: "text en cursiva" + bold_text: "text en negreta" + notification_levels: + never: "Mai" + settings: + follow: "Registre" + followed: "Registrat" + notifications: "Notificacions" + preview: "Previsualitza" + save: "Desa" + saved: "Desat" + unfollow: "Abandona" + incoming_webhooks: + back: "Enrere" + description: "Descripció" + delete: "Suprimeix" + emoji: "Emoji" + name: "Nom" + save: "Desa" + edit: "Edita" + system: "sistema" + url: "URL" + username: "Nom d'usuari " + selection: + cancel: "Cancel·la" + copy: "Còpia" + new_topic: + title: "Mou a un tema nou" + existing_topic: + title: "Mou a un tema existent" + new_message: + title: "Mou a missatge nou" + emoji_picker: + objects: "Objectes" + activities: "Activitats" + flags: "Banderes" + symbols: "Símbols" + draft_channel_screen: + header: "Missatge nou" + cancel: "Cancel·la" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Missatge + review: + types: + reviewable_chat_message: + flagged_by: "Marcat amb bandera per" diff --git a/plugins/chat/config/locales/client.cs.yml b/plugins/chat/config/locales/client.cs.yml new file mode 100644 index 0000000000..6948e7b2f5 --- /dev/null +++ b/plugins/chat/config/locales/client.cs.yml @@ -0,0 +1,111 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + js: + chat: + create: "Vytvoř" + cancel: "Zrušit" + channel_settings: + edit: "Upravit" + add: "přidat" + join: "Přidat se ke skupině" + leave: "Opustit skupinu" + close: "Zavřít" + delete: "Smazat" + muted: "ztišení" + joined: "účet vytvořen" + email_frequency: + never: "Nikdy" + flag: "Nahlášení" + join: "Přidat se ke skupině" + mention_warning: + dismiss: "označit jako přečtené" + reply: "Odpověď" + edit: "Upravit" + rebake_message: "Obnovit HTML" + bookmark_message: "Založit" + bookmark_message_edit: "Upravit záložku" + save: "Uložit" + sounds: + none: "Žádná" + exit: "zpět" + channel_status: + closed: "Uzavřeno" + open: "Otevřít" + browse: + back: "Zpět" + filter_all: Celkem + filter_closed: Uzavřeno + chat_message_separator: + today: Dnes + yesterday: Včera + about_view: + title: Nadpis + description: Popis + channel_info: + back_to_channel: "Zpět" + tabs: + about: O fóru + members: Členové + settings: Nastavení + direct_message_creator: + title: Nová zpráva + prefix: "Komu:" + create_channel: + type: "Typ" + types: + category: "Kategorie" + topic: "Témata" + composer: + italic_text: "text kurzívou" + bold_text: "tučný text" + notification_levels: + never: "Nikdy" + settings: + follow: "Přidat se ke skupině" + followed: "Účet vytvořen" + notifications: "Upozornění" + preview: "Náhled" + save: "Uložit" + saved: "Uloženo" + unfollow: "Opustit skupinu" + incoming_webhooks: + back: "Zpět" + description: "Popis" + delete: "Smazat" + emoji: "Smajlíky :)" + name: "Jméno" + save: "Uložit" + edit: "Upravit" + system: "systémové soukromé zprávy" + url: "URL" + username: "Uživatelské jméno" + selection: + cancel: "Zrušit" + copy: "Kopírovat" + new_topic: + title: "Rozdělit téma" + existing_topic: + title: "Sloučit téma" + emoji_picker: + objects: "Objekty" + flags: "Nahlášení" + draft_channel_screen: + header: "Nová zpráva" + cancel: "Zrušit" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Zpráva + review: + types: + reviewable_chat_message: + flagged_by: "Nahlásil" diff --git a/plugins/chat/config/locales/client.da.yml b/plugins/chat/config/locales/client.da.yml new file mode 100644 index 0000000000..3a3edc2944 --- /dev/null +++ b/plugins/chat/config/locales/client.da.yml @@ -0,0 +1,232 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Status for chat-kanal ændret" + chat_channel_delete: "Chat-kanal slettet" + api: + scopes: + descriptions: + chat: + create_message: "Opret en chatbesked i en angivet kanal." + chat: + dates: + time_tiny: "tt:mm" + all_loaded: "Viser alle beskeder" + already_enabled: "Chat er allerede aktiveret på dette emne. Opdater venligst." + disabled_for_topic: "Chat er deaktiveret på dette emne." + bot: "bot" + create: "Opret" + cancel: "Annuller" + cancel_reply: "Annuller svar" + chat_channels: "Kanaler" + channel_settings: + title: "Kanal indstillinger" + edit: "Rediger" + add: "Tilføj" + leave_channel: "Forlad kanal" + join: "Tilslut" + leave: "Forlad" + channel_archive: + title: "Arkivér Kanal" + retry: "Forsøg igen" + channel_open: + title: "Åbn Kanal" + channel_close: + title: "Luk Kanal" + channel_delete: + title: "Slet Kanal" + confirm_channel_name: "Indtast kanalnavn" + channels_list_popup: + browse: "Gennemse kanaler" + click_to_join: "Klik her for at se tilgængelige kanaler." + close: "Luk" + delete: "Slet" + edited: "redigeret" + muted: "stille!" + joined: "tilmeldt" + email_frequency: + never: "Aldrig" + enable: "Aktiver chat" + flag: "Rapportér" + invalid_access: "Du har ikke adgang til at se denne chatkanal" + invitation_notification: "%{username} inviterede dig til at deltage i en chatkanal" + in_reply_to: "Som svar til" + heading: "Chat" + join: "Tilslut" + new_messages: "nye beskeder" + mention_warning: + dismiss: "ignorer Alle" + reply: "Svar" + edit: "Rediger" + rebake_message: "Gendan HTML" + bookmark_message: "Bogmærk" + bookmark_message_edit: "Rediger Bogmærke" + save: "Gem" + select: "Vælg" + sounds: + none: "Ingen" + bell: "Klokke" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Vedhæft en fil" + uploaded_files: + one: "%{count} fil" + other: "%{count} filer" + exit: "tilbage" + channel_status: + read_only_header: "Kanalen er skrivebeskyttet" + read_only: "Kun læsning" + archived_header: "Kanalen er arkiveret" + archived: "Arkiveret" + closed: "Lukket" + open_header: "Kanalen er åben" + open: "Åbn" + browse: + back: "Tilbage" + title: Kanaler + filter_all: Alle + filter_closed: Lukket + filter_archived: Arkiveret + chat_message_separator: + today: I dag + yesterday: I går + about_view: + title: Titel + description: Beskrivelse + channel_info: + back_to_channel: "Tilbage" + tabs: + about: Om + members: Brugere + settings: Indstillinger + direct_message_creator: + title: Ny Besked + prefix: "Til:" + channel_selector: + title: "Hop til kanal" + no_channels: "Ingen kanaler matcher din søgning" + create_channel: + choose_category: + label: "Vælg en kategori" + none: "vælg en..." + create: "Opret kanal" + description: "Beskrivelse (valgfrit)" + name: "Kanal navn" + type: "Type" + types: + category: "Kategori" + topic: "Emne" + reviewable: + type: "Chat besked" + reactions: + only_you: "Du reagerede med :%{emoji}:" + and_others: "Du, %{usernames} reagerede med :%{emoji}:" + only_others: "%{usernames} reagerede med :%{emoji}:" + others_and_more: "%{usernames} og %{more} andre reagerede med :%{emoji}:" + you_others_and_more: "Du, %{usernames} og %{more} andre reagerede med :%{emoji}:" + composer: + italic_text: "kursiv skrift" + bold_text: "fed skrift" + code_text: "kode tekst" + quote: + copy_success: "Chat-citat kopieret til udklipsholderen" + notification_levels: + never: "Aldrig" + settings: + follow: "Tilslut" + followed: "Tilmeldt" + notifications: "Notifikationer" + preview: "Forhåndsvisning" + save: "Gem" + saved: "Gemt" + unfollow: "Forlad" + admin: + title: "Chat" + incoming_webhooks: + back: "Tilbage" + description: "Beskrivelse" + delete: "Slet" + emoji: "Humørikon" + name: "Navn" + save: "Gem" + edit: "Redigér" + system: "system" + url: "URL" + username: "Brugernavn" + selection: + cancel: "Annuller" + quote_selection: "Citat i emne" + copy: "Kopier" + error: "Der opstod en fejl under flytning af chatbeskeder" + title: "Flyt chat til emne" + new_topic: + title: "Flyt til nyt emne" + existing_topic: + title: "Flyt til eksisterende emne" + new_message: + title: "Flyt til ny besked" + replying_indicator: + single_user: "%{username} skriver" + retention_reminders: + public: "Kanalhistorikken gemmes i %{days} dage." + dm: "Personlig chathistorik gemmes i %{days} dage." + topic_button_title: "Chat" + emoji_picker: + objects: "Objekter" + activities: "Aktiviteter" + flags: "Flag" + symbols: "Symboler" + draft_channel_screen: + header: "Ny Besked" + cancel: "Annuller" + notifications: + chat_invitation_html: "%{username} inviterede dig til at deltage i en chatkanal" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct_html: '%{username} nævnte dig i "%{channel}"' + other_html: '%{username} nævnte %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct_html: "%{username} nævnte dig i personlig chat" + other_html: "%{username} nævnte %{identifier} i personlig chat" + chat_message: "Ny chatbesked" + chat_quoted: "%{username} citerede din chatbesked" + titles: + chat_mention: "Chat omtale" + chat_invitation: "Chat invitation" + chat_quoted: "Chat citeret" + action_codes: + chat: + enabled: '%{who} aktiverede %{when}' + disabled: "%{who} lukkede chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Send chatbesked + fields: + chat_channel_id: + label: Chat kanal ID + message: + label: Meddelelse + sender: + label: Afsender + description: Standard er system + review: + types: + reviewable_chat_message: + flagged_by: "Markeret af" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Skift kanal" diff --git a/plugins/chat/config/locales/client.de.yml b/plugins/chat/config/locales/client.de.yml new file mode 100644 index 0000000000..5f387f5c5b --- /dev/null +++ b/plugins/chat/config/locales/client.de.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Chat-Kanal-Status geändert" + chat_channel_delete: "Chat-Kanal gelöscht" + api: + scopes: + descriptions: + chat: + create_message: "Erstelle eine Chat-Nachricht in einem bestimmten Kanal." + about: + chat_messages_count: "Chat-Nachrichten" + chat_channels_count: "Chat-Kanäle" + chat_users_count: "Chat-Benutzer" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Alle Nachrichten werden angezeigt" + already_enabled: "Der Chat ist für dieses Thema bereits aktiviert. Bitte aktualisieren." + disabled_for_topic: "Der Chat ist für dieses Thema deaktiviert." + bot: "Bot" + create: "Erstellen" + cancel: "Abbrechen" + cancel_reply: "Antwort verwerfen" + chat_channels: "Kanäle" + browse_all_channels: "Alle Kanäle durchsuchen" + move_to_channel: + title: "Nachrichten in Kanal verschieben" + instructions: + one: "Du verschiebst %{count} Nachricht. Wähle einen Zielkanal aus. Im Kanal %{channelTitle} wird eine Platzhalter-Nachricht erstellt, um anzuzeigen, dass diese Nachricht verschoben wurde." + other: "Du verschiebst %{count} Nachrichten. Wähle einen Zielkanal aus. Im Kanal %{channelTitle} wird eine Platzhalter-Nachricht erstellt, um anzuzeigen, dass diese Nachrichten verschoben wurden." + confirm_move: "Nachrichten verschieben" + channel_settings: + title: "Kanaleinstellungen" + edit: "Bearbeiten" + add: "Hinzufügen" + close_channel: "Kanal schließen" + open_channel: "Kanal öffnen" + archive_channel: "Kanal archivieren" + delete_channel: "Kanal löschen" + join_channel: "Kanal beitreten" + leave_channel: "Kanal verlassen" + join: "Beitreten" + leave: "Verlassen" + channel_archive: + title: "Kanal archivieren" + instructions: "

    Das Archivieren eines Kanals versetzt ihn in den schreibgeschützten Modus und verschiebt alle Nachrichten aus dem Kanal in ein neues oder vorhandenes Thema. Es können keine neuen Nachrichten gesendet und keine bestehenden Nachrichten bearbeitet oder gelöscht werden.

    Möchtest du den Kanal %{channelTitle} wirklich archivieren?

    " + process_started: "Der Archivierungsprozess hat begonnen. Dieser Modal-Dialog wird in Kürze geschlossen und du erhältst eine persönliche Nachricht, wenn der Archivierungsvorgang abgeschlossen ist." + retry: "Erneut versuchen" + channel_open: + title: "Kanal öffnen" + instructions: "Wenn du den Kanal wieder öffnest, können alle Benutzer Nachrichten senden und ihre bestehenden Nachrichten bearbeiten." + channel_close: + title: "Kanal schließen" + instructions: "Das Schließen des Kanals verhindert, dass Nicht-Teammitglieder neue Nachrichten senden oder bestehende Nachrichten bearbeiten können. Bist du sicher, dass du diesen Kanal schließen möchtest?" + channel_delete: + title: "Kanal löschen" + instructions: "

    Löscht den Kanal %{name} und den Chat-Verlauf. Alle Nachrichten und zugehörigen Daten wie Reaktionen und Uploads werden dauerhaft gelöscht. Wenn du den Kanalverlauf beibehalten und den Kanal nur außer Betrieb nehmen möchtest, kannst du ihn stattdessen archivieren.

    Möchtest du den Kanal wirklich dauerhaft löschen? Gib zur Bestätigung den Namen des Kanals in das Feld unten ein.

    " + confirm: "Ich verstehe die Konsequenzen und möchte den Kanal löschen" + confirm_channel_name: "Kanalnamen eingeben" + process_started: "Der Löschvorgang für den Kanal hat begonnen. Dieser Modal-Dialog wird in Kürze geschlossen und du wirst den gelöschten Kanal nirgendwo mehr sehen." + channels_list_popup: + browse: "Kanäle durchsuchen" + create: "Neuer Kanal" + click_to_join: "Klicke hier, um die verfügbaren Kanäle zu sehen." + close: "Schließen" + collapse: "Chat-Bereich ausblenden" + confirm_flag: "Bist du sicher, dass du die Nachricht von %{username} markieren möchtest?" + deleted: "Eine Nachricht wurde gelöscht. [Anzeigen]" + hidden: "Eine Nachricht wurde ausgeblendet. [Anzeigen]" + delete: "Löschen" + edited: "bearbeitet" + muted: "stummgeschaltet" + joined: "beigetreten" + empty_state: + direct_message_cta: "Persönlichen Chat beginnen" + direct_message: "Du kannst auch einen persönlichen Chat mit einem oder mehreren Benutzern beginnen." + title: "Keine Kanäle gefunden" + email_frequency: + description: "Wir schicken dir nur dann eine E-Mail, wenn wir dich in den letzten 15 Minuten nicht gesehen haben." + never: "Niemals" + title: "E-Mail-Benachrichtigungen" + when_away: "Nur bei Abwesenheit" + enable: "Chat aktivieren" + flag: "Melden" + emoji: "Emoji einfügen" + flagged: "Diese Nachricht wurde zur Überprüfung markiert" + invalid_access: "Du bist nicht befugt, diesen Chat-Kanal anzuzeigen" + invitation_notification: "%{username} hat dich eingeladen, einem Chat-Kanal beizutreten" + in_reply_to: "Als Antwort auf" + heading: "Chat" + join: "Beitreten" + new_messages: "neue Nachrichten" + mention_warning: + cannot_see: + one: "%{usernames} kann nicht auf diesen Kanal zugreifen und wurde nicht benachrichtigt." + other: "%{usernames} können nicht auf diesen Kanal zugreifen und wurden nicht benachrichtigt." + dismiss: "verwerfen" + invitations_sent: + one: "Einladung gesendet" + other: "Einladungen gesendet" + invite: "Zum Kanal einladen" + without_membership: + one: "%{usernames} ist diesem Kanal nicht beigetreten." + other: "%{usernames} sind diesem Kanal nicht beigetreten." + aria_roles: + header: "Chat-Kopfzeile" + composer: "Chat-Composer" + channels_list: "Liste der Chat-Kanäle" + no_public_channels: "Du bist keinem Kanal beigetreten." + only_chat_push_notifications: + title: "Nur Chat-Push-Benachrichtigungen senden" + description: "Alle Nicht-Chat-Push-Benachrichtigungen blockieren und nicht senden" + ignore_channel_wide_mention: + title: "Kanalweite Erwähnungen ignorieren" + description: "Keine Benachrichtigungen für kanalweite Erwähnungen senden (@here und @all)" + open: "Chat öffnen" + open_full_page: "Vollbild-Chat öffnen" + close_full_page: "Vollbild-Chat schließen" + open_message: "Nachricht im Chat öffnen" + placeholder_self: "Etwas notieren" + placeholder_others: "Chat mit %{messageRecipient}" + placeholder_new_message_disallowed: "Der Kanal ist %{status}, du kannst im Moment keine neuen Nachrichten senden." + placeholder_silenced: "Du kannst derzeit keine Nachrichten senden." + placeholder_start_conversation: Beginne eine Unterhaltung mit %{usernames} + remove_upload: "Datei löschen" + react: "Mit Emoji reagieren" + reply: "Antworten" + edit: "Bearbeiten" + copy_link: "Link kopieren" + rebake_message: "HTML neu erstellen" + retry_staged_message: + title: "Netzwerkfehler" + action: "Erneut senden?" + unreliable_network: "Das Netzwerk ist unzuverlässig, das Senden von Nachrichten und das Speichern von Entwürfen funktioniert möglicherweise nicht" + bookmark_message: "Lesezeichen" + bookmark_message_edit: "Lesezeichen bearbeiten" + restore: "Gelöschte Nachricht wiederherstellen" + save: "Speichern" + select: "Auswählen" + silence: "Benutzer stummschalten" + return_to_list: "Zurück zur Kanalliste" + scroll_to_bottom: "Nach unten scrollen" + scroll_to_new_messages: "Neue Nachrichten anzeigen" + sound: + title: "Desktop-Chat-Benachrichtigungston" + sounds: + none: "Keiner" + bell: "Glocke" + ding: "Ding" + title: "Chat" + title_capitalized: "Chat" + upload: "Datei anhängen" + uploaded_files: + one: "%{count} Datei" + other: "%{count} Dateien" + you_flagged: "Du hast diese Nachricht markiert" + exit: "zurück" + channel_status: + read_only_header: "Kanal ist schreibgeschützt" + read_only: "Schreibgeschützt" + archived_header: "Kanal ist archiviert" + archived: "Archiviert" + archive_failed: "Archivieren des Kanals fehlgeschlagen. %{completed}/%{total} Nachrichten wurden im Zielthema archiviert. Drücke auf „Erneut versuchen“, um zu versuchen, die Archivierung abzuschließen." + archive_completed: "Archivthema anzeigen" + closed_header: "Kanal ist geschlossen" + closed: "Geschlossen" + open_header: "Kanal ist offen" + open: "Offen" + browse: + title: Kanäle + filter_all: Alle + filter_open: Offen + filter_closed: Geschlossen + filter_archived: Archiviert + filter_input_placeholder: Kanal nach Namen suchen + chat_message_separator: + today: Heute + yesterday: Gestern + members_view: + filter_placeholder: Mitglieder finden + about_view: + associated_topic: Verknüpftes Thema + associated_category: Verknüpfte Kategorie + title: Titel + description: Beschreibung + channel_info: + back_to_all_channels: "Alle Kanäle" + back_to_channel: "Zurück" + tabs: + about: Über + members: Mitglieder + settings: Einstellungen + channel_edit_title_modal: + title: Titel bearbeiten + input_placeholder: Titel hinzufügen + description: Gib deinem Kanal einen kurzen aussagekräftigen Titel + channel_edit_description_modal: + title: Beschreibung bearbeiten + input_placeholder: Beschreibung hinzufügen + description: Sag den Leuten, worum es in diesem Kanal geht + direct_message_creator: + title: Neue Nachricht + prefix: "An:" + no_results: Keine Ergebnisse + selected_user_title: "%{username} abwählen" + channel_selector: + title: "Zum Kanal springen" + no_channels: "Keine Kanäle entsprechen deiner Suche" + channel: + no_memberships: Dieser Kanal hat keine Mitglieder + no_memberships_found: Keine Mitglieder gefunden + memberships_count: + one: "%{count} Mitglied" + other: "%{count} Mitglieder" + create_channel: + auto_join_users: + public_category_warning: "%{category} ist eine öffentliche Kategorie. Alle kürzlich aktiven Benutzer automatisch zu diesem Kanal hinzufügen?" + warning_groups: + one: Automatisch %{members_count} Benutzer von %{group} hinzufügen? + other: Automatisch %{members_count} Benutzer von %{group} und %{group_2} hinzufügen? + warning_multiple_groups: Automatisch %{members_count} Benutzer von %{group_1} und %{count} anderen hinzufügen? + choose_category: + label: "Kategorie auswählen" + none: "eine auswählen …" + default_hint: Verwalte den Zugang, indem du die Sicherheitseinstellungen für %{category} besuchst + hint_groups: + one: Benutzer in %{hint} haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal + other: Benutzer in %{hint} und %{hint_2} haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal + hint_multiple_groups: Benutzer in %{hint_1} und %{count} anderen Gruppen haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal + create: "Kanal erstellen" + description: "Beschreibung (optional)" + name: "Kanalname" + title: "Neuer Kanal" + type: "Typ" + types: + category: "Kategorie" + topic: "Thema" + reviewable: + type: "Chat-Nachricht" + reactions: + only_you: "Du hast reagiert mit :%{emoji}:" + and_others: "Du, %{usernames} haben reagiert mit :%{emoji}:" + only_others: "%{usernames} haben reagiert mit :%{emoji}:" + others_and_more: "%{usernames} und %{more} andere haben reagiert mit :%{emoji}:" + you_others_and_more: "Du, %{usernames} und %{more} andere haben reagiert mit :%{emoji}:" + composer: + toggle_toolbar: "Symbolleiste umschalten" + italic_text: "hervorgehobener Text" + bold_text: "fett gedruckter Text" + code_text: "Code-Text" + quote: + original_channel: 'Ursprünglich gesendet in %{channel}' + copy_success: "Chat-Zitat in die Zwischenablage kopiert" + notification_levels: + never: "Niemals" + mention: "Nur für Erwähnungen" + always: "Für alle Aktivitäten" + settings: + enable_auto_join_users: "Automatisch alle kürzlich aktiven Benutzer hinzufügen" + disable_auto_join_users: "Benutzer nicht mehr automatisch hinzufügen" + auto_join_users_warning: "Jeder Benutzer, der kein Mitglied dieses Kanals ist und Zugriff auf die Kategorie %{category} hat, wird beitreten. Bist du dir sicher?" + desktop_notification_level: "Desktop-Benachrichtigungen" + follow: "Beitreten" + followed: "Beigetreten" + mobile_notification_level: "Mobile Push-Benachrichtigungen" + mute: "Kanal stummschalten" + muted_on: "An" + muted_off: "Aus" + notifications: "Benachrichtigungen" + preview: "Vorschau" + save: "Speichern" + saved: "Gespeichert" + unfollow: "Verlassen" + admin: + title: "Chat" + direct_messages: + title: "Persönlicher Chat" + new: "Neuer persönlicher Chat" + create: "Los" + leave: "Diesen persönlichen Chat verlassen" + cannot_create: "Du kannst leider keine Direktnachrichten senden." + incoming_webhooks: + back: "Zurück" + channel_placeholder: "Kanal auswählen" + confirm_destroy: "Bist du sicher, dass du diesen eingehenden Webhook löschen willst? Dies kann nicht rückgängig gemacht werden." + current_emoji: "Aktuelles Emoji" + description: "Beschreibung" + delete: "Löschen" + emoji: "Emoji" + emoji_instructions: "Der System-Avatar wird verwendet, wenn Emoji leer gelassen wird." + name: "Name" + name_placeholder: "Name …" + new: "Neuer eingehender Webhook" + none: "Es wurden keine eingehenden Webhooks erstellt." + no_emoji: "Kein Emoji ausgewählt" + post_to: "Veröffentlichen in" + reset_emoji: "Emoji zurücksetzen" + save: "Speichern" + edit: "Bearbeiten" + select_emoji: "Emoji auswählen" + system: "System" + title: "Eingehende Webhooks" + url: "URL" + url_instructions: "Diese URL enthält einen geheimen Wert – bewahre ihn sicher auf." + username: "Benutzername" + username_instructions: "Benutzername des Bots, der etwas im Kanal veröffentlicht. Standardmäßig „System“, wenn das Feld leer gelassen wird." + instructions: "Eingehende Webhooks können von externen Systemen verwendet werden, um Nachrichten als Bot-Benutzer über den Endpunkt /hooks/:key in einem bestimmten Chat-Kanal zu posten. Die Payload besteht aus einem einzigen text-Parameter, der auf 2000 Zeichen begrenzt ist.

    Wir unterstützen auch in begrenztem Umfang Slack-formatierte text-Parameter und extrahieren Links und Erwähnungen basierend auf dem Format unter https://api.slack.com/reference/surfaces/formatting, aber dazu muss der Endpunkt /hooks/:key/slack verwendet werden." + selection: + cancel: "Abbrechen" + quote_selection: "Zitat im Thema" + copy: "Kopieren" + move_selection_to_channel: "In Kanal verschieben" + error: "Beim Verschieben der Chat-Nachrichten ist ein Fehler aufgetreten" + title: "Chat in Thema verschieben" + new_topic: + title: "In neues Thema verschieben" + instructions: + one: "Du bist dabei, ein neues Thema zu erstellen und es mit der ausgewählten Chat-Nachricht zu füllen." + other: "Du bist dabei, ein neues Thema zu erstellen und es mit den %{count} ausgewählten Chat-Nachrichten zu füllen." + instructions_channel_archive: "Du bist dabei, ein neues Thema zu erstellen und die Kanalnachrichten darin zu archivieren." + existing_topic: + title: "In bestehendes Thema verschieben" + instructions: + one: "Bitte wähle das Thema aus, in das du die Chat-Nachricht verschieben möchtest." + other: "Bitte wähle das Thema aus, in das du die %{count} Chat-Nachrichten verschieben möchtest." + instructions_channel_archive: "Bitte wähle das Thema aus, in dem du die Kanalnachrichten archivieren möchtest." + new_message: + title: "In neue Nachricht verschieben" + instructions: + one: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit der ausgewählten Chat-Nachricht zu füllen." + other: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit den %{count} ausgewählten Chat-Nachrichten zu füllen." + replying_indicator: + single_user: "%{username} schreibt" + multiple_users: "%{commaSeparatedUsernames} und %{lastUsername} schreiben" + many_users: + one: "%{commaSeparatedUsernames} und %{count} andere Person schreiben" + other: "%{commaSeparatedUsernames} und %{count} andere Personen schreiben" + retention_reminders: + public: "Der Kanalverlauf wird für %{days} Tage gespeichert." + dm: "Der persönliche Chatverlauf wird für %{days} Tage gespeichert." + topic_button_title: "Chat" + flags: + off_topic: "Diese Nachricht ist für die aktuelle Diskussion im Sinne des Kanaltitels nicht relevant und sollte wahrscheinlich an eine andere Stelle verschoben werden." + inappropriate: "Diese Nachricht enthält Inhalte, die eine vernünftige Person als anstößig, beleidigend oder als Verstoß gegen unsere Community-Richtlinien ansehen würde." + spam: "Diese Nachricht ist Werbung oder Vandalismus. Sie ist nicht nützlich oder relevant für den aktuellen Kanal." + notify_user: "Ich möchte mit dieser Person direkt und persönlich über ihre Nachricht sprechen." + notify_moderators: "Diese Nachricht erfordert die Aufmerksamkeit des Teams aus einem anderen, oben nicht aufgeführten Grund." + flagging: + action: "Nachricht markieren" + emoji_picker: + favorites: "Häufig verwendet" + smileys_&_emotion: "Smileys und Emotionen" + objects: "Objekte" + people_&_body: "Mensch und Körper" + travel_&_places: "Reisen und Orte" + animals_&_nature: "Tiere und Natur" + food_&_drink: "Essen und Trinken" + activities: "Aktivitäten" + flags: "Flaggen" + symbols: "Symbole" + search_placeholder: "Nach Emoji-Namen und -Alias suchen …" + no_results: "Keine Ergebnisse" + draft_channel_screen: + header: "Neue Nachricht" + cancel: "Abbrechen" + notifications: + chat_invitation: "hat dich eingeladen, einem Chat-Kanal beizutreten" + chat_invitation_html: "%{username} hat dich eingeladen, einem Chat-Kanal beizutreten" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'hat dich in „%{channel}“ erwähnt' + direct_html: '%{username} hat dich in „%{channel}“ erwähnt' + other_plain: 'hat %{identifier} in „%{channel}“ erwähnt' + other_html: '%{username} hat %{identifier} in „%{channel}“ erwähnt' + direct_message_chat_mention: + direct: "hat dich im persönlichen Chat erwähnt" + direct_html: "%{username} hat dich im persönlichen Chat erwähnt" + other_plain: "hat %{identifier} im persönlichen Chat erwähnt" + other_html: "%{username} hat %{identifier} im persönlichen Chat erwähnt" + chat_message: "Neue Chat-Nachricht" + chat_quoted: "%{username} hat deine Chat-Nachricht zitiert" + titles: + chat_mention: "Chat-Erwähnung" + chat_invitation: "Chat-Einladung" + chat_quoted: "Chat zitiert" + action_codes: + chat: + enabled: '%{who} hat aktiviert %{when}' + disabled: "%{who} hat Chat geschlossen %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Chat-Nachricht senden + fields: + chat_channel_id: + label: Chat-Kanal-ID + message: + label: Nachricht + sender: + label: Absender + description: Standardmäßig System + review: + transcript: + view: "Transkript früherer Nachrichten anzeigen" + types: + reviewable_chat_message: + title: "Chat-Nachricht markiert" + flagged_by: "Markiert von" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Kanal wechseln" + open_quick_channel_selector: "%{shortcut} Schnellauswahl für Kanäle öffnen" + open_insert_link_modal: "%{shortcut} Hyperlink einfügen (nur Composer)" + composer_bold: "%{shortcut} Fett (nur Composer)" + composer_italic: "%{shortcut} Kursiv (nur Composer)" + composer_code: "%{shortcut} Code (nur Composer)" + drawer_open: "%{shortcut} Chat-Bereich öffnen" + drawer_close: "%{shortcut} Chat-Bereich schließen" + topic_statuses: + chat: + help: "Der Chat ist für dieses Thema aktiviert" + user: + allow_private_messages: "Anderen Benutzern erlauben, mir persönliche Nachrichten und Chat-Direktnachrichten zu senden" + muted_users_instructions: "Alle Benachrichtigungen, persönlichen Nachrichten und Chat-Direktnachrichten von diesen Benutzern unterdrücken." + allowed_pm_users_instructions: "Nur persönliche Nachrichten oder Chat-Direktnachrichten von diesen Benutzern erlauben." + allow_private_messages_from_specific_users: "Nur bestimmten Benutzern erlauben, mir persönliche Nachrichten oder Chat-Direktnachrichten zu senden" + ignored_users_instructions: "Alle Beiträge, Nachrichten, Benachrichtigungen, persönlichen Nachrichten und Chat-Direktnachrichten von diesen Benutzern unterdrücken." + user_menu: + no_chat_notifications_title: "Du hast noch keine Chat-Benachrichtigungen" + no_chat_notifications_body: > + In diesem Bereich wirst du benachrichtigt, wenn dir jemand eine Direktnachricht sendet oder dich im Chat per @ erwähnt. Außerdem werden Benachrichtigungen an deine E-Mail-Adresse geschickt, wenn du dich eine Weile nicht angemeldet hast.

    Klicke auf den Titel oben in einem Chat-Kanal, um festzulegen, welche Benachrichtigungen du in diesem Kanal erhältst. Weiteres findest du in deinen Benachrichtigungseinstellungen. + tabs: + chat_notifications: "Chat-Benachrichtigungen" + chat_notifications_with_unread: + one: "Chat-Benachrichtigungen – %{count} ungelesene Benachrichtigung" + other: "Chat-Benachrichtigungen – %{count} ungelesene Benachrichtigungen" diff --git a/plugins/chat/config/locales/client.el.yml b/plugins/chat/config/locales/client.el.yml new file mode 100644 index 0000000000..595dd09798 --- /dev/null +++ b/plugins/chat/config/locales/client.el.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: + js: + chat: + create: "Δημιουργία" + cancel: "Ακύρωση" + channel_settings: + edit: "Επεξεργασία" + add: "Προσθήκη" + join: "Γίνετε μέλος" + leave: "Αποχώρηση" + close: "Κλείσιμο" + delete: "Σβήσιμο" + edited: "επεξεργάστηκε" + muted: "σε σίγαση" + joined: "έγινε μέλος" + email_frequency: + never: "Ποτέ" + flag: "Επισήμανση" + join: "Γίνετε μέλος" + mention_warning: + dismiss: "απόρριψη" + reply: "Απάντηση" + edit: "Επεξεργασία" + rebake_message: "Ανανέωση HTML" + bookmark_message: "Σελιδοδείκτης" + save: "Αποθήκευση" + sounds: + none: "Κανένα" + exit: "πίσω" + channel_status: + closed: "Κλειστό" + open: "Ξεκίνημα" + browse: + back: "Πίσω" + filter_all: Όλα + filter_closed: Κλειστό + chat_message_separator: + today: Σήμερα + yesterday: Χτες + about_view: + title: Τίτλος + description: Περιγραφή + channel_info: + back_to_channel: "Πίσω" + tabs: + about: Σχετικά + members: Μέλη + settings: Ρυθμίσεις + direct_message_creator: + title: Νέο Μήνυμα + prefix: "Προς:" + create_channel: + type: "Τύπος" + types: + category: "Κατηγορία" + topic: "Νήμα" + composer: + italic_text: "κείμενο σε έμφαση" + bold_text: "έντονη γραφή" + notification_levels: + never: "Ποτέ" + settings: + follow: "Γίνετε μέλος" + followed: "Έγινε μέλος" + notifications: "Ειδοποιήσεις" + preview: "Προεπισκόπηση" + save: "Αποθήκευση" + saved: "Αποθηκεύτηκε! " + unfollow: "Αποχώρηση" + incoming_webhooks: + back: "Πίσω" + description: "Περιγραφή" + delete: "Σβήσιμο" + emoji: "Emoji" + name: "Όνομα" + save: "Αποθήκευση" + edit: "Επεξεργασία" + system: "σύστημα" + url: "URL" + username: "Όνομα Χρήστη" + selection: + cancel: "Ακύρωση" + copy: "Αντιγραφή" + new_topic: + title: "Μεταφορά σε Νέο Νήμα " + existing_topic: + title: "Μεταφορά σε Υφιστάμενο Νήμα" + new_message: + title: "Μετακίνηση σε νέο μήνυμα" + emoji_picker: + objects: "Αντικείμενα" + activities: "Δραστηριότητες" + flags: "Σημάνσεις" + symbols: "Σύμβολα" + draft_channel_screen: + header: "Νέο Μήνυμα" + cancel: "Ακύρωση" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Μήνυμα + review: + types: + reviewable_chat_message: + flagged_by: "Επισήμανση από" diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml new file mode 100644 index 0000000000..a96c01e29f --- /dev/null +++ b/plugins/chat/config/locales/client.en.yml @@ -0,0 +1,485 @@ +en: + js: + admin: + site_settings: + categories: + chat: Chat + logs: + staff_actions: + actions: + chat_channel_status_change: "Chat channel status changed" + chat_channel_delete: "Chat channel deleted" + api: + scopes: + descriptions: + chat: + create_message: "Create a chat message in a specified channel." + about: + chat_messages_count: "Chat Messages" + chat_channels_count: "Chat Channels" + chat_users_count: "Chat Users" + + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Showing all messages" + already_enabled: "Chat is already enabled on this topic. Please refresh." + disabled_for_topic: "Chat is disabled on this topic." + bot: "bot" + create: "Create" + cancel: "Cancel" + cancel_reply: "Cancel reply" + chat_channels: "Channels" + browse_all_channels: "Browse all channels" + move_to_channel: + title: "Move messages to channel" + instructions: + one: "You are moving %{count} message. Select a destination channel. A placeholder message will be created in the %{channelTitle} channel to indicate that this message has been moved." + other: "You are moving %{count} messages. Select a destination channel. A placeholder message will be created in the %{channelTitle} channel to indicate that these messages have been moved." + confirm_move: "Move Messages" + channel_settings: + title: "Channel settings" + edit: "Edit" + add: "Add" + close_channel: "Close channel" + open_channel: "Open channel" + archive_channel: "Archive channel" + delete_channel: "Delete channel" + join_channel: "Join channel" + leave_channel: "Leave channel" + join: "Join" + leave: "Leave" + save_label: + mute_channel: "Mute channel preference saved" + desktop_notification: "Desktop notification preference saved" + mobile_notification: "Mobile push notification preference saved" + channel_archive: + title: "Archive Channel" + instructions: "

    Archiving a channel puts it into read-only mode and moves all messages from the channel into a new or existing topic. No new messages can be sent, and no existing messages can be edited or deleted.

    Are you sure you want to archive the %{channelTitle} channel?

    " + process_started: "Archiving process has started. This modal will close shortly, and you will receive a personal message when the archive process is complete." + retry: "Retry" + channel_open: + title: "Open Channel" + instructions: "Reopens the channel, all users will be able to send messages and edit their existing messages." + channel_close: + title: "Close Channel" + instructions: "Closing the channel prevents non-staff users from sending new messages or editing existing messages. Are you sure you want to close this channel?" + channel_delete: + title: "Delete Channel" + instructions: "

    Deletes the %{name} channel and chat history. All messages and related data, such as reactions and uploads, will be permanently deleted. If you want to preserve the channel history and decomission it, you may want to archive the channel instead.

    +

    Are you sure you want to permanently delete the channel? To confirm, type the name of the channel in the box below.

    " + confirm: "I understand the consequences, delete the channel" + confirm_channel_name: "Enter channel name" + process_started: "The process to delete the channel has started. This modal will close shortly, you will no longer see the deleted channel anywhere." + channels_list_popup: + browse: "Browse channels" + create: "New channel" + + click_to_join: "Click here to view available channels." + close: "Close" + collapse: "Collapse Chat Drawer" + confirm_flag: "Are you sure you want to flag %{username}'s message?" + deleted: "A message was deleted. [view]" + hidden: "A message was hidden. [view]" + delete: "Delete" + edited: "edited" + muted: "muted" + joined: "joined" + empty_state: + direct_message_cta: "Start a personal Chat" + direct_message: "You can also start a personal chat with one or more users." + title: "No channels found" + email_frequency: + description: "We'll only email you if we haven't seen you in the last 15 minutes." + never: "Never" + title: "Email Notifications" + when_away: "Only when away" + enable: "Enable chat" + flag: "Flag" + emoji: "Insert emoji" + flagged: "This message has been flagged for review" + invalid_access: "You don't have access to view this chat channel" + invitation_notification: "%{username} invited you to join a chat channel" + in_reply_to: "In reply to" + heading: "Chat" + join: "Join" + new_messages: "new messages" + mention_warning: + cannot_see: + one: "%{usernames} cannot access this channel and was not notified." + other: "%{usernames} cannot access this channel and were not notified." + dismiss: "dismiss" + invitations_sent: + one: "Invitation sent" + other: "Invitations sent" + invite: "Invite to channel" + without_membership: + one: "%{usernames} has not joined this channel." + other: "%{usernames} have not joined this channel." + aria_roles: + header: "Chat header" + composer: "Chat composer" + channels_list: "Chat channels list" + + no_public_channels: "You have not joined any channels." + only_chat_push_notifications: + title: "Only send chat push notifications" + description: "Block all non-chat push notifications from being sent" + ignore_channel_wide_mention: + title: "Ignore channel-wide mentions" + description: "Do not send notifications for channel-wide mentions (@here and @all)" + open: "Open chat" + open_full_page: "Open full-screen chat" + close_full_page: "Close full-screen chat" + open_message: "Open message in chat" + placeholder_self: "Jot something down" + placeholder_others: "Chat with %{messageRecipient}" + placeholder_new_message_disallowed: "Channel is %{status}, you cannot send new messages right now." + placeholder_silenced: "You cannot send messages at this time." + placeholder_start_conversation: Start a conversation with %{usernames} + remove_upload: "Remove file" + react: "React with emoji" + reply: "Reply" + edit: "Edit" + copy_link: "Copy link" + rebake_message: "Rebuild HTML" + retry_staged_message: + title: "Network error" + action: "Send again?" + unreliable_network: "Network is unreliable, sending messages and saving draft might not work" + bookmark_message: "Bookmark" + bookmark_message_edit: "Edit Bookmark" + restore: "Restore deleted message" + save: "Save" + select: "Select" + silence: "Silence user" + return_to_list: "Return to channels list" + scroll_to_bottom: "Scroll to bottom" + scroll_to_new_messages: "See new messages" + sound: + title: "Desktop chat notification sound" + sounds: + none: "None" + bell: "Bell" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Attach a file" + uploaded_files: + one: "%{count} file" + other: "%{count} files" + you_flagged: "You flagged this message" + exit: "back" + channel_status: + read_only_header: "Channel is read only" + read_only: "Read Only" + archived_header: "Channel is archived" + archived: "Archived" + archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived in the destination topic. Press retry to attempt to complete the archive." + archive_completed: "See the archive topic" + closed_header: "Channel is closed" + closed: "Closed" + open_header: "Channel is open" + open: "Open" + + browse: + back: "Back" + title: Channels + filter_all: All + filter_open: Opened + filter_closed: Closed + filter_archived: Archived + filter_input_placeholder: Search channel by name + + chat_message_separator: + today: Today + yesterday: Yesterday + + members_view: + filter_placeholder: Find members + + about_view: + associated_topic: Linked topic + associated_category: Linked category + title: Title + description: Description + + channel_info: + back_to_all_channels: "All channels" + back_to_channel: "Back" + tabs: + about: About + members: Members + settings: Settings + + channel_edit_title_modal: + title: Edit title + input_placeholder: Add a title + description: Give a short descriptive title to your channel + + channel_edit_description_modal: + title: Edit description + input_placeholder: Add a description + description: Tell people what this channel is all about + + direct_message_creator: + title: New Message + prefix: "To:" + no_results: No results + selected_user_title: "Deselect %{username}" + + channel_selector: + title: "Jump to channel" + no_channels: "No channels match your search" + + channel: + no_memberships: This channel has no members + no_memberships_found: No members found + memberships_count: + one: "%{count} member" + other: "%{count} members" + + create_channel: + auto_join_users: + public_category_warning: "%{category} is a public category. Automatically add all recently active users to this channel?" + warning_groups: + one: Automatically add %{members_count} users from %{group}? + other: Automatically add %{members_count} users from %{group} and %{group_2}? + warning_multiple_groups: Automatically add %{members_count} users from %{group_1} and %{count} others? + choose_category: + label: "Choose a category" + none: "select one..." + default_hint: Manage access by visiting %{category} security settings + hint_groups: + one: Users in %{hint} will have access to this channel per the security settings + other: Users in %{hint} and %{hint_2} will have access to this channel per the security settings + hint_multiple_groups: Users in %{hint_1} and %{count} other groups will have access to this channel per the security settings + create: "Create channel" + description: "Description (optional)" + name: "Channel name" + title: "New channel" + type: "Type" + types: + category: "Category" + topic: "Topic" + + reviewable: + type: "Chat message" + + reactions: + only_you: "You reacted with :%{emoji}:" + and_others: "You, %{usernames} reacted with :%{emoji}:" + only_others: "%{usernames} reacted with :%{emoji}:" + others_and_more: "%{usernames} and %{more} others reacted with :%{emoji}:" + you_others_and_more: "You, %{usernames} and %{more} others reacted with :%{emoji}:" + + composer: + toggle_toolbar: "Toggle toolbar" + italic_text: "emphasized text" + bold_text: "strong text" + code_text: "code text" + + quote: + original_channel: 'Originally sent in %{channel}' + copy_success: "Chat quote copied to clipboard" + + notification_levels: + never: "Never" + mention: "Only for mentions" + always: "For all activity" + + settings: + enable_auto_join_users: "Automatically add all recently active users" + disable_auto_join_users: "Stop automatically adding users" + auto_join_users_warning: "Every user who isn't a member of this channel and has access to the %{category} category will join. Are you sure?" + desktop_notification_level: "Desktop notifications" + follow: "Join" + followed: "Joined" + mobile_notification_level: "Mobile push notifications" + mute: "Mute channel" + muted_on: "On" + muted_off: "Off" + notifications: "Notifications" + preview: "Preview" + save: "Save" + saved: "Saved" + unfollow: "Leave" + + admin: + title: "Chat" + + direct_messages: + title: "Personal chat" + new: "New personal chat" + create: "Go" + leave: "Leave this personal chat" + cannot_create: "Sorry, you cannot send direct messages." + + incoming_webhooks: + back: "Back" + channel_placeholder: "Select a channel" + confirm_destroy: "Are you sure you want to delete this incoming webhook? This cannot be un-done." + current_emoji: "Current Emoji" + description: "Description" + delete: "Delete" + emoji: "Emoji" + emoji_instructions: "System avatar will be used if emoji is left blank." + name: "Name" + name_placeholder: "name..." + new: "New incoming webhook" + none: "No existing incoming webhooks created." + no_emoji: "No Emoji selected" + post_to: "Post to" + reset_emoji: "Reset Emoji" + save: "Save" + edit: "Edit" + select_emoji: "Choose Emoji" + system: "system" + title: "Incoming webhooks" + url: "URL" + url_instructions: "This URL contains a secret value - keep it safe." + username: "Username" + username_instructions: "Username of bot that posts to channel. Defaults to 'system' when left blank." + instructions: "Incoming webhooks can be used by external systems to post messages into a designated chat channel as a bot user via the /hooks/:key endpoint. The payload consists of a single text parameter, which is limited to 2000 characters.

    We also support limited Slack-formatted text parameters, extracting links and mentions based on the format at https://api.slack.com/reference/surfaces/formatting, but the /hooks/:key/slack endpoint must be used for this." + + selection: + cancel: "Cancel" + quote_selection: "Quote in Topic" + copy: "Copy" + move_selection_to_channel: "Move to Channel" + error: "There was an error moving the chat messages" + title: "Move Chat to Topic" + new_topic: + title: "Move to New Topic" + instructions: + one: "You are about to create a new topic and populate it with the chat message you've selected." + other: "You are about to create a new topic and populate it with the %{count} chat messages you've selected." + instructions_channel_archive: "You are about to create a new topic and archive the channel messages to it." + existing_topic: + title: "Move to Existing Topic" + instructions: + one: "Please choose the topic you'd like to move that chat message to." + other: "Please choose the topic you'd like to move those %{count} chat messages to." + instructions_channel_archive: "Please choose the topic you'd like to archive the channel messages to." + new_message: + title: "Move to New Message" + instructions: + one: "You are about to create a new message and populate it with the chat message you've selected." + other: "You are about to create a new message and populate it with the %{count} chat messages you've selected." + + replying_indicator: + single_user: "%{username} is typing" + multiple_users: "%{commaSeparatedUsernames} and %{lastUsername} are typing" + many_users: + one: "%{commaSeparatedUsernames} and %{count} other are typing" + other: "%{commaSeparatedUsernames} and %{count} others are typing" + + retention_reminders: + public: "Channel history is retained for %{days} days." + dm: "Personal chat history is retained for %{days} days." + + flags: + off_topic: "This message is not relevant to the current discussion as defined by the channel title, and should probably be moved elsewhere." + inappropriate: "This message contains content that a reasonable person would consider offensive, abusive, or a violation of our community guidelines." + spam: "This message is an advertisement, or vandalism. It is not useful or relevant to the current channel." + notify_user: "I want to talk to this person directly and personally about their message." + notify_moderators: "This message requires staff attention for another reason not listed above." + + flagging: + action: "Flag message" + + emoji_picker: + favorites: "Frequently used" + smileys_&_emotion: "Smileys and emotion" + objects: "Objects" + people_&_body: "People and body" + travel_&_places: "Travel and places" + animals_&_nature: "Animals and nature" + food_&_drink: "Food and drink" + activities: "Activities" + flags: "Flags" + symbols: "Symbols" + search_placeholder: "Search by emoji name and alias..." + no_results: "No results" + + draft_channel_screen: + header: "New Message" + cancel: "Cancel" + notifications: + chat_invitation: "invited you to join a chat channel" + chat_invitation_html: "%{username} invited you to join a chat channel" + chat_quoted: "%{username} %{description}" + + popup: + chat_mention: + direct: 'mentioned you in "%{channel}"' + direct_html: '%{username} mentioned you in "%{channel}"' + other_plain: 'mentioned %{identifier} in "%{channel}"' + other_html: '%{username} mentioned %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "mentioned you in personal chat" + direct_html: "%{username} mentioned you in personal chat" + other_plain: "mentioned %{identifier} in personal chat" + other_html: "%{username} mentioned %{identifier} in personal chat" + chat_message: "New chat message" + chat_quoted: "%{username} quoted your chat message" + + titles: + chat_mention: "Chat mention" + chat_invitation: "Chat invitation" + chat_quoted: "Chat quoted" + action_codes: + chat: + enabled: '%{who} enabled %{when}' + disabled: "%{who} closed chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Send chat message + fields: + chat_channel_id: + label: Chat channel ID + message: + label: Message + sender: + label: Sender + description: Defaults to system + review: + transcript: + view: "View previous messages transcript" + types: + reviewable_chat_message: + title: "Flagged Chat Message" + flagged_by: "Flagged By" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Switch channel" + open_quick_channel_selector: "%{shortcut} Open quick channel selector" + open_insert_link_modal: "%{shortcut} Insert hyperlink (composer only)" + composer_bold: "%{shortcut} Bold (composer only)" + composer_italic: "%{shortcut} Italic (composer only)" + composer_code: "%{shortcut} Code (composer only)" + drawer_open: "%{shortcut} Open chat drawer" + drawer_close: "%{shortcut} Close chat drawer" + topic_statuses: + chat: + help: "Chat is enabled for this topic" + user: + allow_private_messages: "Allow other users to send me personal messages and chat direct messages" + muted_users_instructions: "Suppress all notifications, personal messages, and chat direct messages from these users." + allowed_pm_users_instructions: "Only allow personal messages or chat direct messages from these users." + allow_private_messages_from_specific_users: "Only allow specific users to send me personal messages or chat direct messages" + ignored_users_instructions: "Suppress all posts, messages, notifications, personal messages, and chat direct messages from these users." + user_menu: + no_chat_notifications_title: "You don’t have any chat notifications yet" + no_chat_notifications_body: > + You will be notified in this panel when someone direct messages you or @mentions you in chat. Notifications will also be sent to your email when you haven’t logged in for a while. +

    + Click the title at the top of any chat channel to configure what notifications you receive in that channel. For more, see your notification preferences. + tabs: + chat_notifications: "Chat notifications" + chat_notifications_with_unread: + one: "Chat notifications - %{count} unread notification" + other: "Chat notifications - %{count} unread notifications" diff --git a/plugins/chat/config/locales/client.en_GB.yml b/plugins/chat/config/locales/client.en_GB.yml new file mode 100644 index 0000000000..4e05263133 --- /dev/null +++ b/plugins/chat/config/locales/client.en_GB.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: + js: + chat: + composer: + italic_text: "emphasised text" diff --git a/plugins/chat/config/locales/client.es.yml b/plugins/chat/config/locales/client.es.yml new file mode 100644 index 0000000000..bd83e3fe72 --- /dev/null +++ b/plugins/chat/config/locales/client.es.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Se ha cambiado el estado del canal de chat" + chat_channel_delete: "Canal de chat eliminado" + api: + scopes: + descriptions: + chat: + create_message: "Crea un mensaje de chat en un canal especificado." + about: + chat_messages_count: "Mensajes de chat" + chat_channels_count: "Canales de chat" + chat_users_count: "Usuarios del chat" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostrando todos los mensajes" + already_enabled: "El chat ya está activado en este tema. Actualiza." + disabled_for_topic: "El chat está deshabilitado en este tema." + bot: "bot" + create: "Crear" + cancel: "Cancelar" + cancel_reply: "Cancelar respuesta" + chat_channels: "Canales" + browse_all_channels: "Buscar todos los canales" + move_to_channel: + title: "Mover los mensajes al canal" + instructions: + one: "Estás moviendo %{count} mensaje. Selecciona un canal de destino. Se creará un mensaje marcador de posición en el canal %{channelTitle} para indicar que se ha movido este mensaje." + other: "Estás moviendo %{count} mensajes. Selecciona un canal de destino. Se creará un mensaje marcador de posición en el canal %{channelTitle} para indicar que se han movido estos mensajes." + confirm_move: "Mover mensajes" + channel_settings: + title: "Ajustes del canal" + edit: "Editar" + add: "Añade" + close_channel: "Cerrar canal" + open_channel: "Abrir canal" + archive_channel: "Archivar canal" + delete_channel: "Eliminar canal" + join_channel: "Unirse al canal" + leave_channel: "Abandonar canal" + join: "Unirse" + leave: "Abandonar" + channel_archive: + title: "Archivar canal" + instructions: "

    Archivar un canal lo pone en modo de solo lectura y mueve todos los mensajes del canal a un tema nuevo o existente. No se pueden enviar mensajes nuevos y no se pueden editar ni eliminar mensajes existentes.

    ¿Seguro que desea archivar el canal %{channelTitle} ?

    " + process_started: "El proceso de archivado ha comenzado. Este modal se cerrará en breve, y recibirás un mensaje personal cuando el proceso de archivo haya finalizado." + retry: "Reintentar" + channel_open: + title: "Abrir canal" + instructions: "Reabre el canal, todos los usuarios podrán enviar mensajes y editar los existentes." + channel_close: + title: "Cerrar canal" + instructions: "El cierre del canal impide que los usuarios que no son del personal envíen nuevos mensajes o editen los existentes. ¿Seguro que quieres cerrar este canal?" + channel_delete: + title: "Eliminar canal" + instructions: "

    Elimina el canal de %{name} y el historial de chat. Todos los mensajes y datos relacionados, como las reacciones y las subidas, se eliminarán permanentemente. Si quieres conservar el historial del canal y descomponerlo, quizá quieras archivar el canal en su lugar.

    ¿Seguro que quieres eliminar permanentemente el canal? Para confirmarlo, escribe el nombre del canal en la casilla de abajo.

    " + confirm: "Comprendo las consecuencias, eliminar el canal" + confirm_channel_name: "Introduce el nombre del canal" + process_started: "Se ha iniciado el proceso de eliminación del canal. Este modal se cerrará en breve, ya no verás el canal eliminado en ninguna parte." + channels_list_popup: + browse: "Examinar canales" + create: "Nuevo canal" + click_to_join: "Haz clic aquí para ver los canales disponibles." + close: "Cerrar" + collapse: "Contraer contenedor del chat" + confirm_flag: "¿Seguro que quieres denunciar el mensaje de %{username}?" + deleted: "Se eliminó un mensaje. [view]" + hidden: "Se ha ocultado un mensaje. [ver]" + delete: "Eliminar" + edited: "editado" + muted: "silenciado" + joined: "se unió" + empty_state: + direct_message_cta: "Iniciar un chat personal" + direct_message: "También puedes iniciar un chat personal con uno o más usuarios." + title: "No se han encontrado canales" + email_frequency: + description: "Solo te enviaremos un correo electrónico si no te hemos visto en los últimos 15 minutos." + never: "Nunca" + title: "Notificaciones por correo electrónico" + when_away: "Solo cuando estés ausente" + enable: "Habilitar chat" + flag: "Denunciar" + emoji: "Insertar emoji" + flagged: "Este mensaje ha sido denunciado y se someterá a revisión" + invalid_access: "No tienes acceso para ver este canal de chat" + invitation_notification: "%{username} te ha invitado a unirte a un canal de chat" + in_reply_to: "En respuesta a" + heading: "Chat" + join: "Unirse" + new_messages: "nuevos mensajes" + mention_warning: + cannot_see: + one: "%{usernames} no puede acceder a este canal y no fue notificado." + other: "%{usernames} no pueden acceder a este canal y no fueron notificados." + dismiss: "descartar" + invitations_sent: + one: "Invitación enviada" + other: "Invitaciones enviadas" + invite: "Invitar al canal" + without_membership: + one: "%{usernames} no se ha unido a este canal." + other: "%{usernames} no se han unido a este canal." + aria_roles: + header: "Encabezado del chat" + composer: "Compositor del chat" + channels_list: "Lista de canales de chat" + no_public_channels: "No te has unido a ningún canal." + only_chat_push_notifications: + title: "Enviar solo notificaciones de chat" + description: "Bloquear el envío de todas las notificaciones que no sean de chat" + ignore_channel_wide_mention: + title: "Ignorar las menciones de todo el canal" + description: "No enviar notificaciones para las menciones de todo el canal (@aquí y @todos)" + open: "Abrir chat" + open_full_page: "Abrir chat en pantalla completa" + close_full_page: "Cerrar el chat a pantalla completa" + open_message: "Abrir mensaje en el chat" + placeholder_self: "Anota algo" + placeholder_others: "Chatear con %{messageRecipient}" + placeholder_new_message_disallowed: "El canal es %{status}, no puedes enviar nuevos mensajes en este momento." + placeholder_silenced: "No puedes enviar mensajes en este momento." + placeholder_start_conversation: Inicia una conversación con %{usernames} + remove_upload: "Eliminar archivo" + react: "Reaccionar con emojis" + reply: "Responder" + edit: "Editar" + copy_link: "Copiar enlace" + rebake_message: "Reconstruir HTML" + retry_staged_message: + title: "Error de red" + action: "¿Enviar de nuevo?" + unreliable_network: "La red no es fiable, el envío de mensajes y el guardado de borradores pueden no funcionar" + bookmark_message: "Marcador" + bookmark_message_edit: "Editar marcador" + restore: "Restaurar mensaje eliminado" + save: "Guardar" + select: "Seleccionar" + silence: "Silencio usuario" + return_to_list: "Volver a la lista de canales" + scroll_to_bottom: "Desplazar hacia abajo" + scroll_to_new_messages: "Ver nuevos mensajes" + sound: + title: "Sonido de notificación de chat de escritorio" + sounds: + none: "Ninguno" + bell: "Campana" + ding: "Timbre" + title: "chat" + title_capitalized: "Chat" + upload: "Adjuntar un archivo" + uploaded_files: + one: "%{count} archivo" + other: "%{count} archivos" + you_flagged: "Has denunciado este mensaje" + exit: "atrás" + channel_status: + read_only_header: "El canal es de solo lectura" + read_only: "Solo lectura" + archived_header: "El canal está archivado" + archived: "Archivado" + archive_failed: "El canal de archivo ha fallado. Se han archivado %{completed}/%{total} mensajes en el tema de destino. Pulsa reintentar para intentar completar el archivo." + archive_completed: "Ver el tema del archivo" + closed_header: "El canal está cerrado" + closed: "Cerrado" + open_header: "El canal está abierto" + open: "Abierto" + browse: + title: Canales + filter_all: Todos + filter_open: Abierto + filter_closed: Cerrado + filter_archived: Archivado + filter_input_placeholder: Busca el canal por su nombre + chat_message_separator: + today: Hoy + yesterday: Ayer + members_view: + filter_placeholder: Buscar miembros + about_view: + associated_topic: Tema vinculado + associated_category: Categoría vinculada + title: Título + description: Descripción + channel_info: + back_to_all_channels: "Todos los canales" + back_to_channel: "Atrás" + tabs: + about: Acerca de + members: Miembros + settings: Ajustes + channel_edit_title_modal: + title: Editar título + input_placeholder: Añade un título + description: Pon un título corto y descriptivo a tu canal + channel_edit_description_modal: + title: Editar descripción + input_placeholder: Añade una descripción + description: Cuéntale a la gente de qué se trata este canal + direct_message_creator: + title: Nuevo mensaje + prefix: "Para:" + no_results: No hay resultados + selected_user_title: "Deseleccionar %{username}" + channel_selector: + title: "Ir al canal" + no_channels: "Ningún canal coincide con tu búsqueda" + channel: + no_memberships: Este canal no tiene miembros + no_memberships_found: No se ha encontrado ningún miembro + memberships_count: + one: "%{count} miembro" + other: "%{count} miembros" + create_channel: + auto_join_users: + public_category_warning: "%{category} es una categoría pública. ¿Añadir automáticamente a este canal a todos los usuarios activos recientemente?" + warning_groups: + one: '¿Añadir automáticamente %{members_count} usuarios de %{group}?' + other: '¿Añadir automáticamente %{members_count} usuarios de %{group} y %{group_2}?' + warning_multiple_groups: '¿Añadir automáticamente %{members_count} usuarios de %{group_1} y %{count} otros?' + choose_category: + label: "Elige una categoría" + none: "selecciona una..." + default_hint: Administra el acceso visitando la %{category} configuración de seguridad + hint_groups: + one: Los usuarios de %{hint} tendrán acceso a este canal según la configuración de seguridad + other: Los usuarios de %{hint} y %{hint_2} tendrán acceso a este canal según la configuración de seguridad + hint_multiple_groups: Los usuarios de %{hint_1} y %{count} otros grupos tendrán acceso a este canal según la configuración de seguridad. + create: "Crear canal" + description: "Descripción (opcional)" + name: "Nombre del canal" + title: "Nuevo canal" + type: "Tipo" + types: + category: "Categoría" + topic: "Tema" + reviewable: + type: "Mensaje de chat" + reactions: + only_you: "Has reaccionado con :%{emoji}:" + and_others: "Tú, %{usernames} reaccionaste con :%{emoji}:" + only_others: "%{usernames} reaccionó con :%{emoji}:" + others_and_more: "%{usernames} y %{more} personas más reaccionaron con :%{emoji}:" + you_others_and_more: "Tú, %{usernames} y %{more} personas más reaccionasteis con :%{emoji}:" + composer: + toggle_toolbar: "Alternar barra de herramientas" + italic_text: "texto enfatizado" + bold_text: "texto fuerte" + code_text: "texto del código" + quote: + original_channel: 'Enviado originalmente en %{channel}' + copy_success: "Cita del chat copiada en el portapapeles" + notification_levels: + never: "Nunca" + mention: "Solo para menciones" + always: "Para toda actividad" + settings: + enable_auto_join_users: "Añade automáticamente todos los usuarios activos recientemente" + disable_auto_join_users: "Dejar de añadir usuarios automáticamente" + auto_join_users_warning: "Todos los usuarios que no sean miembros de este canal y tengan acceso a la categoría %{category} se unirán. ¿Estás seguro/a?" + desktop_notification_level: "Notificaciones de escritorio" + follow: "Unirse" + followed: "Se unió" + mobile_notification_level: "Notificaciones móviles" + mute: "Silenciar canal" + muted_on: "Activado" + muted_off: "Desactivado" + notifications: "Notificaciones" + preview: "Vista previa" + save: "Guardar" + saved: "Guardado" + unfollow: "Abandonar" + admin: + title: "Chat" + direct_messages: + title: "Chat personal" + new: "Nuevo chat personal" + create: "Ir" + leave: "Abandonar este chat personal" + cannot_create: "Lo sentimos, no puedes enviar mensajes directos." + incoming_webhooks: + back: "Atrás" + channel_placeholder: "Selecciona un canal" + confirm_destroy: "¿Seguro que quieres eliminar este webhook entrante? Esto no se puede deshacer." + current_emoji: "Emoji actual" + description: "Descripción" + delete: "Eliminar" + emoji: "Emoji" + emoji_instructions: "Se usará el avatar del sistema si el emoji se deja en blanco." + name: "Nombre" + name_placeholder: "nombre..." + new: "Nuevo webhook entrante" + none: "No se crearon webhooks entrantes existentes." + no_emoji: "No hay ningún emoji seleccionado" + post_to: "Publicar en" + reset_emoji: "Restablecer emoji" + save: "Guardar" + edit: "Editar" + select_emoji: "Elegir emoji" + system: "sistema" + title: "Webhooks entrantes" + url: "URL" + url_instructions: "Esta URL contiene un valor secreto: mantenlo seguro." + username: "Nombre de usuario" + username_instructions: "Nombre de usuario del bot que publica en el canal. El valor predeterminado es «sistema» cuando se deja en blanco." + instructions: "Los webhooks entrantes pueden utilizarse por sistemas externos para publicar mensajes en un canal de chat designado como usuario del bot a través del punto final /hooks/:key. La carga útil consiste en un único parámetro texto, que está limitado a 2000 caracteres.

    También admitimos parámetros texto limitados con formato Slack, extrayendo enlaces y menciones basados en el formato en https://api.slack.com/reference/surfaces/formatting, pero para ello debe utilizarse el punto final /hooks/:key/slack." + selection: + cancel: "Cancelar" + quote_selection: "Cita en el Tema" + copy: "Copia" + move_selection_to_channel: "Pasar al canal" + error: "Se ha producido un error al mover los mensajes de chat" + title: "Mover chat a tema" + new_topic: + title: "Mover a nuevo tema" + instructions: + one: "Estás a punto de crear un nuevo tema y rellenarlo con el mensaje de chat que has seleccionado." + other: "Estás a punto de crear un nuevo tema y rellenarlo con los %{count} mensajes de chat que has seleccionado." + instructions_channel_archive: "Vas a crear un nuevo tema y archivar en él los mensajes del canal." + existing_topic: + title: "Mover a un tema existente" + instructions: + one: "Elige el tema al que quieres mover ese mensaje de chat." + other: "Elige el tema al que quieres mover esos %{count} mensajes de chat." + instructions_channel_archive: "Elige el tema en el que quieres archivar los mensajes del canal." + new_message: + title: "Mover a mensaje nuevo" + instructions: + one: "Estás a punto de crear un nuevo mensaje y rellenarlo con el mensaje de chat que has seleccionado." + other: "Estás a punto de crear un nuevo mensaje y rellenarlo con los %{count} mensajes de chat que has seleccionado." + replying_indicator: + single_user: "%{username} está escribiendo" + multiple_users: "%{commaSeparatedUsernames} y %{lastUsername} están escribiendo" + many_users: + one: "%{commaSeparatedUsernames} y %{count} más está escribiendo" + other: "%{commaSeparatedUsernames} y %{count} más están escribiendo" + retention_reminders: + public: "El historial del canal se conserva durante %{days} días." + dm: "El historial de chat personal se conserva durante %{days} días." + topic_button_title: "Chat" + flags: + off_topic: "Este mensaje no es relevante para la discusión actual, tal y como se define en el título del canal, y probablemente debería moverse a otro lugar." + inappropriate: "Este mensaje tiene un contenido que una persona razonable consideraría ofensivo, abusivo o que viola las directrices de nuestra comunidad." + spam: "Este mensaje es un anuncio o vandalismo. No es útil ni relevante para el canal actual." + notify_user: "Quiero hablar con esta persona directa y personalmente sobre su mensaje." + notify_moderators: "Este mensaje requiere la atención del personal por otra razón no mencionada anteriormente." + flagging: + action: "Denunciar mensaje" + emoji_picker: + favorites: "De uso frecuente" + smileys_&_emotion: "Sonrisas y emociones" + objects: "Objetos" + people_&_body: "Personas y cuerpo" + travel_&_places: "Viajes y lugares" + animals_&_nature: "Animales y naturaleza" + food_&_drink: "Comida y bebida" + activities: "Actividades" + flags: "Denuncias" + symbols: "Símbolos" + search_placeholder: "Busca por nombre de emoji y alias..." + no_results: "No hay resultados" + draft_channel_screen: + header: "Nuevo mensaje" + cancel: "Cancelar" + notifications: + chat_invitation: "te invitó a unirte a un canal de chat" + chat_invitation_html: "%{username} te ha invitado a unirte a un canal de chat" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'te ha mencionado en «%{channel}»' + direct_html: '%{username} te mencionó en «%{channel}»' + other_plain: 'mencionó %{identifier} en «%{channel}»' + other_html: '%{username} mencionó %{identifier} en «%{channel}»' + direct_message_chat_mention: + direct: "te mencionó en el chat personal" + direct_html: "%{username} te mencionó en el chat personal" + other_plain: "mencionó %{identifier} en el chat personal" + other_html: "%{username} mencionó %{identifier} en el chat personal" + chat_message: "Nuevo mensaje de chat" + chat_quoted: "%{username} citó tu mensaje de chat" + titles: + chat_mention: "Mención de chat" + chat_invitation: "Invitación de chat" + chat_quoted: "Chat citado" + action_codes: + chat: + enabled: '%{who} habilitó el %{when}' + disabled: "%{who} cerró el chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensaje de chat + fields: + chat_channel_id: + label: ID del canal de chat + message: + label: Mensaje + sender: + label: Remitente + description: Valores predeterminados del sistema + review: + transcript: + view: "Ver la transcripción de los mensajes anteriores" + types: + reviewable_chat_message: + title: "Mensaje de chat denunciado" + flagged_by: "Denunciado por" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Cambiar de canal" + open_quick_channel_selector: "%{shortcut} Abrir selector rápido de canales" + open_insert_link_modal: "%{shortcut} Insertar hipervínculo (solo compositor)" + composer_bold: "%{shortcut} Negrita (solo compositor)" + composer_italic: "%{shortcut} Cursiva (solo compositor)" + composer_code: "%{shortcut} Código (solo compositor)" + drawer_open: "%{shortcut} Abrir el cajón del chat" + drawer_close: "%{shortcut} Cerrar cajón del chat" + topic_statuses: + chat: + help: "El chat está activado para este tema" + user: + allow_private_messages: "Permitir que otros usuarios me envíen mensajes personales y mensajes directos del chat" + muted_users_instructions: "Suprime todas las notificaciones, mensajes personales y mensajes directos del chat de estos usuarios." + allowed_pm_users_instructions: "Solo permitir mensajes personales o mensajes directos de estos usuarios." + allow_private_messages_from_specific_users: "Permitir solo a determinados usuarios que me envíen mensajes personales o mensajes directos del chat" + ignored_users_instructions: "Suprime todas las publicaciones, mensajes, notificaciones, mensajes personales y mensajes directos del chat de estos usuarios." + user_menu: + no_chat_notifications_title: "Todavía no tienes ninguna notificación del chat" + no_chat_notifications_body: > + Se te notificará en este panel cuando alguien te envíe un mensaje directo o te @mencione en el chat. También se enviarán notificaciones a tu correo electrónico cuando no te hayas conectado durante un tiempo.

    Haz clic en el título de la parte superior de cualquier canal de chat para configurar las notificaciones que recibes en ese canal. Para más información, consulta tus preferencias de notificaciones. + tabs: + chat_notifications: "Notificaciones de chat" + chat_notifications_with_unread: + one: "Notificaciones del chat: %{count} notificación no leída" + other: "Notificaciones de chat: %{count} notificaciones no leídas" diff --git a/plugins/chat/config/locales/client.et.yml b/plugins/chat/config/locales/client.et.yml new file mode 100644 index 0000000000..1d105e6f8b --- /dev/null +++ b/plugins/chat/config/locales/client.et.yml @@ -0,0 +1,108 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: + js: + chat: + create: "Loo" + cancel: "Tühista" + channel_settings: + edit: "Muuda" + add: "isa" + join: "Liitu" + leave: "Lahku" + close: "Sulge" + delete: "Kustuta" + muted: "vaigistatud" + joined: "liitus" + email_frequency: + never: "Mitte kunagi" + flag: "Tähis" + join: "Liitu" + mention_warning: + dismiss: "ignoreeri" + reply: "Vasta" + edit: "Muuda" + rebake_message: "Rekonstrueeri HTML" + bookmark_message: "Järjehoidja" + save: "Salvesta" + sounds: + none: "Pole" + exit: "tagasi" + channel_status: + closed: "Suletud" + open: "Ava" + browse: + back: "Tagasi" + filter_all: Kõik + filter_closed: Suletud + chat_message_separator: + today: Täna + yesterday: Eile + about_view: + title: Pealkiri + description: Kirjeldus + channel_info: + back_to_channel: "Tagasi" + tabs: + about: Teave + members: Liikmed + settings: Sätted + direct_message_creator: + title: Uus sõnum + prefix: "Kellele:" + create_channel: + type: "Tüüp" + types: + category: "Foorum" + topic: "Teema" + composer: + italic_text: "esiletõstetud tekst" + bold_text: "rasvane tekst" + notification_levels: + never: "Mitte kunagi" + settings: + follow: "Liitu" + followed: "Liitus" + notifications: "Teavitus" + preview: "Eelvaade" + save: "Salvesta" + saved: "Salvestatud" + unfollow: "Lahku" + incoming_webhooks: + back: "Tagasi" + description: "Kirjeldus" + delete: "Kustuta" + emoji: "Emotikon" + name: "Nimi" + save: "Salvesta" + edit: "Muuda" + system: "süsteem" + url: "URL" + username: "Kasutajanimi" + selection: + cancel: "Tühista" + copy: "Kopeeri" + new_topic: + title: "Liiguta uue teema alla" + existing_topic: + title: "Liiguta olemasolevasse teemasse" + new_message: + title: "Liiguta uude sõnumisse" + emoji_picker: + objects: "Objektid" + flags: "Tähised" + draft_channel_screen: + header: "Uus sõnum" + cancel: "Tühista" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Sõnum diff --git a/plugins/chat/config/locales/client.fa_IR.yml b/plugins/chat/config/locales/client.fa_IR.yml new file mode 100644 index 0000000000..706ac0f044 --- /dev/null +++ b/plugins/chat/config/locales/client.fa_IR.yml @@ -0,0 +1,334 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "وضعیت کانال گفتگو تغییر کرد" + chat_channel_delete: "کانال گفتگو حذف شد" + about: + chat_messages_count: "پیام‌های گفتگو" + chat_channels_count: "کانال‌های گفتگو" + chat_users_count: "کاربران گفتگو" + chat: + dates: + time_tiny: "HH:mm" + all_loaded: "نمایش همه پیام‌ها" + already_enabled: "گفتگو در حال حاضر در این موضوع فعال شده است. لطفا صفحه جاری را تازه‌سازی کنید." + disabled_for_topic: "گفتگو در این موضوع غیرفعال است." + bot: "ربات" + create: "ایجاد" + cancel: "انصراف" + cancel_reply: "لغو پاسخ" + chat_channels: "کانال‌ها" + browse_all_channels: "مرور همه کانال‌ها" + move_to_channel: + title: "انتقال پیام‌ها به کانال" + confirm_move: "انتقال پیام‌ها" + channel_settings: + title: "تنظیمات کانال" + edit: "ویرایش" + add: "اضافه کردن" + close_channel: "بستن کانال" + open_channel: "باز کردن کانال" + archive_channel: "بایگانی کانال" + delete_channel: "حذف کانال" + join_channel: "پیوستن به کانال" + leave_channel: "ترک کانال" + join: "عضو شدن" + leave: "ترک کردن" + channel_archive: + title: "بایگانی کانال" + retry: "تلاش مجدد" + channel_open: + title: "باز کردن کانال" + instructions: "کانال را باز می‌کند، همه کاربران قادر خواهند بود پیام جدید ارسال کنند و پیام‌های قبلی خود را ویرایش کنند." + channel_close: + title: "بستن کانال" + instructions: "بستن کانال از ارسال پیام‌های جدید یا ویرایش پیام‌های قبلی توسط کاربران که همکار نیستن جلوگیری می‌کند. آیا مطمئنید که می‌خواهید اين کانال رو ببنديد؟" + channel_delete: + title: "حذف کانال" + confirm_channel_name: "نام کانال را وارد کنید" + channels_list_popup: + browse: "مرور کانال‌ها" + create: "کانال جدید" + click_to_join: "برای مشاهده کانال‌های موجود، اینجا را کلیک کنید." + close: "بستن" + confirm_flag: "آیا برای پرچم گذاری پیام %{username} مطمئن هستید؟" + deleted: "یک پیام حذف شد. [view]" + hidden: "یک پیام پنهان شده است. [view]" + delete: "حذف" + edited: "ویرایش شده" + muted: "خاموش" + joined: "ملحق شده" + empty_state: + direct_message_cta: "شروع گفتگوی شخصی" + direct_message: "شما همچنین می‌توانید یک گفتگو شخصی را با یک یا چند کاربر شروع کنید." + title: "هیچ کانالی پیدا نشد" + email_frequency: + never: "هرگز" + title: "آگاه‌سازی‌های ایمیل" + enable: "فعال کردن گفتگو" + flag: "پرچم" + flagged: "این پیام برای بررسی پرچم گذاری شده است" + invalid_access: "شما برای مشاهده گفتگوی این کانال دسترسی ندارید" + in_reply_to: "در پاسخ به" + heading: "گفتگو" + join: "عضو شدن" + new_messages: "پیام‌های جدید" + mention_warning: + dismiss: "رد کردن" + invitations_sent: + one: "دعوت‌نامه ارسال شد" + other: "دعوت‌نامه‌ها ارسال شد" + invite: "دعوت به کانال" + without_membership: + one: "%{usernames} هنوز در این کانال عضو نشده است." + other: "%{usernames} هنوز در این کانال عضو نشده است." + aria_roles: + channels_list: "فهرست کانال‌های گفتگو" + no_public_channels: "شما هنوز عضو هیچ کانالی نشده‌اید." + open: "باز کردن گفتگو..." + close_full_page: "بستن گفتگو تمام صفحه" + open_message: "پیام را در گفتگو باز کن" + placeholder_start_conversation: شروع گفتگو با %{usernames} + remove_upload: "حذف پرونده" + react: "واکنش با شکلک" + reply: "پاسخ" + edit: "ویرایش" + copy_link: "کپی پیوند" + rebake_message: "ساخت مجدد HTML" + retry_staged_message: + title: "خطای شبکه" + action: "دوباره بفرستم؟" + unreliable_network: "شبکه پایدار نیست، ارسال پیام‌ها و ذخیره پیش‌نویس ممکن است کار نکند" + bookmark_message: "نشانک" + bookmark_message_edit: "ویرایش نشانک" + restore: "بازگرداندن پیام حذف شده" + save: "ذخیره" + select: "انتخاب کنید" + return_to_list: "بازگشت به فهرست کانال‌ها" + scroll_to_bottom: "حرکت به پایین" + scroll_to_new_messages: "مشاهده پیام‌های جدید" + sound: + title: "صدای آگاه‌سازی گفتگوی دسکتاپ" + sounds: + none: "هیچ کدام" + bell: "زنگ" + ding: "دینگ" + title: "گفتگو" + title_capitalized: "گفتگو" + upload: "پیوست کردن یک پرونده" + uploaded_files: + one: "%{count} پرونده" + other: "%{count} پرونده" + you_flagged: "شما این پیام را پرچم گذاری کردید" + exit: "بازگشت" + channel_status: + read_only: "فقط خواندنی" + archived: "بایگانی شد" + closed_header: "کانال بسته است" + closed: "بسته" + open_header: "کانال باز است" + open: "باز" + browse: + back: "بازگشت" + title: کانال‌ها + filter_all: همه + filter_open: باز شد + filter_closed: بسته + filter_archived: بایگانی شد + filter_input_placeholder: کانال را با نام جستجو کنید + chat_message_separator: + today: امروز + yesterday: دیروز + members_view: + filter_placeholder: جستجوی اعضا + about_view: + associated_topic: موضوع مرتبط + associated_category: دسته‌بندی مرتبط + title: عنوان + description: توضیح + channel_info: + back_to_all_channels: "همه کانال‌ها" + back_to_channel: "بازگشت" + tabs: + about: درباره + members: اعضاء + settings: تنظیمات + channel_edit_title_modal: + title: ویرایش عنوان + input_placeholder: افزودن عنوان + description: یک عنوان توصیفی کوتاه به کانال خود بدهید + channel_edit_description_modal: + title: ویرایش توضیحات + input_placeholder: افزودن توضیحات + description: به بقیه افراد بگویید که این کانال در مورد چی هست + direct_message_creator: + title: پیام جدید + prefix: "به:" + no_results: هیج نتیجه‌ای نداشت + channel_selector: + no_channels: "هیچ کانالی با جستجوی شما مطابقت ندارد" + channel: + no_memberships: این کانال هنوز هیچ عضوی ندارد + no_memberships_found: هیچ عضوی یافت نشد + memberships_count: + one: "%{count} عضو" + other: "%{count} عضو" + create_channel: + auto_join_users: + warning_groups: + one: به طور خودکار %{members_count} کاربر از گروه %{group} اضافه شود؟ + other: به طور خودکار %{members_count} کاربر از گروه %{group} و %{group_2} اضافه شود؟ + warning_multiple_groups: به طور خودکار %{members_count} کاربر از گروه %{group_1} و %{count} نفر دیگر اضافه شود؟ + choose_category: + label: "انتخاب دسته‌بندی" + none: "یکی را انتخاب کنید..." + create: "ایجاد کانال" + description: "توضیحات «اختیاری»" + name: "نام کانال" + title: "کانال جدید" + type: "نوع" + types: + category: "دسته" + topic: "موضوعات" + reviewable: + type: "پیام گفتگو" + reactions: + only_you: "شما با :%{emoji}: واکنش نشان دادید" + composer: + italic_text: "متن تاکید شده" + bold_text: "نوشته‌ی ضخیم " + quote: + copy_success: "نقل قول گفتگو در کلیپ‌بورد کپی شد" + notification_levels: + never: "هرگز" + mention: "فقط برای اشاره کردن" + always: "برای تمام فعالیت‌ها" + settings: + desktop_notification_level: "آگاه‌سازی‌های دسکتاپ" + follow: "عضو شدن" + followed: "عضو شده" + mute: "بی‌صدا کردن کانال" + muted_on: "روشن" + muted_off: "خاموش" + notifications: "اعلان‌ها" + preview: "پیش‌نمایش" + save: "ذخیره کردن" + saved: "ذخیره شد" + unfollow: "ترک کردن" + admin: + title: "گفتگو" + direct_messages: + title: "گفتگوی شخصی" + new: "گفتگوی شخصی جدید" + create: "برو" + cannot_create: "با عرض پوزش، شما نمی‌توانید پیام مستقیم ارسال کنید." + incoming_webhooks: + back: "بازگشت" + channel_placeholder: "یک کانال را انتخاب کنید" + current_emoji: "شکلک کنونی" + description: "توضیح" + delete: "پاک کردن" + emoji: "شکلک" + name: "نام" + name_placeholder: "نام..." + no_emoji: "هیچ شکلکی انتخاب نشده" + reset_emoji: "تنظیم مجدد شکلک" + save: "ذخیره کردن" + edit: "ویرایش‌" + select_emoji: "انتخاب شکلک" + system: "سیستم" + url: "آدرس" + username: "نام‌کاربری" + selection: + cancel: "انصراف" + quote_selection: "نقل قول در موضوع" + copy: "کپی" + move_selection_to_channel: "انتقال به کانال" + error: "در انتقال پیام‌های گفتگو خطایی رخ داده" + title: "انتقال گفتگو به موضوع" + new_topic: + title: "انتقال به موضوع جدید" + existing_topic: + title: "انتقال به موضوع موجود" + new_message: + title: "انتقال به پیام جدید" + replying_indicator: + single_user: "%{username} در حال نوشتن" + multiple_users: "%{commaSeparatedUsernames} و %{lastUsername} در حال نوشتن" + topic_button_title: "گفتگو" + emoji_picker: + favorites: "اغلب استفاده می‌شه" + objects: "اشیا" + people_&_body: "مردم و بدن" + travel_&_places: "سفر و مکان‌ها" + animals_&_nature: "حیوانات و طبیعت" + activities: "فعالیت ها" + flags: "پرچم‌ها" + symbols: "نشانه ها" + no_results: "هیج نتیجه‌ای نداشت" + draft_channel_screen: + header: "پیام جدید" + cancel: "انصراف" + notifications: + chat_quoted: "%{username} %{description}" + popup: + chat_message: "پیام گفتگو جدید" + chat_quoted: "%{username} پیام گفتگو شما را نقل کرد" + titles: + chat_mention: "اشاره گفتگو" + chat_invitation: "دعوت‌نامه گفتگو" + action_codes: + chat: + enabled: '%{who} فعال شد %{when}' + disabled: "%{who} گفتگو بسته شد %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: ارسال پیام + fields: + chat_channel_id: + label: شناسه کانال گفتگو + message: + label: پیام + sender: + label: فرستنده + description: پیش‌فرض‌های سیستم + review: + transcript: + view: "مشاهده رونوشت متن پیام‌های قبلی" + types: + reviewable_chat_message: + title: "پیام گفتگوی پرچم گذاری شده" + flagged_by: "پرچم شده توسط" + keyboard_shortcuts_help: + chat: + title: "گفتگو" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} تغییر کانال" + drawer_open: "%{shortcut} باز کردن کِشوی گفتگو" + drawer_close: "%{shortcut} بستن کِشوی گفتگو" + topic_statuses: + chat: + help: "گفتگو برای این موضوع فعال است" + user: + allow_private_messages: "به دیگر، کاربران اجازه دهید پیام‌های شخصی و پیام‌های مستقیم در گفتگو را برای من ارسال کنند" + muted_users_instructions: "همه آگاه‌سازی‌ها، پیام‌های شخصی و پیام‌های مستقیم در گفتگو از این کاربران را سرکوب کنید." + allowed_pm_users_instructions: "فقط پیام‌های شخصی یا پیام‌های مستقیم در گفتگو را از این کاربران اجازه دهید." + allow_private_messages_from_specific_users: "فقط به کاربران خاصی اجازه دهید، تا پیام‌های شخصی یا پیام‌های مستقیم در گفتگو را برای من ارسال کنند" + ignored_users_instructions: "تمام نوشته‌ها، آگاه‌سازی‌ها، پیام‌های شخصی و پیام‌های مستقیم در گفتگو این کاربران را سرکوب کنید." + user_menu: + no_chat_notifications_title: "شما هنوز هیچ آگاه‌سازی گفتگوی ندارید" + tabs: + chat_notifications: "آگاه‌سازی‌های گفتگو" + chat_notifications_with_unread: + one: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" + other: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" diff --git a/plugins/chat/config/locales/client.fi.yml b/plugins/chat/config/locales/client.fi.yml new file mode 100644 index 0000000000..aca5536ab5 --- /dev/null +++ b/plugins/chat/config/locales/client.fi.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Chat-kanavan tila muuttui" + chat_channel_delete: "Chat-kanava poistettu" + api: + scopes: + descriptions: + chat: + create_message: "Luo chat-viesti tietyllä kanavalla." + about: + chat_messages_count: "Chat-viestit" + chat_channels_count: "Chat-kanavat" + chat_users_count: "Chat-käyttäjät" + chat: + dates: + time_tiny: "t:mm" + all_loaded: "Näytetään kaikki viestit" + already_enabled: "Chat on jo käytössä tässä ketjussa. Päivitä." + disabled_for_topic: "Chat on poistettu käytöstä tässä ketjussa." + bot: "botti" + create: "Luo" + cancel: "Peruuta" + cancel_reply: "Peruuta vastaus" + chat_channels: "Kanavat" + browse_all_channels: "Selaa kaikkia kanavia" + move_to_channel: + title: "Siirrä viestit kanavalle" + instructions: + one: "Olet siirtämässä %{count} viestin. Valitse kohdekanava. Kanavalle %{channelTitle} luodaan paikkamerkkiviesti, joka osoittaa, että tämä viesti on siirretty." + other: "Olet siirtämässä %{count} viestiä. Valitse kohdekanava. Kanavalle %{channelTitle} luodaan paikkamerkkiviesti, joka osoittaa, että nämä viestit on siirretty." + confirm_move: "Siirrä viestit" + channel_settings: + title: "Kanavan asetukset" + edit: "Muokkaa" + add: "Lisää" + close_channel: "Sulje kanava" + open_channel: "Avaa kanava" + archive_channel: "Arkistoi kanava" + delete_channel: "Poista kanava" + join_channel: "Liity kanavalle" + leave_channel: "Poistu kanavalta" + join: "Liity" + leave: "Poistu" + channel_archive: + title: "Arkistoi kanava" + instructions: "

    Kanavan arkistointi asettaa sen vain luku -tilaan ja siirtää kaikki kanavan viestit uuteen tai olemassa olevaan ketjuun. Uusia viestejä ei voi lähettää, eikä olemassa olevia viestejä voi muokata tai poistaa.

    Oletko varma, että haluat arkistoida kanavan %{channelTitle}?

    " + process_started: "Arkistointiprosessi on alkanut. Tämä modaalinen ikkuna sulkeutuu pian, ja saat henkilökohtaisen viestin, kun arkistointiprosessi on valmis." + retry: "Yritä uudelleen" + channel_open: + title: "Avaa kanava" + instructions: "Avaa kanavan uudelleen; kaikki käyttäjät voivat lähettää viestejä ja muokata olemassa olevia viestejään." + channel_close: + title: "Sulje kanava" + instructions: "Kanavan sulkeminen estää muita kuin henkilökunnan käyttäjiä lähettämästä uusia viestejä tai muokkaamasta olemassa olevia viestejä. Haluatko varmasti sulkea tämän kanavan?" + channel_delete: + title: "Poista kanava" + instructions: "

    Poistaa kanavan %{name} ja chat-historian. Kaikki viestit ja niihin liittyvät tiedot, kuten reaktiot ja lataukset, poistetaan pysyvästi. Jos haluat säilyttää kanavan historian ja poistaa sen käytöstä, voit sen sijaan arkistoida kanavan.

    Haluatko varmasti poistaa kanavan psyyvästi? Vahvista kirjoittamalla kanavan nimi alla olevaan ruutuun.

    " + confirm: "Ymmärrän seuraukset, poista kanava" + confirm_channel_name: "Anna kanavan nimi" + process_started: "Kanavan poistoprosessi on alkanut. Tämä modaalinen ikkuna sulkeutuu pian, etkä näe enää poistettua kanavaa missään." + channels_list_popup: + browse: "Selaa kanavia" + create: "Uusi kanava" + click_to_join: "Näytä saatavilla olevat kanavat napsauttamalla tätä." + close: "Sulje" + collapse: "Tiivistä chat-laatikko" + confirm_flag: "Haluatko varmasti liputtaa käyttäjän %{username} viestin?" + deleted: "Viesti poistettiin. [näytä]" + hidden: "Viesti piilotettiin. [näytä]" + delete: "Poista" + edited: "muokattu" + muted: "vaimennettu" + joined: "liittyi" + empty_state: + direct_message_cta: "Aloita henkilökohtainen chat" + direct_message: "Voit myös aloittaa henkilökohtaisen chatin yhden tai useamman käyttäjän kanssa." + title: "Kanavia ei löytynyt" + email_frequency: + description: "Lähetämme sinulle sähköpostia vain, jos emme ole nähneet sinua viimeisen 15 minuutin aikana." + never: "Ei koskaan" + title: "Sähköposti-ilmoitukset" + when_away: "Vain poissa ollessa" + enable: "Ota chat käyttöön" + flag: "Liputa" + emoji: "Lisää emoji" + flagged: "Tämä viesti on liputettu käsiteltäväksi" + invalid_access: "Sinulla ei ole tämän chat-kanavan katseluoikeutta" + invitation_notification: "%{username} kutsui sinut liittymään chat-kanavalle" + in_reply_to: "Vastauksena:" + heading: "Chat" + join: "Liity" + new_messages: "uusia viestejä" + mention_warning: + cannot_see: + one: "%{usernames} ei voi käyttää tätä kanavaa, eikä hänelle ilmoitettu." + other: "%{usernames} eivät voi käyttää tätä kanavaa, eikä heille ilmoitettu." + dismiss: "hylkää" + invitations_sent: + one: "Kutsu lähetetty" + other: "Kutsut lähetettiin" + invite: "Kutsu kanavalle" + without_membership: + one: "%{usernames} ei ole liittynyt tälle kanavalle." + other: "%{usernames} eivät ole liittyneet tälle kanavalle." + aria_roles: + header: "Chatin ylätunniste" + composer: "Chatin tekstieditori" + channels_list: "Chat-kanavien luettelo" + no_public_channels: "Et ole liittynyt kanaville." + only_chat_push_notifications: + title: "Lähetä vain chatin push-ilmoituksia" + description: "Estä kaikkien muiden kuin chatin push-ilmoitusten lähettäminen" + ignore_channel_wide_mention: + title: "Ohita kanavanlaajuiset maininnat" + description: "Älä lähetä ilmoituksia kanavanlaajuisista maininnoista (@here ja @all)" + open: "Avaa chat" + open_full_page: "Avaa koko näytön chat" + close_full_page: "Sulje koko näytön chat" + open_message: "Avaa viesti chatissa" + placeholder_self: "Kirjoita jotakin" + placeholder_others: "Chat-keskustelu käyttäjän %{messageRecipient} kanssa" + placeholder_new_message_disallowed: "Kanava on %{status}, et voi lähettää uusia viestejä juuri nyt." + placeholder_silenced: "Et voi lähettää viestejä tällä hetkellä." + placeholder_start_conversation: Aloita keskustelu käyttäjän %{usernames} kanssa + remove_upload: "Poista tiedosto" + react: "Reagoi emojilla" + reply: "Vastaa" + edit: "Muokkaa" + copy_link: "Kopioi linkki" + rebake_message: "Kokoa HTML uudelleen" + retry_staged_message: + title: "Verkkovirhe" + action: "Lähetetäänkö uudelleen?" + unreliable_network: "Verkko on epäluotettava, viestien lähettäminen ja luonnoksen tallentaminen ei ehkä toimi" + bookmark_message: "Kirjanmerkki" + bookmark_message_edit: "Muokkaa kirjanmerkkiä" + restore: "Palauta poistettu viesti" + save: "Tallenna" + select: "Valitse" + silence: "Hiljennä käyttäjä" + return_to_list: "Palaa kanavaluetteloon" + scroll_to_bottom: "Vieritä alas" + scroll_to_new_messages: "Katso uudet viestit" + sound: + title: "Työpöytälaitteen chat-ilmoitusääni" + sounds: + none: "Ei mitään" + bell: "Kello" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Liitä tiedosto" + uploaded_files: + one: "%{count} tiedosto" + other: "%{count} tiedostoa" + you_flagged: "Liputit tämän viestin" + exit: "takaisin" + channel_status: + read_only_header: "Kanava on vain luettavissa" + read_only: "Vain luku" + archived_header: "Kanava on arkistoitu" + archived: "Arkistoitu" + archive_failed: "Kanavan arkistointi epäonnistui. %{completed}/%{total} viestiä on arkistoitu kohdeketjuun. Yritä arkistoinnin viimeistelyä uudelleen painamalla Yritä uudelleen -painiketta." + archive_completed: "Katso arkistoketju" + closed_header: "Kanava on suljettu" + closed: "Suljettu" + open_header: "Kanava on avoinna" + open: "Avoinna" + browse: + title: Kanavat + filter_all: Kaikki + filter_open: Avattu + filter_closed: Suljettu + filter_archived: Arkistoitu + filter_input_placeholder: Hae kanavaa nimellä + chat_message_separator: + today: Tänään + yesterday: Eilen + members_view: + filter_placeholder: Etsi jäseniä + about_view: + associated_topic: Linkitetty ketju + associated_category: Linkitetty alue + title: Otsikko + description: Kuvaus + channel_info: + back_to_all_channels: "Kaikki kanavat" + back_to_channel: "Takaisin" + tabs: + about: Tietoja + members: Jäsenet + settings: Asetukset + channel_edit_title_modal: + title: Muokkaa otsikkoa + input_placeholder: Lisää otsikko + description: Anna kanavallesi lyhyt kuvaava otsikko + channel_edit_description_modal: + title: Muokkaa kuvausta + input_placeholder: Lisää kuvaus + description: Kerro ihmisille, mistä tässä kanavassa on kyse + direct_message_creator: + title: Uusi viesti + prefix: "Vastaanottaja:" + no_results: Ei tuloksia + selected_user_title: "Poista käyttäjän %{username} valinta" + channel_selector: + title: "Siirry kanavalle" + no_channels: "Hakuasi vastaavia kanavia ei ole" + channel: + no_memberships: Tällä kanavalla ei ole jäseniä + no_memberships_found: Jäseniä ei löytynyt + memberships_count: + one: "%{count} jäsen" + other: "%{count} jäsentä" + create_channel: + auto_join_users: + public_category_warning: "%{category} on julkinen alue. Lisätäänkö kaikki hiljattain aktiiviset käyttäjät automaattisesti tälle kanavalle?" + warning_groups: + one: Lisätäänkö %{members_count} käyttäjää automaattisesti ryhmästä %{group}? + other: Lisätäänkö %{members_count} käyttäjää automaattisesti ryhmistä %{group} ja %{group_2}? + warning_multiple_groups: Lisätäänkö %{members_count} käyttäjää automaattisesti ryhmästä %{group_1} ja %{count} muusta? + choose_category: + label: "Valitse alue" + none: "valitse yksi..." + default_hint: Hallinnoi käyttöoikeutta alueen %{category} turvallisuusasetuksissa + hint_groups: + one: Ryhmän %{hint} käyttäjillä on automaattisesti pääsy tälle kanavalle turvallisuusasetusten mukaan + other: Ryhmien %{hint} ja %{hint_2} käyttäjillä on automaattisesti pääsy tälle kanavalle turvallisuusasetusten mukaan + hint_multiple_groups: Ryhmän %{hint_1} ja %{count} muun ryhmän käyttäjillä on automaattisesti pääsy tälle kanavalle turvallisuusasetusten mukaan + create: "Luo kanava" + description: "Kuvaus (valinnainen)" + name: "Kanavan nimi" + title: "Uusi kanava" + type: "Tyyppi" + types: + category: "Alue" + topic: "Ketju" + reviewable: + type: "Chat-viesti" + reactions: + only_you: "Reagoit emojilla :%{emoji}:" + and_others: "Sinä, %{usernames} reagoitte emojilla :%{emoji}:" + only_others: "%{usernames} reagoivat emojilla :%{emoji}:" + others_and_more: "%{usernames} ja %{more} muuta reagoivat emojilla :%{emoji}:" + you_others_and_more: "Sinä, %{usernames} ja %{more} muuta reagoivat emojilla :%{emoji}:" + composer: + toggle_toolbar: "Vaihda työkalupalkki" + italic_text: "korostettu teksti" + bold_text: "lihavoitu teksti" + code_text: "kooditeksti" + quote: + original_channel: 'Lähetetty alun perin kanavalla %{channel}' + copy_success: "Chat-lainaus kopioitiin leikepöydälle" + notification_levels: + never: "Ei koskaan" + mention: "Vain maininnoissa" + always: "Kaikessa toiminnassa" + settings: + enable_auto_join_users: "Lisää automaattisesti kaikki hiljattain aktiiviset käyttäjät" + disable_auto_join_users: "Lopeta käyttäjien automaattinen lisääminen" + auto_join_users_warning: "Jokainen käyttäjä, joka ei ole tämän kanavan jäsen ja jolla on pääsy alueelle %{category}, liittyy. Oletko varma?" + desktop_notification_level: "Työpöytäilmoitukset" + follow: "Liity" + followed: "Liittyi" + mobile_notification_level: "Mobiili-push-ilmoitukset" + mute: "Mykistä kanava" + muted_on: "Käytössä" + muted_off: "Pois käytöstä" + notifications: "Ilmoitukset" + preview: "Esikatselu" + save: "Tallenna" + saved: "Tallennettu" + unfollow: "Poistu" + admin: + title: "Chat" + direct_messages: + title: "Henkilökohtainen chat" + new: "Uusi henkilökohtainen chat" + create: "Siirry" + leave: "Poistu tästä henkilökohtaisesta chatista" + cannot_create: "Valitettavasti et voi lähettää yksityisviestejä." + incoming_webhooks: + back: "Takaisin" + channel_placeholder: "Valitse kanava" + confirm_destroy: "Haluatko varmasti poistaa tämän saapuvan webhookin? Tätä ei voi peruuttaa." + current_emoji: "Nykyinen emoji" + description: "Kuvaus" + delete: "Poista" + emoji: "Emoji" + emoji_instructions: "Järjestelmän avataria käytetään, jos emoji jätetään tyhjäksi." + name: "Nimi" + name_placeholder: "nimi..." + new: "Uusi saapuva webhook" + none: "Olemassa olevia saapuvia webhookeja ei ole luotu." + no_emoji: "Emojia ei ole valittu" + post_to: "Lähetä kohteeseen" + reset_emoji: "Nollaa emoji" + save: "Tallenna" + edit: "Muokkaa" + select_emoji: "Valitse emoji" + system: "järjestelmä" + title: "Saapuvat webhookit" + url: "URL" + url_instructions: "Tämä URL-osoite sisältää salaisen arvon – pidä se turvassa." + username: "Käyttäjätunnus" + username_instructions: "Kanavalle lähettävän botin käyttäjätunnus. Oletus on \"järjestelmä\", kun tämä jätetään tyhjäksi." + instructions: "Ulkoiset järjestelmät voivat käyttää saapuvia webhookeja viestien lähettämiseen määrätylle chat-kanavalle bot-käyttäjänä /hooks/:key-päätepisteen kautta. Hyötykuorma koostuu yhdestä text-parametrista, joka on rajoitettu 2 000 merkkiin.

    Tuemme myös rajoitettuja Slack-muotoiltuja text-parametreja, linkkien ja mainintojen poimimista osoitteessa https://api.slack.com/reference/surfaces/formatting kuvatun muodon perusteella, mutta tähän täytyy käyttää /hooks/:key/ Slack-päätepistettä." + selection: + cancel: "Peruuta" + quote_selection: "Lainaus ketjussa" + copy: "Kopioi" + move_selection_to_channel: "Siirrä kanavalle" + error: "Chat-viestien siirtämisessä tapahtui virhe" + title: "Siirrä chat ketjuun" + new_topic: + title: "Siirrä uuteen ketjuun" + instructions: + one: "Olet luomassa uutta ketjua ja lisäämässä valitsemasi chat-viestin siihen." + other: "Olet luomassa uutta ketjua ja lisäämässä %{count} valitsemaasi chat-viestiä siihen." + instructions_channel_archive: "Olet luomassa uutta ketjua ja arkistoimassa kanavan viestit siihen." + existing_topic: + title: "Siirrä olemassa olevaan ketjuun" + instructions: + one: "Valitse ketju, johon haluat siirtää tämän chat-viestin." + other: "Valitse ketju, johon haluat siirtää nämä %{count} chat-viestiä." + instructions_channel_archive: "Valitse ketju, johon haluat arkistoida kanavan viestit." + new_message: + title: "Siirrä uuteen viestiin" + instructions: + one: "Olet luomassa uutta viestiä ja lisäämässä valitsemasi chat-viestin siihen." + other: "Olet luomassa uutta viestiä ja lisäämässä %{count} valitsemaasi chat-viestiä siihen." + replying_indicator: + single_user: "%{username} kirjoittaa" + multiple_users: "%{commaSeparatedUsernames} ja %{lastUsername} kirjoittavat" + many_users: + one: "%{commaSeparatedUsernames} ja %{count} muu kirjoittavat" + other: "%{commaSeparatedUsernames} ja %{count} muuta kirjoittavat" + retention_reminders: + public: "Kanavan historia säilytetään %{days} päivän ajan." + dm: "Henkilökohtainen keskusteluhistoria säilytetään %{days} päivän ajan." + topic_button_title: "Chat" + flags: + off_topic: "Tämä viesti ei liity nykyiseen keskusteluun kanavan otsikon mukaan, ja se pitäisi todennäköisesti siirtää muualle." + inappropriate: "Tämä viesti sisältää sisältöä, jota kohtuullinen henkilö pitäisi loukkaavana, herjaavana tai yhteisömme ohjeiden vastaisena." + spam: "Tämä viesti on mainos tai ilkivaltaa. Se ei ole hyödyllinen tai olennainen nykyiselle kanavalle." + notify_user: "Haluan keskustella tämän henkilön kanssa suoraan ja henkilökohtaisesti hänen viestistään." + notify_moderators: "Tämä viesti vaatii henkilökunnan huomion muusta syystä, jota ei ole mainittu edellä." + flagging: + action: "Liputa viesti" + emoji_picker: + favorites: "Usein käytetyt" + smileys_&_emotion: "Hymiöt ja tunteet" + objects: "Esineet" + people_&_body: "Ihmiset ja keho" + travel_&_places: "Matkailu ja paikat" + animals_&_nature: "Eläimet ja luonto" + food_&_drink: "Ruoka ja juoma" + activities: "Aktiviteetit" + flags: "Liput" + symbols: "Symbolit" + search_placeholder: "Hae emojin nimen ja aliaksen mukaan..." + no_results: "Ei tuloksia" + draft_channel_screen: + header: "Uusi viesti" + cancel: "Peruuta" + notifications: + chat_invitation: "kutsui sinut liittymään chat-kanavalle" + chat_invitation_html: "%{username} kutsui sinut liittymään chat-kanavalle" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'mainitsi sinut kanavalla "%{channel}"' + direct_html: '%{username} mainitsi sinut kanavalla "%{channel}"' + other_plain: 'mainitsi kohteen %{identifier} kanavalla "%{channel}"' + other_html: '%{username} mainitsi kohteen %{identifier} kanavalla "%{channel}"' + direct_message_chat_mention: + direct: "mainitsi sinut henkilökohtaisessa chatissa" + direct_html: "%{username} mainitsi sinut henkilökohtaisessa chatissa" + other_plain: "mainitsi kohteen %{identifier} henkilökohtaisessa chatissa" + other_html: "%{username} mainitsi kohteen %{identifier} henkilökohtaisessa chatissa" + chat_message: "Uusi chat-viesti" + chat_quoted: "%{username} lainasi chat-viestiäsi" + titles: + chat_mention: "Chat-maininta" + chat_invitation: "Chat-kutsu" + chat_quoted: "Chat-keskustelua lainattu" + action_codes: + chat: + enabled: '%{who} otti käyttöön %{when}' + disabled: "%{who} sulki chatin %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Lähetä chat-viesti + fields: + chat_channel_id: + label: Chat-kanavan tunnus + message: + label: Viesti + sender: + label: Lähettäjä + description: Oletuksena järjestelmä + review: + transcript: + view: "Näytä aiempien viestien transkriptio" + types: + reviewable_chat_message: + title: "Liputettu chat-viesti" + flagged_by: "Liputtanut" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Vaihda kanavaa" + open_quick_channel_selector: "%{shortcut} Avaa kanavan pikavalitsin" + open_insert_link_modal: "%{shortcut} Lisää hyperlinkki (vain tekstieditori)" + composer_bold: "%{shortcut} Lihavointi (vain tekstieditori)" + composer_italic: "%{shortcut} Kursiivi (vain tekstieditori)" + composer_code: "%{shortcut} Koodi (vain tekstieditori)" + drawer_open: "%{shortcut} Avaa chat-laatikko" + drawer_close: "%{shortcut} Sulje chat-laatikko" + topic_statuses: + chat: + help: "Chat on käytössä tässä ketjussa" + user: + allow_private_messages: "Salli muiden käyttäjien lähettää minulle yksityisviestejä ja chat-yksityisviestejä" + muted_users_instructions: "Estä kaikki ilmoitukset, yksityisviestit ja chat-yksityisviestit näiltä käyttäjiltä." + allowed_pm_users_instructions: "Salli vain näiden käyttäjien yksityisviestit tai chat-yksityisviestit." + allow_private_messages_from_specific_users: "Salli vain tiettyjen käyttäjien lähettää minulle yksityisviestejä tai chat-yksityisviestejä" + ignored_users_instructions: "Estä kaikki viestit, ilmoitukset, yksityisviestit ja chat-yksityisviestit näiltä käyttäjiltä." + user_menu: + no_chat_notifications_title: "Sinulla ei ole vielä chat-ilmoituksia" + no_chat_notifications_body: > + Saat ilmoituksen tässä paneelissa, kun joku lähettää sinulle viestin tai @mainitsee sinut chatissa. Ilmoitukset lähetetään myös sähköpostiisi, jos et ole kirjautunut sisään vähään aikaan.

    Klikkaa minkä tahansa chat-kanavan yläosassa olevaa otsikkoa määrittääksesi, mitä ilmoituksia saat kyseisellä kanavalla. Katso lisätietoja ilmoitusasetuksistasi. + tabs: + chat_notifications: "Chat-ilmoitukset" + chat_notifications_with_unread: + one: "Chat-ilmoitukset – %{count} lukematon ilmoitus" + other: "Chat-ilmoitukset – %{count} lukematonta ilmoitusta" diff --git a/plugins/chat/config/locales/client.fr.yml b/plugins/chat/config/locales/client.fr.yml new file mode 100644 index 0000000000..3fd58cb732 --- /dev/null +++ b/plugins/chat/config/locales/client.fr.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Le statut du canal de discussion a changé" + chat_channel_delete: "Canal de discussion supprimé" + api: + scopes: + descriptions: + chat: + create_message: "Créez un message instantané dans un canal spécifié." + about: + chat_messages_count: "Messages instantanés" + chat_channels_count: "Canaux de discussion" + chat_users_count: "Utilisateurs de la discussion" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Affichage de tous les messages" + already_enabled: "La discussion est déjà activée sur ce sujet. Veuillez actualiser." + disabled_for_topic: "La discussion est désactivée sur ce sujet." + bot: "robot" + create: "Créer" + cancel: "Annuler" + cancel_reply: "Annuler la réponse" + chat_channels: "Canaux" + browse_all_channels: "Parcourir tous les canaux" + move_to_channel: + title: "Déplacer les messages vers le canal" + instructions: + one: "Vous déplacez %{count} message. Sélectionnez un canal de destination. Un message de substitution sera créé dans le canal %{channelTitle} pour indiquer que ce message a été déplacé." + other: "Vous déplacez %{count} messages. Sélectionnez un canal de destination. Un message de substitution sera créé dans le canal %{channelTitle} pour indiquer que ces messages ont été déplacés." + confirm_move: "Déplacer les messages" + channel_settings: + title: "Paramètres du canal" + edit: "Modifier" + add: "Ajouter" + close_channel: "Fermer le canal" + open_channel: "Ouvrir le canal" + archive_channel: "Archiver le canal" + delete_channel: "Supprimer le canal" + join_channel: "Rejoindre le canal" + leave_channel: "Quitter le canal" + join: "Rejoindre" + leave: "Quitter" + channel_archive: + title: "Archiver le canal" + instructions: "

    L'archivage d'un canal le place en mode lecture seule et déplace tous les messages du canal vers un sujet nouveau ou existant. Aucun nouveau message ne peut être envoyé et aucun message existant ne peut être modifié ou supprimé.

    Voulez-vous vraiment archiver le canal %{channelTitle}  ?

    " + process_started: "Le processus d'archivage a commencé. Ce modal sera bientôt fermé et vous recevrez un message personnel lorsque le processus d'archivage sera terminé." + retry: "Réessayer" + channel_open: + title: "Ouvrir le canal" + instructions: "Rouvre le canal, tous les utilisateurs pourront envoyer des messages et modifier leurs messages existants." + channel_close: + title: "Fermer le canal" + instructions: "La fermeture du canal empêche les utilisateurs non responsables d'envoyer de nouveaux messages ou de modifier des messages existants. Voulez-vous vraiment fermer ce canal ?" + channel_delete: + title: "Supprimer le canal" + instructions: "

    Supprime le canal %{name} et l'historique des discussions. Tous les messages et données associées, telles que les réactions et les téléversements, seront définitivement supprimés. Si vous souhaitez conserver l'historique du canal et le désactiver, vous pouvez plutôt archiver le canal.

    Voulez-vous vraiment supprimer définitivement le canal ? Pour confirmer, saisissez le nom du canal dans la case ci-dessous.

    " + confirm: "Je comprends les conséquences. Supprimer le canal" + confirm_channel_name: "Saisissez le nom du canal" + process_started: "Le processus de suppression du canal a commencé. Ce modal sera bientôt fermé et vous ne verrez plus le canal supprimé nulle part." + channels_list_popup: + browse: "Parcourir les canaux" + create: "Nouveau canal" + click_to_join: "Cliquez ici pour voir les canaux disponibles." + close: "Fermer" + collapse: "Réduire le tiroir de discussion" + confirm_flag: "Voulez-vous vraiment signaler le message de %{username} ?" + deleted: "Un message a été supprimé. [view]" + hidden: "Un message a été masqué. [view]" + delete: "Supprimer" + edited: "modifié" + muted: "en sourdine" + joined: "rejoint" + empty_state: + direct_message_cta: "Commencer une discussion privée" + direct_message: "Vous pouvez également démarrer une discussion privée avec un ou plusieurs utilisateurs." + title: "Aucun canal trouvé" + email_frequency: + description: "Nous ne vous enverrons un e-mail que si nous ne vous avons pas vu au cours des 15 dernières minutes." + never: "Jamais" + title: "Notifications par e-mail" + when_away: "Seulement en cas d'absence" + enable: "Activer la discussion" + flag: "Drapeau" + emoji: "Insérer un émoji" + flagged: "Ce message a été signalé pour examen" + invalid_access: "Vous n'avez pas accès à ce canal de discussion" + invitation_notification: "%{username} vous a invité(e) à rejoindre un canal de discussion" + in_reply_to: "En réponse à" + heading: "Discussion" + join: "Rejoindre" + new_messages: "nouveaux messages" + mention_warning: + cannot_see: + one: "%{usernames} ne peut pas accéder à ce canal et n'a pas été averti(e)." + other: "%{usernames} ne peuvent pas accéder à ce canal et n'ont pas été avertis." + dismiss: "rejeter" + invitations_sent: + one: "Invitation envoyée" + other: "Invitations envoyées" + invite: "Inviter à rejoindre le canal" + without_membership: + one: "%{usernames} n'a pas rejoint ce canal." + other: "%{usernames} n'ont pas rejoint ce canal." + aria_roles: + header: "En-tête de discussion" + composer: "Compositeur de discussion" + channels_list: "Liste des canaux de discussion" + no_public_channels: "Vous n'avez rejoint aucun canal." + only_chat_push_notifications: + title: "Envoyer uniquement des notifications push de discussion" + description: "Bloquer l'envoi de toutes les notifications push non liées à la discussion" + ignore_channel_wide_mention: + title: "Ignorer les mentions à l'échelle du canal" + description: "Ne pas envoyer de notifications pour les mentions à l'échelle du canal (@here et @all)" + open: "Ouvrir la discussion" + open_full_page: "Ouvrir la discussion en plein écran" + close_full_page: "Fermer la discussion en plein écran" + open_message: "Ouvrir le message dans la discussion" + placeholder_self: "Noter quelque chose" + placeholder_others: "Discuter avec %{messageRecipient}" + placeholder_new_message_disallowed: "Le canal a le statut %{status}, vous ne pouvez pas envoyer de nouveaux messages pour le moment." + placeholder_silenced: "Vous ne pouvez pas envoyer de messages pour le moment." + placeholder_start_conversation: Démarrer une conversation avec %{usernames} + remove_upload: "Supprimer le fichier" + react: "Réagir avec un émoji" + reply: "Répondre" + edit: "Modifier" + copy_link: "Copier le lien" + rebake_message: "Reconstruire le HTML" + retry_staged_message: + title: "Erreur de réseau" + action: "Envoyer à nouveau ?" + unreliable_network: "Le réseau n'est pas fiable, l'envoi de messages et l'enregistrement de brouillon peuvent ne pas fonctionner" + bookmark_message: "Signet" + bookmark_message_edit: "Modifier le signet" + restore: "Restaurer le message supprimé" + save: "Enregistrer" + select: "Sélectionner" + silence: "Désactiver l'utilisateur" + return_to_list: "Retour à la liste des canaux" + scroll_to_bottom: "Défiler vers le bas" + scroll_to_new_messages: "Voir les nouveaux messages" + sound: + title: "Son de notification de discussion sur ordinateur" + sounds: + none: "Aucun" + bell: "Cloche" + ding: "Ding" + title: "discussion" + title_capitalized: "Discussion" + upload: "Joindre un fichier" + uploaded_files: + one: "%{count} fichier" + other: "%{count} fichiers" + you_flagged: "Vous avez signalé ce message" + exit: "retour" + channel_status: + read_only_header: "Le canal est en lecture seule" + read_only: "Lecture seule" + archived_header: "Le canal est archivé" + archived: "Archivé" + archive_failed: "Échec de l'archivage du canal. %{completed}/%{total} messages ont été archivés dans le sujet de destination. Appuyez sur Réessayer pour tenter de terminer l'archivage." + archive_completed: "Voir le sujet de l'archive" + closed_header: "Le canal est fermé" + closed: "Fermé" + open_header: "Le canal est ouvert" + open: "Ouvert" + browse: + title: Canaux + filter_all: Tout + filter_open: Ouvert + filter_closed: Fermé + filter_archived: Archivé + filter_input_placeholder: Rechercher un canal par nom + chat_message_separator: + today: Aujourd'hui + yesterday: Hier + members_view: + filter_placeholder: Trouver des membres + about_view: + associated_topic: Sujet lié + associated_category: Catégorie liée + title: Titre + description: Description + channel_info: + back_to_all_channels: "Tous les canaux" + back_to_channel: "Retour" + tabs: + about: À propos + members: Membres + settings: Paramètres + channel_edit_title_modal: + title: Modifier le titre + input_placeholder: Ajouter un titre + description: Ajoutez un court titre descriptif à votre canal + channel_edit_description_modal: + title: Modifier la description + input_placeholder: Ajouter une description + description: Dites aux utilisateurs en quoi consiste ce canal + direct_message_creator: + title: Nouveau message + prefix: "À :" + no_results: Aucun résultat + selected_user_title: "Désélectionner %{username}" + channel_selector: + title: "Accéder au canal" + no_channels: "Aucun canal ne correspond à votre recherche" + channel: + no_memberships: Ce canal ne comprend aucun membre + no_memberships_found: Aucun membre trouvé + memberships_count: + one: "%{count} membre" + other: "%{count} membres" + create_channel: + auto_join_users: + public_category_warning: "%{category} est une catégorie publique. Ajouter automatiquement tous les utilisateurs récemment actifs à ce canal ?" + warning_groups: + one: Ajouter automatiquement %{members_count}  utilisateurs de %{group} ? + other: Ajouter automatiquement %{members_count} utilisateurs de %{group} et %{group_2} ? + warning_multiple_groups: Ajouter automatiquement %{members_count} utilisateurs de %{group_1} et %{count} autres groupes ? + choose_category: + label: "Choisir une catégorie" + none: "sélectionnez-en une…" + default_hint: Gérer l'accès en visitant les paramètres de sécurité de %{category} + hint_groups: + one: Les utilisateurs de %{hint} auront accès à ce canal selon les paramètres de sécurité + other: Les utilisateurs de %{hint} et de %{hint_2} auront accès à ce canal selon les paramètres de sécurité + hint_multiple_groups: Les utilisateurs de %{hint_1} et de %{count} autres groupes auront accès à ce canal selon les paramètres de sécurité + create: "Créer un canal" + description: "Description (facultative)" + name: "Nom du canal" + title: "Nouveau canal" + type: "Type" + types: + category: "Catégorie" + topic: "Sujet" + reviewable: + type: "Message de discussion" + reactions: + only_you: "Vous avez réagi avec :%{emoji}:" + and_others: "Vous, %{usernames} avez réagi avec :%{emoji}:" + only_others: "%{usernames} a réagi avec :%{emoji} :" + others_and_more: "%{usernames} et %{more} autres utilisateurs ont réagi avec :%{emoji}:" + you_others_and_more: "Vous, %{usernames} et %{more} autres utilisateurs avez réagi avec :%{emoji}:" + composer: + toggle_toolbar: "Basculer la barre d'outils" + italic_text: "texte souligné" + bold_text: "texte gras" + code_text: "texte codé" + quote: + original_channel: 'Envoyé à l''origine dans le canal %{channel}' + copy_success: "Citation de discussion copiée dans le presse-papiers" + notification_levels: + never: "Jamais" + mention: "Seulement pour les mentions" + always: "Pour toutes les activités" + settings: + enable_auto_join_users: "Ajouter automatiquement tous les utilisateurs récemment actifs" + disable_auto_join_users: "Arrêter l'ajout automatique d'utilisateurs" + auto_join_users_warning: "Chaque utilisateur qui n'est pas membre de ce canal et qui a accès à la catégorie %{category} le rejoindra. Voulez-vous continuer ?" + desktop_notification_level: "Notifications sur le bureau" + follow: "Rejoindre" + followed: "Rejoint" + mobile_notification_level: "Notifications push mobiles" + mute: "Mettre le canal en sourdine" + muted_on: "Activé" + muted_off: "Désactivé" + notifications: "Notifications" + preview: "Aperçu" + save: "Enregistrer" + saved: "Enregistré" + unfollow: "Quitter" + admin: + title: "Discussion" + direct_messages: + title: "Discussion privée" + new: "Nouvelle discussion privée" + create: "Valider" + leave: "Quitter cette discussion privée" + cannot_create: "Nous sommes désolés, vous ne pouvez pas envoyer de messages privés." + incoming_webhooks: + back: "Retour" + channel_placeholder: "Sélectionnez un canal" + confirm_destroy: "Voulez-vous vraiment supprimer ce webhook entrant ? Cette action est irréversible." + current_emoji: "Émoji actuel" + description: "Description" + delete: "Supprimer" + emoji: "Émoji" + emoji_instructions: "L'avatar du système sera utilisé si l'émoji est laissé vide." + name: "Nom" + name_placeholder: "Nom…" + new: "Nouveau webhook entrant" + none: "Aucun webhook entrant existant n'a été créé." + no_emoji: "Aucun émoji n'a été sélectionné" + post_to: "Publier sur" + reset_emoji: "Réinitialiser l'émoji" + save: "Enregistrer" + edit: "Modifier" + select_emoji: "Choisir un émoji" + system: "système" + title: "Webhooks entrants" + url: "URL" + url_instructions: "Cette adresse URL contient une valeur secrète - conservez-la précieusement." + username: "Nom d'utilisateur" + username_instructions: "Nom d'utilisateur du robot qui publie sur le canal. La valeur par défaut est « système » lorsqu'elle est laissée vide." + instructions: "Les webhooks entrants peuvent être utilisés par des systèmes externes pour publier des messages dans un canal de discussion désigné en tant qu'utilisateur robot via le point de terminaison /hooks/:key. La charge utile se compose d'un seul paramètre texte, qui est limité à 2000 caractères.

    Nous prenons également en charge des paramètres de texte au format Slack limités, en extrayant des liens et des mentions sur la base du format https://api.slack.com/reference/surfaces/formatting, mais le point de terminaison /hooks/:key/slack doit être utilisé pour cela." + selection: + cancel: "Annuler" + quote_selection: "Citation dans le sujet" + copy: "Copie" + move_selection_to_channel: "Déplacer vers le canal" + error: "Une erreur s'est produite lors du déplacement des messages de la discussion" + title: "Déplacer la discussion vers le sujet" + new_topic: + title: "Déplacer vers un nouveau sujet" + instructions: + one: "Vous êtes sur le point de créer un nouveau sujet et de le remplir avec le message de discussion que vous avez sélectionné." + other: "Vous êtes sur le point de créer un nouveau sujet et de le remplir avec les %{count} messages de discussion que vous avez sélectionnés." + instructions_channel_archive: "Vous êtes sur le point de créer un nouveau sujet et d'y archiver les messages du canal." + existing_topic: + title: "Déplacer vers un sujet existant" + instructions: + one: "Veuillez choisir le sujet vers lequel vous souhaitez déplacer ce message de discussion." + other: "Veuillez choisir le sujet vers lequel vous souhaitez déplacer ces %{count} messages de discussion." + instructions_channel_archive: "Veuillez choisir le sujet dans lequel vous souhaitez archiver les messages du canal." + new_message: + title: "Déplacer vers un nouveau message" + instructions: + one: "Vous êtes sur le point de créer un nouveau message et de le remplir avec le message de discussion que vous avez sélectionné." + other: "Vous êtes sur le point de créer un nouveau message et de le remplir avec les %{count} messages de discussion que vous avez sélectionnés." + replying_indicator: + single_user: "%{username} est en train d'écrire" + multiple_users: "%{commaSeparatedUsernames} et %{lastUsername} sont en train d'écrire" + many_users: + one: "%{commaSeparatedUsernames} et %{count} autre utilisateur sont en train d'écrire" + other: "%{commaSeparatedUsernames} et %{count} autres utilisateurs sont en train d'écrire" + retention_reminders: + public: "L'historique du canal est conservé pendant %{days} jours." + dm: "L'historique des discussions privées est conservé pendant %{days} jours." + topic_button_title: "Discussion" + flags: + off_topic: "Ce message n'est pas pertinent pour la discussion en cours telle que définie par le titre du canal et devrait probablement être déplacé ailleurs." + inappropriate: "Ce message contient du contenu qu'une personne raisonnable considérerait comme offensant, abusif ou contraire à nos consignes communautaires." + spam: "Ce message est une publicité ou du vandalisme. Il n'est pas utile ou pertinent pour le canal actuel." + notify_user: "Je veux parler à cette personne directement et personnellement de son message." + notify_moderators: "Ce message requiert l'attention d'un responsable pour une autre raison non répertoriée ci-dessus." + flagging: + action: "Signaler un message" + emoji_picker: + favorites: "Fréquemment utilisé" + smileys_&_emotion: "Smileys et émotions" + objects: "Objets" + people_&_body: "Personnes et corps" + travel_&_places: "Voyages et lieux" + animals_&_nature: "Animaux et nature" + food_&_drink: "Nourriture et boissons" + activities: "Activités" + flags: "Drapeaux" + symbols: "Symboles" + search_placeholder: "Recherche par nom d'émoji et alias…" + no_results: "Aucun résultat" + draft_channel_screen: + header: "Nouveau message" + cancel: "Annuler" + notifications: + chat_invitation: "vous a invité(e) à rejoindre un canal de discussion" + chat_invitation_html: "%{username} vous a invité(e) à rejoindre un canal de discussion" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'vous a mentionné(e) dans « %{channel} »' + direct_html: '%{username} vous a mentionné(e) dans « %{channel} »' + other_plain: 'a mentionné %{identifier} dans « %{channel} »' + other_html: '%{username} a mentionné %{identifier} dans « %{channel} »' + direct_message_chat_mention: + direct: "vous a mentionné(e) dans la discussion privée" + direct_html: "%{username} vous a mentionné(e) dans une discussion privée" + other_plain: "a mentionné %{identifier} dans une discussion privée" + other_html: "%{username} a mentionné %{identifier} dans une discussion privée" + chat_message: "Nouveau message de discussion" + chat_quoted: "%{username} a cité votre message de discussion" + titles: + chat_mention: "Mention de discussion" + chat_invitation: "Invitation à rejoindre la discussion" + chat_quoted: "Discussion citée" + action_codes: + chat: + enabled: '%{who} a activé la %{when}' + disabled: "%{who} a fermé la discussion %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Envoyer un message de discussion + fields: + chat_channel_id: + label: ID du canal de discussion + message: + label: Message + sender: + label: Expéditeur + description: Paramètres par défaut + review: + transcript: + view: "Afficher la transcription des messages précédents" + types: + reviewable_chat_message: + title: "Message de discussion signalé" + flagged_by: "Signalé par" + keyboard_shortcuts_help: + chat: + title: "Discussion" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Changer de canal" + open_quick_channel_selector: "%{shortcut} Ouvrir le sélecteur de canal rapide" + open_insert_link_modal: "%{shortcut} Insérer un lien hypertexte (compositeur uniquement)" + composer_bold: "%{shortcut} Gras (compositeur uniquement)" + composer_italic: "%{shortcut} Italique (compositeur uniquement)" + composer_code: "%{shortcut} Code (compositeur uniquement)" + drawer_open: "%{shortcut} Ouvrir le tiroir de discussion" + drawer_close: "%{shortcut} Fermer le tiroir de discussion" + topic_statuses: + chat: + help: "La discussion est activée pour ce sujet" + user: + allow_private_messages: "Autoriser les autres utilisateurs à m'envoyer des messages privés et des messages directs de discussion" + muted_users_instructions: "Supprimer toutes les notifications, les messages privés et les messages directs de discussion de ces utilisateurs." + allowed_pm_users_instructions: "Autoriser uniquement les messages privés ou les messages directs de ces utilisateurs." + allow_private_messages_from_specific_users: "Autoriser uniquement des utilisateurs spécifiques à m'envoyer des messages privés ou des messages directs dans la discussion" + ignored_users_instructions: "Supprimer tous les messages, notifications, messages privés et messages directs de discussion de ces utilisateurs." + user_menu: + no_chat_notifications_title: "Vous n'avez pas encore reçu de notification de discussion" + no_chat_notifications_body: > + Vous serez averti(e) dans ce panneau lorsque quelqu'un vous enverra un message direct ou une @mention dans la discussion. Des notifications seront également envoyées à votre adresse e-mail si vous ne vous êtes pas connecté(e) pendant un certain temps.

    Cliquez sur le titre en haut de n'importe quel canal de discussion pour configurer les notifications que vous recevez dans ce canal. Pour en savoir plus, consultez vos préférences de notification. + tabs: + chat_notifications: "Notifications de discussion" + chat_notifications_with_unread: + one: "Notifications de discussion - %{count} notification non lue" + other: "Notifications de discussion - %{count} notifications non lues" diff --git a/plugins/chat/config/locales/client.gl.yml b/plugins/chat/config/locales/client.gl.yml new file mode 100644 index 0000000000..dd8d5ba85c --- /dev/null +++ b/plugins/chat/config/locales/client.gl.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: + js: + chat: + create: "Crear" + cancel: "Cancelar" + channel_settings: + edit: "Editar" + add: "Engadir" + join: "Participar" + leave: "Abandonar" + close: "Pechar" + delete: "Eliminar" + edited: "editado" + muted: "silenciado" + joined: "uniuse" + email_frequency: + never: "Nunca" + flag: "Sinalar" + join: "Participar" + mention_warning: + dismiss: "desbotar" + reply: "Responder" + edit: "Editar" + rebake_message: "Reconstruír HTML" + bookmark_message: "Marcador" + save: "Gardar" + sounds: + none: "Ningunha" + exit: "volver" + channel_status: + closed: "Pechado" + open: "Abrir" + browse: + back: "Volver" + filter_all: Todas + filter_closed: Pechado + chat_message_separator: + today: Hoxe + yesterday: Onte + about_view: + title: Título + description: Descrición + channel_info: + back_to_channel: "Volver" + tabs: + about: Verbo de + members: Membros + settings: Configuración + direct_message_creator: + title: Nova mensaxe + prefix: "A:" + create_channel: + type: "Tipo" + types: + category: "Categoría" + topic: "Tema" + composer: + italic_text: "texto recalcado" + bold_text: "texto groso" + notification_levels: + never: "Nunca" + settings: + follow: "Participar" + followed: "Uniuse" + notifications: "Notificacións" + preview: "Visualizar" + save: "Gardar" + saved: "Gardado" + unfollow: "Abandonar" + incoming_webhooks: + back: "Volver" + description: "Descrición" + delete: "Eliminar" + emoji: "Emoji" + name: "Nome" + save: "Gardar" + edit: "Editar" + system: "sistema" + url: "URL" + username: "Nome de usuario" + selection: + cancel: "Cancelar" + copy: "Copiar" + new_topic: + title: "Mover ao tema novo" + existing_topic: + title: "Mover a un tema existente" + new_message: + title: "Mover a Nova mensaxe" + emoji_picker: + objects: "Obxectos" + activities: "Actividades" + flags: "Alertas" + symbols: "Símbolos" + draft_channel_screen: + header: "Nova mensaxe" + cancel: "Cancelar" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Mensaxe + review: + types: + reviewable_chat_message: + flagged_by: "Sinalado por" diff --git a/plugins/chat/config/locales/client.he.yml b/plugins/chat/config/locales/client.he.yml new file mode 100644 index 0000000000..571c4b9c90 --- /dev/null +++ b/plugins/chat/config/locales/client.he.yml @@ -0,0 +1,476 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "מצב ערוץ הצ׳אט השתנה" + chat_channel_delete: "ערוץ הצ׳אט נמחק" + api: + scopes: + descriptions: + chat: + create_message: "יצירת הודעת צ׳אט בערוץ מסוים." + about: + chat_messages_count: "הודעות צ׳אט" + chat_channels_count: "ערוצי צ׳אט" + chat_users_count: "משתמשי צ׳אט" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "כל ההודעות מוצגות" + already_enabled: "כבר מופעל צ׳אט בנושא הזה. נא לרענן." + disabled_for_topic: "הצ׳אט מושבת בנושא הזה." + bot: "בוט" + create: "ליצור" + cancel: "ביטול" + cancel_reply: "ביטול תגובה" + chat_channels: "ערוצים" + browse_all_channels: "עיון בכל הערוצים" + move_to_channel: + title: "העברת הודעות לערוץ" + instructions: + one: "פעולה זו תעביר הודעה %{count}. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעה הזאת הועברה." + two: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + many: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + other: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + confirm_move: "העברת הודעות" + channel_settings: + title: "הגדרות ערוץ" + edit: "עריכה" + add: "הוספה" + close_channel: "סגירת ערוץ" + open_channel: "פתיחת ערוץ" + archive_channel: "העברת ערוץ לארכיון" + delete_channel: "מחיקת ערוץ" + join_channel: "הצטרפות לערוץ" + leave_channel: "יציאה מהערוץ" + join: "הצטרף" + leave: "עזוב" + channel_archive: + title: "העברת ערוץ לארכיון" + instructions: "

    העברת ערוץ לארכיון מעבירה אותו למצב לקריאה בלבד ומעביר את כל ההודעות מהערוץ לנושא חדש או קיים. אי אפשר לשלוח הודעות חדשות ואי אפשר לערוך או למחוק הודעות קיימות.

    להעביר את הערוץ ‎%{channelTitle} לארכיון?

    " + process_started: "תהליך ההעברה לארכיון החל. חלונית צצה זו תיסגר בקרוב ותישלח אליך הודעה אישית כשתהליך ההעברה לארכיון יסתיים." + retry: "לנסות שוב" + channel_open: + title: "פתיחת ערוץ" + instructions: "פתיחת הערוץ מחדש, כל המשתמשים יוכלו לשלוח הודעות ולערוך את ההודעות הקיימות שלהם." + channel_close: + title: "סגירת ערוץ" + instructions: "סגירת הערוץ מונעת ממשתמשים מחוץ לסגל לשלוח הודעות חדשות או לערוך הודעות קיימות. לסגור את הערוץ הזה?" + channel_delete: + title: "מחיקת ערוץ" + instructions: "

    תהליך זה ימחק את הערוץ %{name} ואת היסטוריית ההתכתבות בו. כל ההודעות והנתונים הקשורים כגון תגובות והעלאות יימחקו לחלוטין. אם עדיף לך לשמר את היסטוריית הערוץ ולבטל אותו, אפשר להעביר את הערוץ לארכיון במקום.

    למחוק את הערוץ לצמיתות? כדי לאשר, נא למלא את שם הערוץ בתיבה שלהלן.

    " + confirm: "ההשלכות ברורות לי, נא למחוק את הערוץ" + confirm_channel_name: "נא למלא את שם הערוץ" + process_started: "תהליך מחיקת הערוץ החל. חלונית צצה זו תיסגר בקרוב, הערוץ שנמחק לא יופיעו עוד בשום מקום." + channels_list_popup: + browse: "עיון בערוצים" + create: "ערוץ חדש" + click_to_join: "לחיצה כאן תציג את הערוצים הזמינים." + close: "סגירה" + collapse: "צמצום מגירת צ׳אט" + confirm_flag: "לסמן את ההודעה של %{username}?" + deleted: "הודעה נמחקה. [צפייה]" + hidden: "הודעה הוסתרה. [צפייה]" + delete: "מחיקה" + edited: "נערך" + muted: "בהשתקה" + joined: "הצטרפו" + empty_state: + direct_message_cta: "התחלת צ׳אט אישי" + direct_message: "אפשר לפתוח בצ׳אט אישי עם משתמש אחד או יותר." + title: "לא נמצאו ערוצים" + email_frequency: + description: "נשלח לך דוא״ל אם לא ראינו אותך ב־15 הדקות האחרונות." + never: "לעולם לא" + title: "הודעות בדוא״ל" + when_away: "רק כשלא במערכת" + enable: "הפעלת צ׳אט" + flag: "דיגול" + emoji: "הוספת אמוג׳י" + flagged: "הודעה זו סומנה לסקירה" + invalid_access: "אין לך גישה לצפות בערוץ הצ׳אט הזה" + invitation_notification: "הוזמנת להצטרף לערוץ צ׳אט על ידי %{username}" + in_reply_to: "בתגובה אל" + heading: "צ׳אט" + join: "הצטרף" + new_messages: "הודעות חדשות" + mention_warning: + cannot_see: + one: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + two: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + many: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + other: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + dismiss: "התעלמות" + invitations_sent: + one: "נשלחה הזמנה" + two: "נשלחו הזמנות" + many: "נשלחו הזמנות" + other: "נשלחו הזמנות" + invite: "הזמנה לערוץ" + without_membership: + one: "לא הצטרפו לערוץ הזה: %{usernames}." + two: "%{usernames} לא הצטרפו לערוץ הזה." + many: "%{usernames} לא הצטרפו לערוץ הזה." + other: "%{usernames} לא הצטרפו לערוץ הזה." + aria_roles: + header: "כותרת צ׳אט" + composer: "כותב צ׳אט" + channels_list: "רשימת ערוצי צ׳אט" + no_public_channels: "לא הצטרפת לאף ערוץ." + only_chat_push_notifications: + title: "לשלוח התראות בדחיפה על הצ׳אט בלבד" + description: "לחסום שליחה של התראות בדחיפה שאינן לגבי הצ׳אט" + ignore_channel_wide_mention: + title: "התעלמות מאזכורים לכל הערוץ" + description: "לא לשלוח התראות על אזכורים לכל הערוץ (‎@here ו־‎@all)" + open: "פתיחת צ׳אט" + open_full_page: "פתיחת צ׳אט במסך מלא" + close_full_page: "סגירת צ׳אט במסך מלא" + open_message: "פתיחת הודעה בצ׳אט" + placeholder_self: "לקשקש משהו" + placeholder_others: "צ׳אט עם %{messageRecipient}" + placeholder_new_message_disallowed: "הערוץ %{status}, אין לך אפשרות לשלוח הודעות חדשות כעת." + placeholder_silenced: "אין לך אפשרות לשלוח הודעות כרגע." + placeholder_start_conversation: פתיחת דיון עם %{usernames} + remove_upload: "הסרת קובץ" + react: "להגיב עם אמוג׳י" + reply: "להגיב" + edit: "עריכה" + copy_link: "העתקת קישור" + rebake_message: "בנייה מחודשת של HTML" + retry_staged_message: + title: "שגיאת רשת" + action: "לשלוח שוב?" + unreliable_network: "הרשת אינה אמינה, ייתכן ששליחת הודעות ושמירת טיוטה לא תפעלנה" + bookmark_message: "סימנייה" + bookmark_message_edit: "עריכת סימנייה" + restore: "שחזור הודעה שנמחקה" + save: "שמירה" + select: "בחירה" + silence: "השתקת משתמש" + return_to_list: "חזרה לרשימת הערוצים" + scroll_to_bottom: "גלילה לתחתית" + scroll_to_new_messages: "הצגת הודעות חדשות" + sound: + title: "צליל התראת צ׳אט שולחן עבודה" + sounds: + none: "ללא" + bell: "פעמון" + ding: "דינג" + title: "צ׳אט" + title_capitalized: "צ׳אט" + upload: "צירוף קובץ" + uploaded_files: + one: "קובץ %{count}" + two: "%{count} קבצים" + many: "%{count} קבצים" + other: "%{count} קבצים" + you_flagged: "סימנת את ההודעה הזאת" + exit: "חזרה" + channel_status: + read_only_header: "הערוץ הוא לקריאה בלבד" + read_only: "לקריאה בלבד" + archived_header: "הערוץ בארכיון" + archived: "בארכיון" + archive_failed: "העברת הערוץ לארכיון נכשלה. %{completed}/%{total} הודעות הועברו לארכיון תחת נושא היעד. נא לנסות להשלים את ההעברה לארכיון פעם נוספת." + archive_completed: "אפשר לעיין בארכיון הנושא" + closed_header: "הערוץ סגור" + closed: "סגורה" + open_header: "הערוץ פתוח" + open: "פתיחה" + browse: + back: "חזרה" + title: ערוצים + filter_all: הכול + filter_open: נפתחו + filter_closed: סגורה + filter_archived: בארכיון + filter_input_placeholder: חיפוש ערוץ לפי שם + chat_message_separator: + today: היום + yesterday: אתמול + members_view: + filter_placeholder: איתור חברים + about_view: + associated_topic: נושא מקושר + associated_category: קטגוריה מקושרת + title: כותרת + description: תיאור + channel_info: + back_to_all_channels: "כל הערוצים" + back_to_channel: "חזרה" + tabs: + about: אודות + members: חברים + settings: הגדרות + channel_edit_title_modal: + title: עריכת כותרת + input_placeholder: הוספת כותרת + description: נא לספק כותרת ברורה לערוץ שלך + channel_edit_description_modal: + title: עריכת תיאור + input_placeholder: הוספת תיאור + description: כדי לספר לאנשים על מה הערוץ הזה + direct_message_creator: + title: הודעה חדשה + prefix: "אל:" + no_results: אין תוצאות + selected_user_title: "ביטול בחירת %{username}" + channel_selector: + title: "קפיצה לערוץ" + no_channels: "אין ערוצים שתואמים לחיפוש שלך" + channel: + no_memberships: אין חברים בערוץ הזה + no_memberships_found: לא נמצאו חברים + memberships_count: + one: "חבר %{count}" + two: "%{count} חברים" + many: "%{count} חברים" + other: "%{count} חברים" + create_channel: + auto_join_users: + public_category_warning: "%{category} היא קטגוריה ציבורית. להוסיף את כל המשתמשים שהיו פעילים לאחרונה לערוץ הזה?" + warning_groups: + one: להוסיף %{members_count} משתמשים מתוך %{group} אוטומטית? + two: להוסיף %{members_count} משתמשים מתוך %{group} ומתוך %{group_2} אוטומטית? + many: להוסיף %{members_count} משתמשים מתוך %{group} ומתוך %{group_2} אוטומטית? + other: להוסיף %{members_count} משתמשים מתוך %{group} ומתוך %{group_2} אוטומטית? + warning_multiple_groups: להוסיף %{members_count} משתמשים מתוך %{group_1} ועוד %{count} קבוצות אוטומטית? + choose_category: + label: "נא לבחור קטגוריה" + none: "נא לבחור אחד…" + default_hint: ניתן לנהל את הגישה באמצעות ביקור בהגדרות האבטחה של %{category} + hint_groups: + one: למשתמשים ב־%{hint} תהיה גישה לערוץ בהתאם להגדרות האבטחה + two: למשתמשים ב־%{hint} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + many: למשתמשים ב־%{hint} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + other: למשתמשים ב־%{hint} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + hint_multiple_groups: למשתמשים ב־%{hint_1} וב־%{count} קבוצות נוספות תהיה גישה לערוץ בהתאם להגדרות האבטחה + create: "יצירת ערוץ" + description: "תיאור (רשות)" + name: "שם הערוץ" + title: "ערוץ חדש" + type: "סוג" + types: + category: "קטגוריה" + topic: "נושא" + reviewable: + type: "הודעת צ׳אט" + reactions: + only_you: "הגבת עם :%{emoji}:" + and_others: "הגבת, יחד עם %{usernames} באמוג׳י :%{emoji}:" + only_others: "%{usernames} הגיבו באמוג׳י :%{emoji}:" + others_and_more: "%{usernames} ו־%{more} נוספים הגיבו באמוג׳י :%{emoji}:" + you_others_and_more: "הגבת, יחד עם %{usernames} ו־%{more} נוספים באמוג׳י :%{emoji}:" + composer: + toggle_toolbar: "החלפת מצב סרגל כלים" + italic_text: "טקסט נטוי" + bold_text: "טקסט מודגש" + code_text: "טקסט קוד" + quote: + original_channel: 'נשלח במקור ב־%{channel}' + copy_success: "ציטוט מהצ׳אט הועתק ללוח הגזירים" + notification_levels: + never: "לעולם לא" + mention: "רק אזכורים" + always: "לכל פעילות" + settings: + enable_auto_join_users: "להוסיף אוטומטית את כל המשתמשים שהיו פעילים לאחרונה" + disable_auto_join_users: "להפסיק להוסיף משתמשים אוטומטית" + auto_join_users_warning: "כל משתמש שאינו חבר בערוץ הזה ויש לו גישה לקטגוריה %{category} יצטרף. זה בסדר?" + desktop_notification_level: "התראות שולחן עבודה" + follow: "הצטרף" + followed: "הצטרפו" + mobile_notification_level: "התראות בדחיפה לנייד" + mute: "השתקת ערוץ" + muted_on: "פעילה" + muted_off: "כבויה" + notifications: "התראות" + preview: "תצוגה מקדימה" + save: "שמירה" + saved: "נשמר" + unfollow: "עזוב" + admin: + title: "צ׳אט" + direct_messages: + title: "צ׳אט אישי" + new: "צ׳אט אישי חדש" + create: "קדימה" + leave: "יציאה מהצ׳אט האישי הזה" + cannot_create: "מחילה, אין לך אפשרות לשלוח הודעות ישירות." + incoming_webhooks: + back: "חזרה" + channel_placeholder: "בחירת ערוץ" + confirm_destroy: "למחוק את ההתלייה הנכנסת? אי אפשר לבטל את זה." + current_emoji: "ה־Emoji הנוכחי" + description: "תיאור" + delete: "מחיקה" + emoji: "אמוג׳י" + emoji_instructions: "אם האמוג׳י יישאר ריק ייעשה שימוש בתמונה הייצוגית של המערכת." + name: "שם" + name_placeholder: "שם…" + new: "התליה נכנסת חדשה" + none: "לא נוצרו התליות נכנסות קיימות." + no_emoji: "לא נבחר אמוג׳י" + post_to: "פרסום אל" + reset_emoji: "איפוס אמוג׳י" + save: "שמירה" + edit: "עריכה" + select_emoji: "בחירת אמוג׳י" + system: "מערכת" + title: "התליות נכנסות" + url: "כתובת" + url_instructions: "כתובת זו מכילה ערך סודי - כדאי לשמור עליה בצורה מאובטחת." + username: "שם משתמש" + username_instructions: "שם משתמש הבוט שמפרסם לערוץ. ברירת המחדל היא ‚מערכת’ כשזה נשאר ריק." + instructions: "מערכות חיצוניות יכולות להשתמש בהתליות כדי לפרסם הודעות לערוץ צ׳אט מסוים כמשתמש בוט דרך נקודת הגישה ‏/hooks/:key‎. המטען מורכב ממשתנה text (טקסט) יחיד שמוגבל ל־2000 תווים.

    אנו תומכים גם במשתני text בעיצוב Slack, חילוץ קישורים ואזכורים לפי התקן https://api.slack.com/reference/surfaces/formatting, לשם כך יש להשתמש בנקודת הקצה ‎/hooks/:key/slack‎." + selection: + cancel: "ביטול" + quote_selection: "ציטוט בנושא" + copy: "העתקה" + move_selection_to_channel: "העברה לערוץ" + error: "אירעה שגיאה בהעברת הודעות הצ׳אט" + title: "העברת צ׳אט לנושא" + new_topic: + title: "העבר לנושא חדש" + instructions: + one: "פעולה זו תיצור נושא חדש ותמלא בו את הודעת הצ׳אט שבחרת." + two: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + many: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + other: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + instructions_channel_archive: "פעולה זו תיצור נושא חדש ותעביר אליו את הודעות הערוץ כארכיון." + existing_topic: + title: "העבר לנושא קיים" + instructions: + one: "נא לבחור את הנושא אליו ברצונך להעביר את הודעת הצ׳אט הזו." + two: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + many: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + other: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + instructions_channel_archive: "נא לבחור את הנושא אליו ברצונך להעביר את הודעות הערוץ כארכיון." + new_message: + title: "העבר להודעה חדשה" + instructions: + one: "פעולה זו תיצור הודעה חדשה ותמלא בה את הודעת הצ׳אט שבחרת." + two: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + many: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + other: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + replying_indicator: + single_user: "מתבצעת הקלדה מצד %{username}" + multiple_users: "%{commaSeparatedUsernames} וגם %{lastUsername} מקלידים" + many_users: + one: "%{commaSeparatedUsernames} ועוד %{count} בנוסף מקלידים" + two: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + many: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + other: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + retention_reminders: + public: "היסטוריית הערוץ נשמרת למשך %{days} ימים." + dm: "היסטוריית הצ׳אט האישית נשמרת למשך %{days} ימים." + topic_button_title: "צ׳אט" + flags: + off_topic: "הודעה זו לא תואמת לדיון הנוכחי כפי שהוגדר בכותרת הערוץ וכנראה שצריך להעביר אותה." + inappropriate: "הודעה זו מכילה תוכן שאדם מן השורה עשוי להחשיב כפוגעני, נצלני או מפר את הכללים המנחים את הקהילה שלנו." + spam: "הודעה זו היא פרסומת או השחתה. היא אינה שימושית או רלוונטית לערוץ הנוכחי." + notify_user: "אשמח לדבר עם אותו גורם ישירות ובאופן אישי על ההודעה שנשלחה על ידי הגורם." + notify_moderators: "הודעה זו דורשת את התערבות הסגל מסיבות אחרות שאינן מופיעות לעיל." + flagging: + action: "סימון הודעה בדגל" + emoji_picker: + favorites: "נפוצים" + smileys_&_emotion: "חייכנים ורגשות" + objects: "עצמים" + people_&_body: "אנשים וגוף" + travel_&_places: "טיולים ומקומות" + animals_&_nature: "חיות וטבע" + food_&_drink: "מזון ומשקאות" + activities: "פעילויות" + flags: "דגלים" + symbols: "סמלים" + search_placeholder: "חיפוש לפי שם וכינוי של האמוג׳י…" + no_results: "אין תוצאות" + draft_channel_screen: + header: "הודעה חדשה" + cancel: "ביטול" + notifications: + chat_invitation: "נשלחה אליך הזמנה להצטרף לערוץ צ׳אט" + chat_invitation_html: "הוזמנת להצטרף לערוץ צ׳אט על ידי %{username}" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'אוזכרת בערוץ „%{channel}”' + direct_html: 'אוזכרת בערוץ „%{channel}” על ידי %{username}' + other_plain: 'נוסף אזכור של %{identifier} בערוץ „%{channel}”' + other_html: 'נוסף אזכור של %{identifier} בערוץ „%{channel}” על ידי %{username}' + direct_message_chat_mention: + direct: "אוזכרת בצ׳אט אישי" + direct_html: "אוזכרת בצ׳אט אישי על ידי %{username}" + other_plain: "נוסף אזכור של %{identifier} בצ׳אט אישי" + other_html: "נוסף אזכור של ‎%{identifier} בצ׳אט אישי על ידי %{username}" + chat_message: "הודעת צ׳אט חדשה" + chat_quoted: "הודעת הצ׳אט שלך צוטטה על ידי %{username}" + titles: + chat_mention: "איזכור בצ׳אט" + chat_invitation: "הזמנה לצ׳אט" + chat_quoted: "הצ׳אט צוטט" + action_codes: + chat: + enabled: 'ההופעל על ידי%{who} ב־%{when}' + disabled: "הצ׳אט %{when} נסגר על ידי %{who}" + discourse_automation: + scriptables: + send_chat_message: + title: שליחת הודעת צ׳אט + fields: + chat_channel_id: + label: מזהה ערוץ צ׳אט + message: + label: הודעה + sender: + label: מוען + description: ברירת המחדל כמו המערכת + review: + transcript: + view: "הצגת תמלול הודעות קודמות" + types: + reviewable_chat_message: + title: "הודעת צ׳אט מסומנת" + flagged_by: "דוגל על ידי" + keyboard_shortcuts_help: + chat: + title: "צ׳אט" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} החלפת ערוץ" + open_quick_channel_selector: "%{shortcut} פתיחת בורר הערוצים המהיר" + open_insert_link_modal: "%{shortcut} הוספת קישור (עורך בלבד)" + composer_bold: "%{shortcut} מודגש (עורך בלבד)" + composer_italic: "%{shortcut} נטוי (עורך בלבד)" + composer_code: "%{shortcut} קוד (עורך בלבד)" + drawer_open: "%{shortcut} פתיחת מגירת הצ׳אט" + drawer_close: "%{shortcut} סגירת מגירת הצ׳אט" + topic_statuses: + chat: + help: "הצ׳אט מופעל בנושא הזה" + user: + allow_private_messages: "לאפשר למשתמשים אחרים לשלוח לי הודעות פרטיות והודעות ישירות בצ׳אט" + muted_users_instructions: "לדחות את כל ההתראות על הודעות אישיות והודעות ישירות בצ׳אט מהמשמתמשים האלה." + allowed_pm_users_instructions: "לאפשר רק הודעות אישיות או הודעות ישירות בצ׳אט מהמשמתמשים האלה." + allow_private_messages_from_specific_users: "לאפשר רק למשתמשים מסוימים לשלוח לי הודעות פרטיות או הודעות ישירות בצ׳אט" + ignored_users_instructions: "לדחות את כל הפוסטים, ההודעות, ההתראות, ההודעות אישיות וההודעות הישירות בצ׳אט מהמשמתמשים האלה." + user_menu: + no_chat_notifications_title: "עדיין אין לך התראות צ׳אט" + no_chat_notifications_body: > + תישלח אליך התראה בלוח הזה כאשר תישלח אליך הודעה ישירה או שיהיה @אזכור שלך בצ׳אט. תישלחנה התראות לדוא״ל שלך אם לא נכנסת מזה זמן רב.

    לחיצה על הכותרת בראש כל ערוץ צ׳אט שהוא תעביר אותך להגדרת ההתראות שתישלחנה אליך באותו הערוץ. למידע נוסף, כדאי לבקר בהעדפות ההתראות שלך. + tabs: + chat_notifications: "התראות צ׳אט" + chat_notifications_with_unread: + one: "התראות צ׳אט - התראה %{count} שלא נקראה" + two: "התראות צ׳אט - %{count} התראות שלא נקראו" + many: "התראות צ׳אט - %{count} התראות שלא נקראו" + other: "התראות צ׳אט - %{count} התראות שלא נקראו" diff --git a/plugins/chat/config/locales/client.hr.yml b/plugins/chat/config/locales/client.hr.yml new file mode 100644 index 0000000000..245e81fc4e --- /dev/null +++ b/plugins/chat/config/locales/client.hr.yml @@ -0,0 +1,198 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Status chat kanala je promijenjen" + chat_channel_delete: "Chat kanal je izbrisan" + api: + scopes: + descriptions: + chat: + create_message: "Izradite chat poruku na određenom kanalu." + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Prikaz svih poruka" + already_enabled: "Chat je već omogućen na ovu temu. Molimo osvježite." + disabled_for_topic: "Chat je onemogućen na ovu temu." + bot: "bot" + create: "Kreiraj" + cancel: "Odustani" + cancel_reply: "Otkaži odgovor" + chat_channels: "Kanali" + move_to_channel: + title: "Premještanje poruka na kanal" + confirm_move: "Premjesti poruke" + channel_settings: + title: "Postavke kanala" + edit: "Uredi" + add: "Dodaj" + leave_channel: "Napusti kanal" + join: "Pridružite se" + leave: "Napustiti" + channel_archive: + title: "Arhiviraj kanal" + instructions: "

    Arhiviranje kanala stavlja ga u način rada samo za čitanje i premješta sve poruke s kanala u novu ili postojeću temu. Ne mogu se slati nove poruke, niti se postojeće poruke ne mogu uređivati ili brisati.

    Jeste li sigurni da želite arhivirati %{channelTitle} kanal?

    " + process_started: "Počeo je proces arhiviranja. Ovaj način će se uskoro zatvoriti, a vi ćete dobiti osobnu poruku kada je proces arhive završen." + retry: "Pokušaj ponovo" + channel_open: + title: "Otvori kanal" + instructions: "Ponovno otvara kanal, svi korisnici će moći slati poruke i uređivati svoje postojeće poruke." + channel_close: + title: "Zatvori kanal" + instructions: "Zatvaranje kanala onemogućuje korisnicima koji nisu zaposlenici da šalju nove poruke ili uređuju postojeće poruke. Jeste li sigurni da želite zatvoriti ovaj kanal?" + channel_delete: + title: "Izbriši kanal" + instructions: "

    Briše %{name} kanal i povijest razgovora. Sve poruke i srodni podaci, kao što su reakcije i prijenosi, trajno će biti izbrisani. Ako želite sačuvati povijest kanala i raspada ga, možda želite arhivirati kanal umjesto.

    Jeste li sigurni da želite trajno izbrisati kanal? Da biste potvrdili, upišite naziv kanala u okvir ispod.

    " + confirm: "Razumijem posljedice, obriši kanal" + confirm_channel_name: "Unesite naziv kanala" + process_started: "Započeo je proces brisanja kanala. Ovaj modal će se uskoro zatvoriti, više nigdje nećete vidjeti izbrisani kanal." + channels_list_popup: + browse: "Pretražujte kanale" + click_to_join: "Kliknite ovdje da biste vidjeli dostupne kanale." + close: "Zatvori" + collapse: "Sažmi ladicu za chat" + confirm_flag: "Jeste li sigurni da želite označiti poruku korisnika %{username}?" + deleted: "Poruka je izbrisana. [pogledaj]" + delete: "Pobriši" + edited: "uredio" + muted: "utišano" + joined: "prijavljen" + empty_state: + direct_message: "Također možete započeti osobni razgovor s jednim ili više korisnika." + email_frequency: + never: "Nikad" + enable: "Omogući chat" + flag: "Označi zastavicom" + flagged: "Ova poruka je označena za pregled" + invalid_access: "Nemate pristup za pregled ovog kanala za chat" + invitation_notification: "%{username} vas je pozvao da se pridružite chat kanalu" + in_reply_to: "U odgovoru na" + heading: "Čet" + join: "Pridružite se" + new_messages: "nove poruke" + mention_warning: + cannot_see: + one: "%{usernames} ne može pristupiti ovom kanalu i nije obaviješten." + few: "%{usernames} ne mogu pristupiti ovom kanalu i nisu obaviješteni." + other: "%{usernames} ne mogu pristupiti ovom kanalu i nisu obaviješteni." + dismiss: "skloni" + invitations_sent: + one: "Poziv poslan" + few: "Pozivnice poslane" + other: "Pozivnice poslane" + invite: "Pozovite na kanal" + without_membership: + one: "%{usernames} se nije pridružio ovom kanalu." + few: "%{usernames} se nisu pridružili ovom kanalu." + other: "%{usernames} se nisu pridružili ovom kanalu." + aria_roles: + header: "Zaglavlje chata" + composer: "Skladatelj chata" + reply: "Odgovor" + edit: "Uredi" + rebake_message: "Popravi HTML" + bookmark_message: "Zabilješka" + bookmark_message_edit: "Uredi oznaku" + save: "Spremi" + sounds: + none: "Ništa" + title: "čet" + title_capitalized: "Čet" + exit: "natrag" + channel_status: + closed: "Zatvoreno" + open: "Otvori" + browse: + back: "Natrag" + title: Kanali + filter_all: Sve + filter_closed: Zatvoreno + chat_message_separator: + today: Danas + yesterday: Jučer + about_view: + title: Naslov + description: Opis + channel_info: + back_to_channel: "Natrag" + tabs: + about: O nama + members: Članovi + settings: Postavke + direct_message_creator: + title: Nova poruka + prefix: "Za:" + create_channel: + type: "Tip" + types: + category: "Kategorija" + topic: "Tema" + composer: + italic_text: "naglašen tekst" + bold_text: "jaki text" + notification_levels: + never: "Nikad" + settings: + follow: "Pridružite se" + followed: "Prijavljen" + notifications: "Obavijest" + preview: "Pregled" + save: "Spremi" + saved: "Spremljeno" + unfollow: "Napustiti" + admin: + title: "Čet" + incoming_webhooks: + back: "Natrag" + description: "Opis" + delete: "Pobriši" + emoji: "Emoji" + name: "Ime" + save: "Spremi" + edit: "Uredi" + system: "sistem" + url: "URL" + username: "Korisničko ime" + selection: + cancel: "Odustani" + copy: "Kopija" + new_topic: + title: "Prebaci u novu temu" + existing_topic: + title: "Prebaci u postojeću temu" + new_message: + title: "Premjestite u novu poruku" + topic_button_title: "Čet" + emoji_picker: + objects: "Objekti" + activities: "Aktivnosti" + flags: "Oznake zastavicom" + symbols: "Simboli" + draft_channel_screen: + header: "Nova poruka" + cancel: "Odustani" + notifications: + chat_invitation_html: "%{username} vas je pozvao da se pridružite chat kanalu" + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Poruka + review: + types: + reviewable_chat_message: + flagged_by: "Označio" + keyboard_shortcuts_help: + chat: + title: "Čet" diff --git a/plugins/chat/config/locales/client.hu.yml b/plugins/chat/config/locales/client.hu.yml new file mode 100644 index 0000000000..bc4a5e13b9 --- /dev/null +++ b/plugins/chat/config/locales/client.hu.yml @@ -0,0 +1,403 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "A csevegőcsatorna állapota megváltozott" + chat_channel_delete: "Csevegőcsatorna törölve" + api: + scopes: + descriptions: + chat: + create_message: "Csevegőüzenet létrehozása egy megadott csatornán." + about: + chat_messages_count: "Csevegőüzenetek" + chat_channels_count: "Csevegőcsatornák" + chat_users_count: "Csevegőfelhasználók" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Összes üzenet megjelenítése" + already_enabled: "A csevegés már engedélyezve van ebben a témában. Frissítsen." + disabled_for_topic: "A csevegés le van tiltva ebben a témában." + bot: "bot" + create: "Létrehozás" + cancel: "Mégse" + cancel_reply: "Válasz elvetése" + chat_channels: "Csatornák" + browse_all_channels: "Összes csatorna böngészése" + move_to_channel: + title: "Üzenetek áthelyezése a csatornába" + confirm_move: "Üzenetek áthelyezése" + channel_settings: + title: "Csatornabeállítások" + edit: "Szerkesztés" + add: "Új" + close_channel: "Csatorna bezárása" + open_channel: "Csatorna megnyitása" + archive_channel: "Csatorna archiválása" + delete_channel: "Csatorna törlése" + join_channel: "Csatlakozás a csatornához" + leave_channel: "Csatorna elhagyása" + join: "Belépés" + leave: "Elhagyás" + channel_archive: + title: "Csatorna archiválása" + instructions: "

    Egy csatorna archiválása írásvédett módba helyezi azt, és a csatorna összes üzenetét egy új vagy meglévő témába helyezi át. Új üzeneteket nem lehet küldeni, és a meglévő üzeneteket nem lehet szerkeszteni vagy törölni.

    Biztos, hogy archiválja a(z) %{channelTitle} csatornát?

    " + process_started: "Az archiválási folyamat megkezdődött. Ez az ablak rövidesen bezárul, és az archiválási folyamat befejeztével személyes üzenetet kap." + retry: "Újra" + channel_open: + title: "Csatorna megnyitása" + instructions: "Újranyitja a csatornát, az összes felhasználó fog tudni üzeneteket küldeni és szerkeszteni a meglévőket." + channel_close: + title: "Csatorna bezárása" + instructions: "A csatorna lezárása megakadályozza, hogy a nem stábtagok új üzeneteket küldjenek vagy szerkesszék a meglévő üzeneteket. Biztos, hogy be akarja zárni ezt a csatornát?" + channel_delete: + title: "Csatorna törlése" + instructions: "

    Törli a(z) %{name} csatornát és a csevegési előzményeket. Minden üzenet és a kapcsolódó adat, például a reakciók és a feltöltések véglegesen törlődnek. Ha meg szeretné őrizni a csatorna előzményeit, de meg akarja szüntetni, akkor inkább archiválja a csatornát.

    Biztos, hogy véglegesen törli a csatornát? A megerősítéshez írja be a csatorna nevét az alábbi mezőbe.

    " + confirm: "Megértem a következményeket, a csatorna törlése" + confirm_channel_name: "Adja meg a csatorna nevét" + process_started: "A csatorna törlése megkezdődött. Ez a kérdésablak hamarosan bezárul, és már nem fogja látni a törölt csatornát." + channels_list_popup: + browse: "Csatornák böngészése" + click_to_join: "Kattintson ide az elérhető csatornák megtekintéséhez." + close: "Lezárás" + collapse: "Csevegőfiók összecsukása" + confirm_flag: "Biztos, hogy jelenti %{username} üzenetét?" + deleted: "Egy üzenet törölve lett. [megtekintés]" + hidden: "Egy üzenet el lett rejtve. [megtekintés]" + delete: "Törlés" + edited: "szerkesztve" + muted: "némítás" + joined: "csatlakozott" + empty_state: + direct_message_cta: "Személyes csevegés indítása" + direct_message: "Személyes csevegést is indíthat egy vagy több felhasználóval." + title: "Nem található csatorna" + email_frequency: + description: "Csak akkor küldünk e-mailt, ha az elmúlt 15 percben nem láttuk." + never: "Soha" + title: "E-mail értesítések" + when_away: "Csak ha távol van" + enable: "Csevegés engedélyezése" + flag: "Jelölés" + flagged: "Ezt az üzenetet felülvizsgálatra jelentették" + invalid_access: "Nincs hozzáférése ennek a csevegőcsatornának a megtekintéséhez" + invitation_notification: "%{username} meghvta Önt, hogy csatlakozzon egy csevegőcsatornához" + in_reply_to: "Válaszul erre:" + heading: "Csevegés" + join: "Belépés" + new_messages: "új üzenetek" + mention_warning: + cannot_see: + one: "%{usernames} nem fér hozzá ehhez a csatornához, és nem lett értesítve." + other: "%{usernames} nem fér hozzá ehhez a csatornához, és nem lettek értesítve." + dismiss: "elvetés" + invitations_sent: + one: "Meghívó elküldve" + other: "Meghívók elküldve" + invite: "Meghívás a csatornára" + without_membership: + one: "%{usernames} nem csatlakozott ehhez a csatornához." + other: "%{usernames} nem csatlakozott ehhez a csatornához." + aria_roles: + header: "Csevegés fejléce" + composer: "Csevegés szerkesztője" + channels_list: "Csevegőcsatornák" + no_public_channels: "Nem csatlakozott egyetlen csatornához sem." + only_chat_push_notifications: + title: "Csak csevegési leküldéses értesítések küldése" + description: "Az összes nem csevegési leküldéses értesítés elküldésének letiltása" + ignore_channel_wide_mention: + title: "A csatornaszintű említések figyelmen kívül hagyása" + description: "Ne küldjön értesítést a csatornaszintű említésekről (@here és @all)" + open: "Csevegés megnyitása" + open_full_page: "Teljes képernyős csevegés megnyitása" + close_full_page: "Teljes képernyős csevegés bezárása" + open_message: "Üzenet megnyitása a csevegésben" + placeholder_self: "Jegyezzen le valamit" + placeholder_others: "Csevegés vele: %{messageRecipient}" + placeholder_new_message_disallowed: "A csatorna „%{status}”, jelenleg nem küldhet új üzeneteket." + placeholder_silenced: "Jelenleg nem küldhet üzeneteket." + placeholder_start_conversation: 'Beszélgetés kezdése a következőkkel: %{usernames}' + remove_upload: "Fájl eltávolítása" + react: "Reagálás emodzsival" + reply: "Válasz" + edit: "Szerkesztés" + copy_link: "Hivatkozás másolása" + rebake_message: "HTML újjáépítése" + retry_staged_message: + title: "Hálózati hiba" + action: "Újraküldi?" + unreliable_network: "A hálózat nem megbízható, az üzenetek küldése és a piszkozat mentése lehet, hogy nem működik." + bookmark_message: "Könyvjelző" + bookmark_message_edit: "Könyvjelző szerkesztése" + restore: "Törölt üzenet helyreállítása" + save: "Mentés" + select: "Válasszon" + silence: "Felhasználó némítása" + return_to_list: "Vissza a csatornákhoz" + scroll_to_bottom: "Görgetés lefelé" + scroll_to_new_messages: "Új üzenetek megtekintése" + sound: + title: "Asztali csevegőértesítés hangja" + sounds: + none: "Egyik sem" + bell: "Harang" + ding: "Csengettyű" + title: "csevegés" + title_capitalized: "Csevegés" + upload: "Fájl csatolása" + uploaded_files: + one: "%{count} fájl" + other: "%{count} fájl" + you_flagged: "Ön jelentette ezt az üzenetet" + exit: "vissza" + channel_status: + read_only_header: "A csatorna írásvédett" + read_only: "Csak olvasható" + archived_header: "A csatorna archiválva van" + archived: "Archivált" + archive_failed: "A csatorna archiválása nem sikerült.%{completed}/%{total} üzenet archiválva lett a céltémában . Nyomja meg az Újra gombot az archiválás befejezéséhez." + archive_completed: "Lásd az archív témát" + closed_header: "A csatorna zárolt" + closed: "Zárt" + open_header: "A csatorna nyitott" + open: "Megnyitás" + browse: + back: "Vissza" + title: Csatornák + filter_all: Összes + filter_open: Nyitott + filter_closed: Zárt + filter_archived: Archivált + filter_input_placeholder: Csatorna keresése név szerint + chat_message_separator: + today: Ma + yesterday: Tegnap + members_view: + filter_placeholder: Tagok keresése + about_view: + associated_topic: Kapcsolódó téma + associated_category: Kapcsolódó kategória + title: Cím + description: Leírás + channel_info: + back_to_all_channels: "Összes csatorna" + back_to_channel: "Vissza" + tabs: + about: Névjegy + members: Tagok + settings: Beállítások + channel_edit_title_modal: + title: Cím szerkesztése + input_placeholder: Cím hozzáadása + description: Adjon egy rövid, leíró címet a csatornájának + channel_edit_description_modal: + title: Leírás szerkesztése + input_placeholder: Leírás hozzáadása + description: Mondja el az embereknek, hogy miről szól ez a csatorna + direct_message_creator: + title: Új üzenet + prefix: "Címzett:" + no_results: Nincs találat + selected_user_title: "%{username} kijelölésének megszüntetése" + channel_selector: + title: "Ugrás a csatornára" + no_channels: "Egyetlen csatorna sem felel meg a keresésnek" + channel: + no_memberships: Ennek a csatornának nincsenek tagjai + no_memberships_found: Nem találhatók tagok + memberships_count: + one: "%{count} tag" + other: "%{count} tag" + create_channel: + auto_join_users: + public_category_warning: "A(z) %{category} egy nyilvános kategória. Automatikusan hozzáadja az összes nemrég aktív felhasználót ehhez a csatornához?" + warning_groups: + one: '%{members_count} felhasználó automatikus hozzáadása a(z) %{group} csoportból?' + other: '%{members_count} felhasználó automatikus hozzáadása a(z) %{group} és %{group_2} csoportokból?' + warning_multiple_groups: Automatikusan hozzáadja a(z) %{group_1} csoport %{members_count} felhasználóját, és további %{count} felhasználót? + choose_category: + label: "Válasszon kategóriát" + none: "válasszon egyet…" + default_hint: Kezelje a hozzáférést a(z) %{category} biztonsági beállításainak felkeresésével + create: "Csatorna létrehozása" + description: "Leírás (nem kötelező)" + name: "Csatorna neve" + type: "Típus" + types: + category: "Kategória" + topic: "Téma" + reviewable: + type: "Csevegőüzenet" + reactions: + only_you: "Ezzel reagált: :%{emoji}:" + and_others: "Ön, %{usernames} ezzel reagált: :%{emoji}:" + only_others: "%{usernames} ezzel reagált: :%{emoji}:" + others_and_more: "%{usernames} és még %{more} valaki ezzel reagált: :%{emoji}:" + you_others_and_more: "Ön, %{usernames} és még %{more} valaki ezzel reagált: :%{emoji}:" + composer: + toggle_toolbar: "Eszköztár be/ki" + italic_text: "dőlt szöveg" + bold_text: "félkövér szöveg" + code_text: "kódszöveg" + quote: + copy_success: "Csevegési idézet a vágólapra másolva" + notification_levels: + never: "Soha" + mention: "Csak megemlítésnél" + always: "Összes tevékenységnél" + settings: + enable_auto_join_users: "Az összes nemrég aktív felhasználó automatikus hozzáadása" + disable_auto_join_users: "A felhasználók automatikus hozzáadásának leállítása" + desktop_notification_level: "Asztali értesítések" + follow: "Belépés" + followed: "Csatlakozott" + mobile_notification_level: "Mobilos leküldéses értesítések" + mute: "Csatorna némítása" + muted_on: "Be" + muted_off: "Ki" + notifications: "Értesítések" + preview: "Előnézet" + save: "Mentés" + saved: "Mentve" + unfollow: "Elhagyás" + admin: + title: "Csevegés" + direct_messages: + title: "Személyes csevegés" + new: "Új személyes csevegés" + create: "Ugrás" + leave: "E személyes csevegés elhagyása" + incoming_webhooks: + back: "Vissza" + channel_placeholder: "Válasszon csatornát" + confirm_destroy: "Biztos, hogy törli ezt a bejövő webhookot? Ezt nem lehet visszavonni." + current_emoji: "Jelenlegi emodzsi" + description: "Leírás" + delete: "Törlés" + emoji: "Emodzsi" + emoji_instructions: "A rendszerben használt profilképe lesz használva, ha az emodzsi üresen marad." + name: "Név" + name_placeholder: "név…" + new: "Új bejövő webhook" + none: "Nincs meglévő bejövő webhoook létrehozva." + no_emoji: "Nincs emodzsi kiválasztva" + post_to: "Közzététel ide:" + reset_emoji: "Emodzsi visszaállítása" + save: "Mentés" + edit: "Szerkesztés" + select_emoji: "Válasszon emodzsit" + system: "rendszer" + title: "Bejövő webhookok" + url: "URL" + username: "Felhasználónév" + username_instructions: "A csatornán közzétevő bot felhasználóneve. Ha üresen marad, akkor alapértelmezés szerint „rendszer”." + selection: + cancel: "Visszavon" + quote_selection: "Idézet a témában" + copy: "Másolás" + move_selection_to_channel: "Áthelyezés csatornába" + error: "Hiba történt a csevegőüzenetek áthelyezésekor" + title: "Csevegés áthelyezése a témához" + new_topic: + title: "Áthelyezés új témába" + instructions: + one: "Arra készül, hogy új témát hozzon létre, és feltöltse azt a kiválasztott csevegőüzenettel." + other: "Arra készül, hogy új témát hozzon létre, és feltöltse azt %{count} kiválasztott csevegőüzenettel." + instructions_channel_archive: "Egy új témát fog létrehozni, és archiválni fogja a csatornaüzeneteket." + existing_topic: + title: "Áthelyezés egy meglévő témába" + instructions: + one: "Válassza ki azt a témát, amelyhez áthelyezi a csevegőüzenetet." + other: "Válassza ki azt a témát, amelyhez áthelyez %{count} csevegőüzenetet." + instructions_channel_archive: "Válassza ki azt a témát, amelybe a csatornaüzeneteket archiválná." + new_message: + title: "Áthelyezés az új üzenethez" + instructions: + one: "Arra készül, hogy új üzenet hozzon létre, és feltöltse azt a kiválasztott csevegőüzenettel." + other: "Arra készül, hogy új üzenetet hozzon létre, és feltöltse azt %{count} kiválasztott csevegőüzenettel." + replying_indicator: + single_user: "%{username} gépel" + multiple_users: "%{commaSeparatedUsernames} és %{lastUsername} gépel" + many_users: + one: "%{commaSeparatedUsernames} és még %{count} valaki gépel" + other: "%{commaSeparatedUsernames} és még %{count} valaki gépel" + retention_reminders: + public: "A csatorna előzményei %{days} napig maradnak meg." + dm: "A személyes csevegési előzményei %{days} napig maradnak meg." + topic_button_title: "Csevegés" + emoji_picker: + objects: "Tárgyak" + activities: "Tevékenységek" + flags: "Zászlók" + symbols: "Szimbólumok" + no_results: "Nincs találat" + draft_channel_screen: + header: "Új üzenet" + cancel: "Visszavon" + notifications: + chat_invitation: "meghívta Önt, hogy csatlakozzon egy csevegőcsatornához" + chat_invitation_html: "%{username} meghvta Önt, hogy csatlakozzon egy csevegőcsatornához" + chat_quoted: "%{username}%{description}" + popup: + chat_mention: + direct: 'megemlítette Önt a következő csatornán: „%{channel}”' + direct_html: '%{username} megemlítette Önt a következő csatornán: „%{channel}”' + other_plain: 'megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + other_html: '%{username} megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + direct_message_chat_mention: + direct: "megemlítette Önt egy személyes csevegésben" + direct_html: "%{username} megemlítette Önt egy személyes csevegésben" + other_plain: "megemlítette %{identifier} felhasználót egy személyes csevegésben" + other_html: "%{username} megemlítette %{identifier} felhasználót egy személyes csevegésben" + chat_message: "Új csevegőüzenet" + chat_quoted: "%{username} idézte a csevegési üzenetet" + titles: + chat_mention: "Csevegési megemlítés" + chat_invitation: "Csevegési meghívó" + chat_quoted: "Csevegés idézve" + action_codes: + chat: + enabled: '%{who} engedélyezte a %{when}' + disabled: "%{who} lezárta a csevegést %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Csevegőüzenet küldése + fields: + chat_channel_id: + label: Csevegőcsatorna azonosítója + message: + label: Üzenet + sender: + label: Feladó + description: Alapértelmezés szerint a rendszer + review: + types: + reviewable_chat_message: + title: "Jelentett csevegőüzenet" + flagged_by: "Megjelölte" + keyboard_shortcuts_help: + chat: + title: "Csevegés" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Csatorna váltása" + open_quick_channel_selector: "%{shortcut} Gyors csatornaválasztó megnyitása" + open_insert_link_modal: "%{shortcut} Hiperhivatkozás beillesztése (csak a szerkesztőben)" + composer_bold: "%{shortcut} Félkövér (csak a szerkesztőben)" + composer_italic: "%{shortcut} Dőlt (csak a szerkesztőben)" + composer_code: "%{shortcut} Kód (csak a szerkesztőben)" + drawer_open: "%{shortcut} Csevegési fiók megnyitása" + drawer_close: "%{shortcut} Csevegési fiók bezárása" + topic_statuses: + chat: + help: "A csevegés engedélyezett ebben a témában" diff --git a/plugins/chat/config/locales/client.hy.yml b/plugins/chat/config/locales/client.hy.yml new file mode 100644 index 0000000000..8931700910 --- /dev/null +++ b/plugins/chat/config/locales/client.hy.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: + js: + chat: + create: "Ստեղծել" + cancel: "Չեղարկել" + channel_settings: + edit: "Խմբագրել" + add: "Ավելացնել" + join: "Միանալ" + leave: "Լքել" + close: "Փակել" + delete: "Ջնջել" + edited: "խմբագրվել է" + muted: "խլացված" + joined: "միացել է" + email_frequency: + never: "Երբեք" + flag: "Դրոշակավորել" + join: "Միանալ" + mention_warning: + dismiss: "չեղարկել" + reply: "Պատասխանել" + edit: "Խմբագրել" + rebake_message: "Վերակառուցել HTML-ը" + bookmark_message: "Էջանշել" + save: "Պահպանել" + sounds: + none: "Ոչ մի" + exit: "ետ" + channel_status: + closed: "Փակված" + open: "Բացել" + browse: + back: "Ետ" + filter_all: Բոլորը + filter_closed: Փակված + chat_message_separator: + today: Այսօրվա + yesterday: Երեկվա + about_view: + title: Վերնագիր + description: Նկարագրությունը + channel_info: + back_to_channel: "Ետ" + tabs: + about: Մասին + members: Անդամներ + settings: Կարգավորումներ + direct_message_creator: + title: Նոր Հաղորդագրություն + prefix: "Ում:" + create_channel: + type: "Տիպը" + types: + category: "Կատեգորիա" + topic: "Թեմա" + composer: + italic_text: "շեղ տեքստ" + bold_text: "թավ տեքստ" + notification_levels: + never: "Երբեք" + settings: + follow: "Միանալ" + followed: "Միացել է" + notifications: "Ծանուցումներ" + preview: "Նախադիտում" + save: "Պահպանել" + saved: "Պահված է" + unfollow: "Լքել" + incoming_webhooks: + back: "Ետ" + description: "Նկարագրությունը" + delete: "Ջնջել" + emoji: "Էմոջի" + name: "Անուն" + save: "Պահպանել" + edit: "Խմբագրել" + system: "համակարգային" + url: "URL" + username: "Օգտանուն" + selection: + cancel: "Չեղարկել" + copy: "Կրկնօրինակել" + new_topic: + title: "Տեղափոխվել դեպի Նոր Թեմա" + existing_topic: + title: "Տեղափոխել դեպի Գոյություն Ունեցող Թեմա" + new_message: + title: "Տեղափոխել դեպի Նոր Հաղորդագրություն" + emoji_picker: + objects: "Օբյեկտներ" + activities: "Ակտիվություն" + flags: "Դրոշակներ" + symbols: "Նշաններ" + draft_channel_screen: + header: "Նոր Հաղորդագրություն" + cancel: "Չեղարկել" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Նոր Հաղորդագրություն + review: + types: + reviewable_chat_message: + flagged_by: "Դրոշակավորել է" diff --git a/plugins/chat/config/locales/client.id.yml b/plugins/chat/config/locales/client.id.yml new file mode 100644 index 0000000000..ce26a3808b --- /dev/null +++ b/plugins/chat/config/locales/client.id.yml @@ -0,0 +1,88 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: + js: + chat: + cancel: "Batal" + channel_settings: + edit: "Ubah" + add: "Menambahkan" + join: "Gabung" + leave: "Keluar" + close: "Tutup" + delete: "Hapus" + muted: "diredam" + joined: "joined" + email_frequency: + never: "Tidak pernah" + join: "Gabung" + mention_warning: + dismiss: "bubar" + reply: "Balas" + edit: "Ubah" + bookmark_message: "Penandaan" + bookmark_message_edit: "Edit Bookmark" + save: "Simpan" + sounds: + none: "Tidak ada" + channel_status: + closed: "Tertutup" + open: "Buka" + browse: + filter_all: Semua + filter_closed: Tertutup + about_view: + title: Judul + channel_info: + tabs: + about: Tentang + members: Anggota + settings: Pengaturan + direct_message_creator: + title: Pesan Baru + prefix: "Kepada:" + create_channel: + type: "Tipe" + types: + category: "Kategori" + topic: "Topik" + notification_levels: + never: "Tidak pernah" + settings: + follow: "Gabung" + followed: "Joined" + notifications: "Pemberitahuan" + save: "Simpan" + saved: "Tersimpan" + unfollow: "Keluar" + incoming_webhooks: + delete: "Hapus" + name: "Name" + save: "Simpan" + edit: "Ubah" + system: "sistem" + username: "Nama Pengguna" + selection: + cancel: "Batal" + new_topic: + title: "Pindahkan sebagai Topik Baru" + emoji_picker: + objects: "Objek" + flags: "Flags" + draft_channel_screen: + header: "Pesan Baru" + cancel: "Batal" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: pesan + review: + types: + reviewable_chat_message: + flagged_by: "Dipanji Oleh" diff --git a/plugins/chat/config/locales/client.it.yml b/plugins/chat/config/locales/client.it.yml new file mode 100644 index 0000000000..d05a194349 --- /dev/null +++ b/plugins/chat/config/locales/client.it.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Lo stato del canale di chat è cambiato" + chat_channel_delete: "Canale di chat eliminato" + api: + scopes: + descriptions: + chat: + create_message: "Crea un messaggio di chat in un canale specifico." + about: + chat_messages_count: "Messaggi di chat" + chat_channels_count: "Canali di chat" + chat_users_count: "Utenti della chat" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostra tutti i messaggi" + already_enabled: "La chat è già abilitata su questo argomento. Prova ad aggiornare." + disabled_for_topic: "La chat è disabilitata su questo argomento." + bot: "bot" + create: "Crea" + cancel: "Annulla" + cancel_reply: "Annulla risposta" + chat_channels: "Canali" + browse_all_channels: "Sfoglia tutti i canali" + move_to_channel: + title: "Sposta i messaggi sul canale" + instructions: + one: "Stai spostando %{count} messaggio. Seleziona un canale di destinazione. Verrà creato un messaggio segnaposto nel canale %{channelTitle} per indicare che questo messaggio è stato spostato." + other: "Stai spostando %{count} messaggi. Seleziona un canale di destinazione. Verrà creato un messaggio segnaposto nel canale %{channelTitle} per indicare che questi messaggi sono stati spostati." + confirm_move: "Sposta messaggi" + channel_settings: + title: "Impostazioni del canale" + edit: "Modifica" + add: "Aggiungi" + close_channel: "Chiudi canale" + open_channel: "Apri canale" + archive_channel: "Archivia canale" + delete_channel: "Elimina canale" + join_channel: "Partecipa al canale" + leave_channel: "Lascia il canale" + join: "Partecipa" + leave: "Esci" + channel_archive: + title: "Archivia canale" + instructions: "

    L'archiviazione di un canale lo mette in modalità di sola lettura e sposta tutti i messaggi dal canale a un argomento nuovo o esistente. Non è possibile inviare nuovi messaggi e non è possibile modificare o eliminare messaggi esistenti.

    Sei sicuro di voler archiviare il canale %{channelTitle}?

    " + process_started: "Il processo di archiviazione è iniziato. Questo avviso si chiuderà a breve e riceverai un messaggio personale al termine del processo di archiviazione." + retry: "Riprova" + channel_open: + title: "Apri canale" + instructions: "Riapre il canale, tutti gli utenti potranno inviare messaggi e modificare i propri messaggi esistenti." + channel_close: + title: "Chiudi canale" + instructions: "La chiusura del canale impedisce agli utenti non appartenenti allo staff di inviare nuovi messaggi o modificare quelli esistenti. Sei sicuro di voler chiudere questo canale?" + channel_delete: + title: "Elimina canale" + instructions: "

    Elimina il canale %{name} e la cronologia delle chat. Tutti i messaggi e i dati correlati, come reazioni e caricamenti, verranno eliminati in modo permanente. Se vuoi conservare la cronologia del canale e rimuoverla, puoi invece archiviare il canale.

    Sei sicuro di voler eliminare definitivamente il canale? Per confermare, digita il nome del canale nella casella sottostante.

    " + confirm: "Ho capito le conseguenze, procedi all'eliminazione del canale" + confirm_channel_name: "Inserisci il nome del canale" + process_started: "Il processo di eliminazione del canale è iniziato. Questo avviso si chiuderà a breve, non vedrai più il canale eliminato da nessuna parte." + channels_list_popup: + browse: "Sfoglia i canali" + create: "Nuovo canale" + click_to_join: "Fai clic qui per visualizzare i canali disponibili." + close: "Chiudi" + collapse: "Comprimi il cassetto della chat" + confirm_flag: "Vuoi segnalare il messaggio di %{username}?" + deleted: "Un messaggio è stato eliminato. [visualizza]" + hidden: "Un messaggio è stato nascosto. [view]" + delete: "Elimina" + edited: "modificato" + muted: "silenziato" + joined: "partecipante" + empty_state: + direct_message_cta: "Avvia una chat personale" + direct_message: "Puoi anche avviare una chat personale con uno o più utenti." + title: "Nessun canale trovato" + email_frequency: + description: "Ti invieremo un'e-mail solo se non ti sei fatto vedere negli ultimi 15 minuti." + never: "Mai" + title: "Notifiche via e-mail" + when_away: "Solo quando non sono collegato" + enable: "Abilita chat" + flag: "Segnala" + emoji: "Inserisci emoji" + flagged: "Questo messaggio è stato segnalato per la revisione" + invalid_access: "Non hai accesso alla visualizzazione di questo canale chat" + invitation_notification: "%{username} ti ha inviato un invito a partecipare a un canale di chat" + in_reply_to: "In risposta a" + heading: "Chat" + join: "Partecipa" + new_messages: "nuovi messaggi" + mention_warning: + cannot_see: + one: "%{usernames} non può accedere a questo canale e non è stato avvisato." + other: "%{usernames} non possono accedere a questo canale e non sono stati avvisati." + dismiss: "ignora" + invitations_sent: + one: "Invito inviato" + other: "Inviti inviati" + invite: "Invita al canale" + without_membership: + one: "%{usernames} non ha partecipato a questo canale." + other: "%{usernames} non hanno partecipato a questo canale." + aria_roles: + header: "Intestazione della chat" + composer: "Compositore di chat" + channels_list: "Elenco dei canali di chat" + no_public_channels: "Non hai partecipato a nessun canale." + only_chat_push_notifications: + title: "Invia solo le notifiche push della chat" + description: "Blocca l'invio di tutte le notifiche push non relative alla chat" + ignore_channel_wide_mention: + title: "Ignora le menzioni a livello di canale" + description: "Non inviare notifiche per menzioni a livello di canale (@here e @all)" + open: "Apri chat" + open_full_page: "Apri chat a schermo intero" + close_full_page: "Chiudi la chat a schermo intero" + open_message: "Apri messaggio in chat" + placeholder_self: "Scrivi qualche annotazione" + placeholder_others: "Chatta con %{messageRecipient}" + placeholder_new_message_disallowed: "Il canale è %{status}, non puoi inviare nuovi messaggi in questo momento." + placeholder_silenced: "In questo momento non puoi inviare messaggi." + placeholder_start_conversation: Inizia una conversazione con %{usernames} + remove_upload: "Rimuovi file" + react: "Reagisci con delle emoji" + reply: "Rispondi" + edit: "Modifica" + copy_link: "Copia link" + rebake_message: "Ricompila HTML" + retry_staged_message: + title: "Errore di rete" + action: "Inviare di nuovo?" + unreliable_network: "La rete non è affidabile, l'invio di messaggi e il salvataggio della bozza potrebbero non funzionare" + bookmark_message: "Aggiungi ai segnalibri" + bookmark_message_edit: "Modifica segnalibri" + restore: "Ripristina messaggio eliminato" + save: "Salva" + select: "Seleziona" + silence: "Silenzia utente" + return_to_list: "Torna all'elenco dei canali" + scroll_to_bottom: "Scorri fino in fondo" + scroll_to_new_messages: "Vedi nuovi messaggi" + sound: + title: "Suono di notifica della chat desktop" + sounds: + none: "Nessuno" + bell: "Campanello" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Allega un file" + uploaded_files: + one: "%{count} file" + other: "%{count} file" + you_flagged: "Hai segnalato questo messaggio" + exit: "indietro" + channel_status: + read_only_header: "Il canale è di sola lettura" + read_only: "Sola lettura" + archived_header: "Il canale è archiviato" + archived: "Archiviati" + archive_failed: "Archiviazione del canale non riuscita. %{completed}/%{total} messaggi sono stati archiviati nell'argomento di destinazione. Premi Riprova per tentare di completare l'archiviazione." + archive_completed: "Vedi l'argomento di archiviazione" + closed_header: "Il canale è chiuso" + closed: "Chiusi" + open_header: "Il canale è aperto" + open: "Aperto" + browse: + title: Canali + filter_all: Tutti + filter_open: Aperti + filter_closed: Chiusi + filter_archived: Archiviati + filter_input_placeholder: Cerca canale per nome + chat_message_separator: + today: Oggi + yesterday: Ieri + members_view: + filter_placeholder: Trova membri + about_view: + associated_topic: Argomento collegato + associated_category: Categoria collegata + title: Titolo + description: Descrizione + channel_info: + back_to_all_channels: "Tutti i canali" + back_to_channel: "Indietro" + tabs: + about: Informazioni + members: Membri + settings: Impostazioni + channel_edit_title_modal: + title: Modifica titolo + input_placeholder: Aggiungi un titolo + description: Assegna un breve titolo descrittivo al tuo canale + channel_edit_description_modal: + title: Modifica descrizione + input_placeholder: Aggiungi una descrizione + description: Spiega agli altri di cosa si occupa questo canale + direct_message_creator: + title: Nuovo messaggio + prefix: "A:" + no_results: Nessun risultato + selected_user_title: "Deseleziona %{username}" + channel_selector: + title: "Vai al canale" + no_channels: "Nessun canale corrisponde alla tua ricerca" + channel: + no_memberships: Questo canale non ha membri + no_memberships_found: Nessun membro trovato + memberships_count: + one: "%{count} membro" + other: "%{count} membri" + create_channel: + auto_join_users: + public_category_warning: "%{category} è una categoria pubblica. Aggiungere automaticamente tutti gli utenti attivi di recente a questo canale?" + warning_groups: + one: Aggiungere automaticamente %{members_count} utenti da %{group}? + other: Aggiungere automaticamente %{members_count} utenti da %{group} e %{group_2}? + warning_multiple_groups: Aggiungere automaticamente %{members_count} utenti da %{group_1} e altri %{count}? + choose_category: + label: "Scegli una categoria" + none: "selezionane una..." + default_hint: Gestisci l'accesso visitando le impostazioni di sicurezza di %{category} + hint_groups: + one: Gli utenti in %{hint} avranno accesso a questo canale in base alle impostazioni di sicurezza + other: Gli utenti in %{hint} e %{hint_2} avranno accesso a questo canale in base alle impostazioni di sicurezza + hint_multiple_groups: Gli utenti in %{hint_1} e altri %{count} gruppi avranno accesso a questo canale in base alle impostazioni di sicurezza + create: "Crea canale" + description: "Descrizione (facoltativa)" + name: "Nome del canale" + title: "Nuovo canale" + type: "Tipo" + types: + category: "Categoria" + topic: "Argomento" + reviewable: + type: "Messaggio di chat" + reactions: + only_you: "Hai reagito con :%{emoji}:" + and_others: "Tu, %{usernames} avete reagito con :%{emoji}:" + only_others: "%{usernames} ha reagito con :%{emoji}:" + others_and_more: "%{usernames} e altri %{more} hanno reagito con :%{emoji}:" + you_others_and_more: "Tu, %{usernames} e altri %{more} avete reagito con :%{emoji}:" + composer: + toggle_toolbar: "Attiva barra degli strumenti" + italic_text: "testo in evidenza" + bold_text: "testo in grassetto" + code_text: "testo di codice" + quote: + original_channel: 'Inviato in origine in #%{channel}' + copy_success: "Citazione della chat copiata negli appunti" + notification_levels: + never: "Mai" + mention: "Solo per le menzioni" + always: "Per tutte le attività" + settings: + enable_auto_join_users: "Aggiungi automaticamente tutti gli utenti attivi di recente" + disable_auto_join_users: "Smetti di aggiungere automaticamente gli utenti" + auto_join_users_warning: "Tutti gli utenti che non sono membri di questo canale e hanno accesso alla categoria %{category} parteciperanno. Vuoi procedere?" + desktop_notification_level: "Notifiche sul desktop" + follow: "Partecipa" + followed: "Partecipante" + mobile_notification_level: "Notifiche push su dispositivi mobili" + mute: "Silenzia canale" + muted_on: "On" + muted_off: "Off" + notifications: "Notifiche" + preview: "Anteprima" + save: "Salva" + saved: "Salvato" + unfollow: "Esci" + admin: + title: "Chat" + direct_messages: + title: "Chat personale" + new: "Nuova chat personale" + create: "Vai" + leave: "Lascia questa chat personale" + cannot_create: "Spiacenti, non puoi inviare messaggi diretti." + incoming_webhooks: + back: "Indietro" + channel_placeholder: "Seleziona un canale" + confirm_destroy: "Vuoi eliminare questo webhook in ingresso? L'operazione non può essere annullata." + current_emoji: "Emoji attuale" + description: "Descrizione" + delete: "Elimina" + emoji: "Emoji" + emoji_instructions: "L'avatar di sistema verrà utilizzato se l'emoji è lasciata vuota." + name: "Nome" + name_placeholder: "nome..." + new: "Nuovo webhook in ingresso" + none: "Non sono stati creati webhook in ingresso." + no_emoji: "Nessuna emoji selezionata" + post_to: "Pubblica in" + reset_emoji: "Reimposta emoji" + save: "Salva" + edit: "Modifica" + select_emoji: "Scegli emoji" + system: "sistema" + title: "Webhook in ingresso" + url: "URL" + url_instructions: "Questo URL contiene un valore segreto: tienilo al sicuro." + username: "Nome utente" + username_instructions: "Nome utente del bot che pubblica sul canale. Il valore predefinito è 'system' se l'impostazione è lasciata vuota." + instructions: "I webhook in entrata possono essere utilizzati da sistemi esterni per pubblicare messaggi in un canale di chat designato come utente bot tramite l'endpoint /hooks/:key. Il carico utile è costituito da un solo parametro di testo, che è limitato a 2000 caratteri.

    Supportiamo anche i parametri di testo con formattazione Slack limitata, l'estrazione di link e menzioni in base al formato su https://api. lack.com/reference/surfaces/formatting, ma l'endpoint /hooks/:key/slack deve essere utilizzato per questo." + selection: + cancel: "Annulla" + quote_selection: "Cita in argomento" + copy: "Copia" + move_selection_to_channel: "Sposta nel canale" + error: "Si è verificato un errore durante lo spostamento dei messaggi di chat" + title: "Sposta la chat nell'argomento" + new_topic: + title: "Sposta nel nuovo argomento" + instructions: + one: "Stai per creare un nuovo argomento, inserendovi il messaggio di chat che hai selezionato." + other: "Stai per creare un nuovo argomento, inserendovi i %{count} messaggi di chat che hai selezionato." + instructions_channel_archive: "Stai per creare un nuovo argomento e archiviare in esso i messaggi del canale." + existing_topic: + title: "Sposta in argomento esistente" + instructions: + one: "Scegli l'argomento in cui intendi spostare il messaggio di chat." + other: "Scegli l'argomento in cui intendi spostare questi %{count} messaggi di chat." + instructions_channel_archive: "Scegli l'argomento in cui intendi archiviare i messaggi del canale." + new_message: + title: "Sposta in un nuovo messaggio" + instructions: + one: "Stai per creare un nuovo messaggio, inserendovi il messaggio di chat che hai selezionato." + other: "Stai per creare un nuovo messaggio, inserendovi i %{count} messaggi di chat che hai selezionato." + replying_indicator: + single_user: "%{username} sta scrivendo" + multiple_users: "%{commaSeparatedUsernames} e %{lastUsername} stanno scrivendo" + many_users: + one: "%{commaSeparatedUsernames} e %{count} altro stanno scrivendo" + other: "%{commaSeparatedUsernames} e altri %{count} stanno scrivendo" + retention_reminders: + public: "La cronologia del canale è conservata per %{days} giorni." + dm: "La cronologia della chat personale è conservata per %{days} giorni." + topic_button_title: "Chat" + flags: + off_topic: "Questo messaggio non è rilevante per la discussione in corso in base alla definizione del titolo del canale e probabilmente dovrebbe essere spostato altrove." + inappropriate: "Questo messaggio ha contenuti che chiunque considererebbe offensivi o ingiuriosi, oppure contiene violazioni delle nostre linee guida della community." + spam: "Questo messaggio è un annuncio pubblicitario o un atto vandalico. Non è utile o rilevante per il canale corrente." + notify_user: "Voglio parlare con questa persona direttamente e personalmente del suo messaggio." + notify_moderators: "Questo messaggio richiede l'attenzione dello staff per motivi diversi da quelli sopra indicati" + flagging: + action: "Segnala messaggio" + emoji_picker: + favorites: "Utilizzati di frequente" + smileys_&_emotion: "Faccine ed emoticon" + objects: "Oggetti" + people_&_body: "Persone e corpo" + travel_&_places: "Viaggi e luoghi" + animals_&_nature: "Animali e natura" + food_&_drink: "Cibo e bevande" + activities: "Attività" + flags: "Segnalazioni" + symbols: "Simboli" + search_placeholder: "Cerca per nome emoji e alias..." + no_results: "Nessun risultato" + draft_channel_screen: + header: "Nuovo messaggio" + cancel: "Annulla" + notifications: + chat_invitation: "ti ha inviato un invito a partecipare a un canale di chat" + chat_invitation_html: "%{username} ti ha inviato un invito a partecipare a un canale di chat" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'ti ha menzionato in "%{channel}"' + direct_html: '%{username} ti ha menzionato in "%{channel}"' + other_plain: 'ha menzionato %{identifier} in "%{channel}"' + other_html: '%{username} ha menzionato %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "ti ha menzionato nella chat personale" + direct_html: "%{username} ti ha menzionato nella chat personale" + other_plain: "ha menzionato %{identifier} nella chat personale" + other_html: "%{username} ha menzionato %{identifier} nella chat personale" + chat_message: "Nuovo messaggio di chat" + chat_quoted: "%{username} ha citato il tuo messaggio di chat" + titles: + chat_mention: "Menzione in chat" + chat_invitation: "Invito alla chat" + chat_quoted: "Chat citata" + action_codes: + chat: + enabled: '%{who} ha abilitato la %{when}' + disabled: "%{who} ha chiuso la chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Invia messaggio di chat + fields: + chat_channel_id: + label: ID canale chat + message: + label: Messaggio + sender: + label: Mittente + description: Torna ai valori predefiniti di sistema + review: + transcript: + view: "Visualizza la trascrizione dei messaggi precedenti" + types: + reviewable_chat_message: + title: "Messaggio di chat segnalato" + flagged_by: "Segnalato da" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Cambia canale" + open_quick_channel_selector: "%{shortcut} Apri il selettore rapido dei canali" + open_insert_link_modal: "%{shortcut} Inserisci collegamento ipertestuale (solo compositore)" + composer_bold: "%{shortcut} Grassetto (solo compositore)" + composer_italic: "%{shortcut} Corsivo (solo compositore)" + composer_code: "%{shortcut} Codice (solo compositore)" + drawer_open: "%{shortcut} Apri il cassetto della chat" + drawer_close: "%{shortcut} Chiudi il cassetto della chat" + topic_statuses: + chat: + help: "La chat è abilitata per questo argomento" + user: + allow_private_messages: "Consenti ad altri utenti di inviarmi messaggi personali e messaggi diretti in chat" + muted_users_instructions: "Elimina tutte le notifiche, i messaggi personali e i messaggi diretti della chat da questi utenti." + allowed_pm_users_instructions: "Consenti solo messaggi personali o messaggi diretti in chat da questi utenti." + allow_private_messages_from_specific_users: "Consenti solo a utenti specifici di inviarmi messaggi personali o messaggi diretti in chat" + ignored_users_instructions: "Elimina tutti i post, i messaggi, le notifiche, i messaggi personali e i messaggi diretti in chat di questi utenti." + user_menu: + no_chat_notifications_title: "Non hai ancora nessuna notifica di chat" + no_chat_notifications_body: > + Riceverai una notifica in questo pannello quando qualcuno ti invia un messaggio diretto o ti @menziona nella chat. Le notifiche verranno inviate anche alla tua e-mail quando non effettui l'accesso da un po' di tempo.

    Fai clic sul titolo nella parte superiore di qualsiasi canale di chat per configurare quali notifiche ricevere in quel canale. Per ulteriori informazioni, consulta le tue preferenze di notifica. + tabs: + chat_notifications: "Notifiche chat" + chat_notifications_with_unread: + one: "Notifiche chat - %{count} notifica non letta" + other: "Notifiche chat - %{count} notifiche non lette" diff --git a/plugins/chat/config/locales/client.ja.yml b/plugins/chat/config/locales/client.ja.yml new file mode 100644 index 0000000000..57ba79e996 --- /dev/null +++ b/plugins/chat/config/locales/client.ja.yml @@ -0,0 +1,436 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "チャットチャンネルのステータスが変更されました" + chat_channel_delete: "チャットチャンネルが削除されました" + api: + scopes: + descriptions: + chat: + create_message: "特定のチャンネルでチャットメッセージを作成します。" + about: + chat_messages_count: "チャットメッセージ" + chat_channels_count: "チャットチャンネル" + chat_users_count: "チャットユーザー" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "すべてのメッセージを表示中" + already_enabled: "このトピックのチャットはすでに有効になっています。再読み込みしてください。" + disabled_for_topic: "このトピックのチャットは無効になっています。" + bot: "ボット" + create: "作成" + cancel: "キャンセル" + cancel_reply: "返信をキャンセルする" + chat_channels: "チャンネル" + browse_all_channels: "すべてのチャンネルを閲覧する" + move_to_channel: + title: "メッセージをチャンネルに移動する" + instructions: + other: "%{count} 件のメッセージを移動しようとしています。移動先のチャンネルを選択してください。%{channelTitle} チャンネルに、これらのメッセージが移動されたことを示すプレースホルダーメッセージが作成されます。" + confirm_move: "メッセージを移動" + channel_settings: + title: "チャンネルの設定" + edit: "編集" + add: "追加" + close_channel: "チャンネルを閉鎖する" + open_channel: "チャンネルを開く" + archive_channel: "チャンネルをアーカイブする" + delete_channel: "チャンネルを削除する" + join_channel: "チャンネルに参加する" + leave_channel: "チャンネルから退出する" + join: "参加" + leave: "退出" + channel_archive: + title: "チャンネルをアーカイブ" + instructions: "

    チャンネルをアーカイブすると、チャンネルは読み取り専用モードになり、すべてのメッセージが新しいトピックまたは既存のトピックに移動されます。新しいメッセージを送信することはできません。また既存のメッセージの編集や削除も行えません。

    %{channelTitle} チャンネルをアーカイブしてもよろしいですか?

    " + process_started: "アーカイブ処理が開始されました。このモーダルは間もなく閉じられ、アーカイブ処理が完了すると個人メッセージが送信されます。" + retry: "再試行" + channel_open: + title: "チャンネルを開く" + instructions: "チャンネルを再開すると、すべてのユーザーがメッセージの送信と、既存のメッセージの編集を行えるようになります。" + channel_close: + title: "チャンネルを閉鎖" + instructions: "チャンネルを閉鎖すると、スタッフ以外のユーザーが新しいメッセージの送信や既存のメッセージの編集を行えなくなります。このチャンネルを閉鎖してもよろしいですか?" + channel_delete: + title: "チャンネルを削除" + instructions: "

    %{name} チャンネルとチャット履歴を削除します。すべてのメッセージと、リアクションやアップロードといった関連データは永久に削除されます。チャンネルの履歴を保持して閉鎖するには、チャンネルを削除ではなくアーカイブすることをお勧めします。

    チャンネルを永久に削除してもよろしいですか?確定するには、チャンネルの名前を下のボックスに入力してください。

    " + confirm: "結果を理解し、チャンネルを削除します" + confirm_channel_name: "チャンネル名を入力してください" + process_started: "チャンネルの削除処理が開始しました。このモーダルは間もなく閉じられ、削除されたチャンネルがどこにも表示されなくなります。" + channels_list_popup: + browse: "チャンネルを閲覧する" + create: "新しいチャンネル" + click_to_join: "利用可能なチャンネルを表示するにはここをクリックします。" + close: "閉じる" + collapse: "チャットドロワーを折りたたむ" + confirm_flag: "%{username} のメッセージを通報してよろしいですか?" + deleted: "メッセージは削除されました。[view]" + hidden: "メッセージが非表示でした。[view]" + delete: "削除" + edited: "編集済み" + muted: "ミュート中" + joined: "参加中" + empty_state: + direct_message_cta: "パーソナルチャットを開始" + direct_message: "1 人または複数のユーザーとパーソナルチャットを開始することもできます。" + title: "チャンネルが見つかりません" + email_frequency: + description: "15 分以上アクセスがない場合にのみメールで通知します。" + never: "なし" + title: "メール通知" + when_away: "退席中の時のみ" + enable: "チャットを有効にする" + flag: "通報する" + emoji: "絵文字を挿入する" + flagged: "このメッセージはレビュー目的で通報されました" + invalid_access: "このチャットチャンネルを表示するためのアクセス権がありません" + invitation_notification: "%{username} があなたをチャットチャンネルに招待しました" + in_reply_to: "返信先" + heading: "チャット" + join: "参加" + new_messages: "新しいメッセージ" + mention_warning: + cannot_see: + other: "%{usernames} はこのチャンネルにアクセスできないため通知されませんでした。" + dismiss: "閉じる" + invitations_sent: + other: "招待状を送信しました" + invite: "チャンネルに招待する" + without_membership: + other: "%{usernames} はこのチャンネルに参加していません。" + aria_roles: + header: "チャットヘッダー" + composer: "チャット作成ツール" + channels_list: "チャットのチャンネルリスト" + no_public_channels: "どのチャンネルにも参加していません。" + only_chat_push_notifications: + title: "チャットのプッシュ通知のみ送信する" + description: "チャット以外のすべてのプッシュ通知の送信をブロックします" + ignore_channel_wide_mention: + title: "チャンネル全体のメンションを無視する" + description: "チャット全体のメンション(@here および @all)の通知を送信しません" + open: "チャットを開く" + open_full_page: "全画面チャットを開く" + close_full_page: "全画面チャットを閉じる" + open_message: "メッセージをチャットで開く" + placeholder_self: "メモを書き留める" + placeholder_others: "%{messageRecipient} とチャット" + placeholder_new_message_disallowed: "チャンネルは %{status} です。現在、新しいメッセージを送信できません。" + placeholder_silenced: "現在、メッセージを送信できません。" + placeholder_start_conversation: '%{usernames} と会話を始める' + remove_upload: "ファイルを削除する" + react: "絵文字でリアクション" + reply: "返信" + edit: "編集" + copy_link: "リンクをコピーする" + rebake_message: "HTML を再構築" + retry_staged_message: + title: "ネットワークエラー" + action: "もう一度送信しますか?" + unreliable_network: "ネットワークが不安定です。メッセージの送信と下書きの保存が機能しない可能性があります" + bookmark_message: "ブックマーク" + bookmark_message_edit: "ブックマークを編集" + restore: "削除されたメッセージを復元する" + save: "保存" + select: "選択" + silence: "ユーザーを投稿禁止にする" + return_to_list: "チャンネルリストに戻る" + scroll_to_bottom: "一番下にスクロール" + scroll_to_new_messages: "新しいメッセージを見る" + sound: + title: "デスクトップチャットの通知音" + sounds: + none: "なし" + bell: "ベル" + ding: "ゴーン" + title: "チャット" + title_capitalized: "チャット" + upload: "ファイルを添付する" + uploaded_files: + other: "%{count} 個のファイル" + you_flagged: "このメッセージを通報しました" + exit: "戻る" + channel_status: + read_only_header: "チャンネルは読み取り専用です" + read_only: "読み取り専用" + archived_header: "チャンネルはアーカイブされています" + archived: "アーカイブ済み" + archive_failed: "チャンネルのアーカイブに失敗しました。%{completed} / 全 %{total} 件のメッセージがアーカイブ先のトピックにアーカイブされました。再試行を押して、アーカイブの完了を試してください。" + archive_completed: "アーカイブ済みのトピックを見る" + closed_header: "チャンネルは閉鎖されています" + closed: "閉鎖" + open_header: "チャンネルは開いています" + open: "オープン" + browse: + title: チャンネル + filter_all: すべて + filter_open: オープン + filter_closed: 閉鎖 + filter_archived: アーカイブ済み + filter_input_placeholder: チャンネル名で検索 + chat_message_separator: + today: 今日 + yesterday: 昨日 + members_view: + filter_placeholder: メンバーを検索 + about_view: + associated_topic: リンクされたトピック + associated_category: リンクされたカテゴリ + title: タイトル + description: 説明 + channel_info: + back_to_all_channels: "すべてのチャンネル" + back_to_channel: "戻る" + tabs: + about: 紹介 + members: メンバー + settings: 設定 + channel_edit_title_modal: + title: タイトルを編集する + input_placeholder: タイトルを追加する + description: チャンネルに説明的な短いタイトルを付けます + channel_edit_description_modal: + title: 説明を編集する + input_placeholder: 説明を追加する + description: このチャンネルの紹介を入力します + direct_message_creator: + title: 新しいメッセージ + prefix: "宛先:" + no_results: 結果がありません + selected_user_title: "%{username} を選択解除" + channel_selector: + title: "チャンネルにジャンプ" + no_channels: "検索に一致するチャンネルはありません" + channel: + no_memberships: このチャンネルにはメンバーがいません + no_memberships_found: メンバーが見つかりません + memberships_count: + other: "%{count} 人のメンバー" + create_channel: + auto_join_users: + public_category_warning: "%{category} は公開カテゴリです。最近アクティブなすべてのユーザーをこのチャンネルに自動的に追加しますか?" + warning_groups: + other: '%{group} と %{group_2} から %{members_count} 人のユーザーを自動的に追加しますか?' + warning_multiple_groups: '%{group_1} の %{members_count} 人のユーザーと他 %{count} 人を自動的に追加しますか?' + choose_category: + label: "カテゴリを選択する" + none: "1 つ選択してください..." + default_hint: %{category} のセキュリティ設定に移動し、アクセス権を管理します + hint_groups: + other: '%{hint} と %{hint_2} ユーザーは、セキュリティ設定に従って、このチャンネルにアクセスできます。' + hint_multiple_groups: '%{hint_1} のユーザー他 %{count} 個のグループのユーザーは、セキュリティ設定に従って、このチャンネルにアクセスできます。' + create: "チャンネルを作成" + description: "説明(オプション)" + name: "チャンネル名" + title: "新しいチャンネル" + type: "タイプ" + types: + category: "カテゴリ" + topic: "トピック" + reviewable: + type: "チャットメッセージ" + reactions: + only_you: ":%{emoji}: でリアクションしました" + and_others: "あなたと %{usernames} が :%{emoji}: でリアクションしました" + only_others: "%{usernames} が :%{emoji}: でリアクションしました" + others_and_more: "%{usernames} と他 %{more} 人が :%{emoji}: でリアクションしました" + you_others_and_more: "あなた、%{usernames} と他 %{more} 人が :%{emoji}: でリアクションしました" + composer: + toggle_toolbar: "ツールバーの切り替え" + italic_text: "強調文字" + bold_text: "太字" + code_text: "コードテキスト" + quote: + original_channel: '最初に %{channel} で送信されました' + copy_success: "チャットの引用をクリップボードにコピーしました" + notification_levels: + never: "なし" + mention: "メンションのみ" + always: "すべてのアクティビティ" + settings: + enable_auto_join_users: "最近アクティブなユーザーをすべて自動的に追加する" + disable_auto_join_users: "ユーザーの自動追加を停止する" + auto_join_users_warning: "このチャンネルのメンバーでなく、%{category} カテゴリにアクセスできるすべてのユーザーが参加します。よろしいですか?" + desktop_notification_level: "デスクトップ通知" + follow: "参加" + followed: "参加中" + mobile_notification_level: "モバイルプッシュ通知" + mute: "チャンネルをミュート" + muted_on: "オン" + muted_off: "オフ" + notifications: "通知" + preview: "プレビュー" + save: "保存" + saved: "保存しました" + unfollow: "退出" + admin: + title: "チャット" + direct_messages: + title: "パーソナルチャット" + new: "新しいパーソナルチャット" + create: "開始" + leave: "このパーソナルチャットから退出する" + cannot_create: "ダイレクトメッセージを送信できません。" + incoming_webhooks: + back: "戻る" + channel_placeholder: "チャンネルを選択する" + confirm_destroy: "この着信 Webhook を削除してもよろしいですか?この操作は元に戻せません。" + current_emoji: "現在の絵文字" + description: "説明" + delete: "削除" + emoji: "絵文字" + emoji_instructions: "絵文字を空白のままにすると、システムアバターが使用されます。" + name: "名前" + name_placeholder: "名前..." + new: "新しい着信 Webhook" + none: "既存の着信 Webhook は作成されていません。" + no_emoji: "絵文字が選択されていません" + post_to: "投稿先" + reset_emoji: "絵文字をリセット" + save: "保存" + edit: "編集" + select_emoji: "絵文字を選択" + system: "システム" + title: "着信 Webhook" + url: "URL" + url_instructions: "この URL には秘密の値が含まれます。安全に保管してください。" + username: "ユーザー名" + username_instructions: "チャンネルに投稿するボットのユーザー名。空白のままにすると、デフォルトで「システム」になります。" + instructions: "受信 Webhook は、外部システムが /hooks/:key エンドポイントを介して指定されたチャットチャンネルにボットユーザーとしてメッセージを投稿する際に使用できます。ペイロードは、2000 文字に制限された単一の text パラメーターで構成されます。

    また、Slack 形式の text パラメーターも制限付きでサポートしており、https://api.slack.com/reference/surfaces/formatting のフォーマットに基いてリンクとメンションが抽出されますが、これには、/hooks/:key/slack エンドポイントを使用する必要があります。" + selection: + cancel: "キャンセル" + quote_selection: "トピックで引用" + copy: "コピー" + move_selection_to_channel: "チャンネルに移動" + error: "チャットメッセージを移動中にエラーが発生しました" + title: "チャットをトピックに移動" + new_topic: + title: "新しいトピックに移動" + instructions: + other: "新しいトピックを作成し、選択した %{count} 件のチャットメッセージを挿入しようとしています。" + instructions_channel_archive: "新しいトピックを作成し、それにチャンネルメッセージをアーカイブしようとしています。" + existing_topic: + title: "既存のトピックに移動" + instructions: + other: "それらの %{count} 件のチャットメッセージを移動するトピックを選択してください。" + instructions_channel_archive: "チャンネルメッセージのアーカイブ先のトピックを選択してください。" + new_message: + title: "新しいメッセージに移動" + instructions: + other: "新しいメッセージを作成し、選択した %{count} 件のチャットメッセージを挿入しようとしています。" + replying_indicator: + single_user: "%{username} が入力中です" + multiple_users: "%{commaSeparatedUsernames} と %{lastUsername} が入力中です" + many_users: + other: "%{commaSeparatedUsernames} と他 %{count} 人が入力中です" + retention_reminders: + public: "チャンネル履歴は %{days} 日間保持されます。" + dm: "パーソナルチャット履歴は %{days} 日間保持されます。" + topic_button_title: "チャット" + flags: + off_topic: "このメッセージは、チャンネルのタイトルで定義された現在のディスカッションとは関係がないため、おそらく別の場所に移動する必要があります。" + inappropriate: "このメッセージには、合理的な人が攻撃的、虐待的、またはコミュニティーガイドラインに違反すると見なすコンテンツが含まれます。" + spam: "このメッセージは広告または荒らし行為です。現在のチャンネルに有益な内容でなく、関連性もありません。" + notify_user: "この人のメッセージについて、この人に直接個人的に話を聞きたいと思います。" + notify_moderators: "このメッセージには、上記以外の理由でスタッフの注意が必要です。" + flagging: + action: "メッセージを通報する" + emoji_picker: + favorites: "よく使用される絵文字" + smileys_&_emotion: "顔文字と感情" + objects: "物" + people_&_body: "人と体" + travel_&_places: "旅行と場所" + animals_&_nature: "動物と自然" + food_&_drink: "食べ物と飲み物" + activities: "活動" + flags: "旗" + symbols: "記号" + search_placeholder: "絵文字名とエイリアスで検索..." + no_results: "結果がありません" + draft_channel_screen: + header: "新しいメッセージ" + cancel: "キャンセル" + notifications: + chat_invitation: "があなたをチャットチャンネルに招待しました" + chat_invitation_html: "%{username} があなたをチャットチャンネルに招待しました" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'があなたを "%{channel}" でメンションしました' + direct_html: '%{username} があなたを "%{channel}" でメンションしました' + other_plain: 'が %{identifier} を "%{channel}" でメンションしました' + other_html: '%{username} が %{identifier} を "%{channel}" でメンションしました' + direct_message_chat_mention: + direct: "があなたをパーソナルチャットでメンションしました" + direct_html: "%{username} があなたをパーソナルチャットでメンションしました" + other_plain: "が %{identifier} をパーソナルチャットでメンションしました" + other_html: "%{username} が %{identifier} をパーソナルチャットでメンションしました" + chat_message: "新しいチャットメッセージ" + chat_quoted: "%{username} があなたのチャットメッセージを引用しました" + titles: + chat_mention: "チャットのメンション" + chat_invitation: "チャットの招待状" + chat_quoted: "チャットが引用されました" + action_codes: + chat: + enabled: '%{who} がを有効にしました: %{when}' + disabled: "%{who} がチャットをクローズしました: %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: チャットメッセージを送信する + fields: + chat_channel_id: + label: チャットチャンネル ID + message: + label: メッセージ + sender: + label: 送信者 + description: デフォルトはシステムです + review: + transcript: + view: "前のメッセージのトランスクリプトを表示" + types: + reviewable_chat_message: + title: "通報されたチャットメッセージ" + flagged_by: "通報者" + keyboard_shortcuts_help: + chat: + title: "チャット" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} チャンネルを切り替える" + open_quick_channel_selector: "%{shortcut} クイックチャンネルセレクターを開く" + open_insert_link_modal: "%{shortcut} ハイパーリンクを挿入(作成ツールのみ)" + composer_bold: "%{shortcut} 太字(作成ツールのみ)" + composer_italic: "%{shortcut} 斜体(作成ツールのみ)" + composer_code: "%{shortcut} コード(作成ツールのみ)" + drawer_open: "%{shortcut} チャットドロワーを開く" + drawer_close: "%{shortcut} チャットドロワーを閉じる" + topic_statuses: + chat: + help: "このトピックのチャットは有効になっています" + user: + allow_private_messages: "他のユーザーが個人メッセージとチャットダイレクトメッセージを自分に送信することを許可する" + muted_users_instructions: "これらのユーザーからのすべての通知、個人メッセージ、およびチャットダイレクトメッセージを非表示にします。" + allowed_pm_users_instructions: "これらのユーザーからの個人メッセージまたはチャットダイレクトメッセージのみを許可します。" + allow_private_messages_from_specific_users: "特定のユーザーのみが個人メッセージまたはチャットダイレクトメッセージを自分に送信することを許可する" + ignored_users_instructions: "これらのユーザーからのすべての投稿、メッセージ、通知、個人メッセージ、およびチャットダイレクトメッセージを非表示にします。" + user_menu: + no_chat_notifications_title: "チャット通知はまだありません" + no_chat_notifications_body: > + 誰かがあなたにダイレクトメッセージを送信したり、チャットであなたを @メンションすると、このパネルに通知されます。あなたがしばらくログインしていない場合、通知はメールでも送信されます。

    チャットチャンネルの上部にあるタイトルをクリックすると、そのチャンネルでどの通知を受け取るかを構成できます。詳細については、通知設定をご覧ください。 + tabs: + chat_notifications: "チャット通知" + chat_notifications_with_unread: + other: "チャット通知 - %{count} 件の未読の通知" diff --git a/plugins/chat/config/locales/client.ko.yml b/plugins/chat/config/locales/client.ko.yml new file mode 100644 index 0000000000..b11977d141 --- /dev/null +++ b/plugins/chat/config/locales/client.ko.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: + js: + chat: + create: "글" + cancel: "취소" + channel_settings: + edit: "편집" + add: "추가" + join: "가입" + leave: "나가기" + close: "닫기" + delete: "삭제하기" + edited: "편집됨" + muted: "알림끔" + joined: "가입" + email_frequency: + never: "알림 받지 않기" + flag: "신고" + join: "가입" + mention_warning: + dismiss: "무시" + reply: "댓글쓰기" + edit: "편집" + rebake_message: "HTML 다시 빌드하기" + bookmark_message: "북마크" + bookmark_message_edit: "북마크 수정" + save: "저장" + sounds: + none: "없음" + exit: "뒤로" + channel_status: + closed: "닫힘" + open: "열기" + browse: + back: "뒤로" + filter_all: 전체 + filter_closed: 닫힘 + chat_message_separator: + today: 오늘 + yesterday: 어제 + about_view: + title: 제목 + description: 내용 + channel_info: + back_to_channel: "뒤로" + tabs: + about: 소개 + members: 회원 + settings: 설정 + direct_message_creator: + title: 새로운 메시지 + prefix: "받는사람:" + create_channel: + type: "유형" + types: + category: "카테고리" + topic: "글" + composer: + italic_text: "강조하기" + bold_text: "굵게하기" + notification_levels: + never: "알림 받지 않기" + settings: + follow: "가입" + followed: "가입" + notifications: "알림" + preview: "미리 보기" + save: "저장" + saved: "저장되었습니다" + unfollow: "나가기" + incoming_webhooks: + back: "뒤로" + description: "내용" + delete: "삭제하기" + emoji: "이모티콘" + name: "그룹명" + save: "저장" + edit: "수정" + system: "시스템" + url: "URL" + username: "아이디" + selection: + cancel: "취소" + copy: "복사" + new_topic: + title: "새로운 주제로 이동" + existing_topic: + title: "이미 있는 주제로 옮기기" + new_message: + title: "새 메시지로 이동" + emoji_picker: + objects: "사물" + activities: "활동" + flags: "신고" + symbols: "기호" + draft_channel_screen: + header: "새로운 메시지" + cancel: "취소" + notifications: + chat_quoted: "%{username} %{description} " + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: 메시지 보내기 + review: + types: + reviewable_chat_message: + flagged_by: "신고자" diff --git a/plugins/chat/config/locales/client.lt.yml b/plugins/chat/config/locales/client.lt.yml new file mode 100644 index 0000000000..5a0358c584 --- /dev/null +++ b/plugins/chat/config/locales/client.lt.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: + js: + chat: + create: "Sukurti" + cancel: "Atšaukti" + channel_settings: + edit: "Redaguoti" + add: "Pridėti" + join: "Prisijungti" + leave: "Palikti" + close: "Uždaryti" + delete: "Pašalinti" + edited: "taisytas" + muted: "nutyldita" + joined: "prisijungė" + email_frequency: + never: "Niekada" + flag: "Pranešti" + join: "Prisijungti" + mention_warning: + dismiss: "praleisti" + reply: "Atsakyti" + edit: "Redaguoti" + rebake_message: "Perkurti HTML" + bookmark_message: "Žymės" + bookmark_message_edit: "Redaguoti žymę" + save: "Išsaugoti" + sounds: + none: "Nieko" + exit: "atgal" + channel_status: + closed: "Uždaryta" + open: "Atidaryti" + browse: + back: "Atgal" + filter_all: Visos + filter_closed: Uždaryta + chat_message_separator: + today: Šiandien + yesterday: Vakar + about_view: + title: Antraštė + description: Aprašymas + channel_info: + back_to_channel: "Atgal" + tabs: + about: Apie + members: Nariai + settings: Nustatymai + direct_message_creator: + title: Nauja žinutė + prefix: "Kam:" + create_channel: + type: "Tipas" + types: + category: "Kategorija" + topic: "Tema" + composer: + italic_text: "pasviras tekstas" + bold_text: "paryškintas tekstas" + notification_levels: + never: "Niekada" + settings: + follow: "Prisijungti" + followed: "Prisijungė" + notifications: "Pranešimai" + preview: "Peržiūrėti" + save: "Išsaugoti" + saved: "Išsaugota" + unfollow: "Palikti" + incoming_webhooks: + back: "Atgal" + description: "Aprašymas" + delete: "Pašalinti" + emoji: "Emoji" + name: "Vardas" + save: "Išsaugoti" + edit: "Redaguoti" + system: "sistema" + url: "Nuoroda" + username: "Vartotojo vardas" + selection: + cancel: "Atšaukti" + copy: "Kopijuoti" + new_topic: + title: "Perkelti į naują temą" + existing_topic: + title: "Perkelti į esamą temą" + new_message: + title: "Pereiti prie naujos žinutės" + emoji_picker: + objects: "Objektai" + activities: "Veikla" + flags: "Pažymėk!" + symbols: "Simboliai" + draft_channel_screen: + header: "Nauja žinutė" + cancel: "Atšaukti" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Žinutės + review: + types: + reviewable_chat_message: + flagged_by: "Pažymėjo" diff --git a/plugins/chat/config/locales/client.lv.yml b/plugins/chat/config/locales/client.lv.yml new file mode 100644 index 0000000000..3413b0627c --- /dev/null +++ b/plugins/chat/config/locales/client.lv.yml @@ -0,0 +1,112 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: + js: + chat: + create: "Izveidot" + cancel: "Atcelt" + channel_settings: + edit: "Rediģēt" + add: "Pievienot" + join: "Pievienojieties" + leave: "Iziet" + close: "Aizvērt" + delete: "Dzēst" + edited: "rediģēts" + muted: "klusināts" + joined: "pievienojās" + email_frequency: + never: "Nekad" + flag: "Sūdzība" + join: "Pievienojieties" + mention_warning: + dismiss: "nerādīt" + reply: "Atbilde" + edit: "Rediģēt" + rebake_message: "Pārbūvēt HTML" + bookmark_message: "Grāmatzīmes" + bookmark_message_edit: "Rediģēt grāmatzīmi" + save: "Saglabāt" + sounds: + none: "Nav" + exit: "atpakaļ" + channel_status: + closed: "Slēgts" + open: "Atvērt" + browse: + back: "Atpakaļ" + filter_all: Viss + filter_closed: Slēgts + chat_message_separator: + today: Šodien + yesterday: Vakar + about_view: + title: Virsraksts + description: Apraksts + channel_info: + back_to_channel: "Atpakaļ" + tabs: + about: Par + members: Dalībnieki + settings: Iestatījumi + direct_message_creator: + title: Jauns ziņa + prefix: "Kam:" + create_channel: + type: "Tips" + types: + category: "Sadaļa" + topic: "Tēmas" + composer: + italic_text: "Uzsvērts teksts" + bold_text: "treknrakstā" + notification_levels: + never: "Nekad" + settings: + follow: "Pievienojieties" + followed: "Pievienojās" + notifications: "Paziņojumi" + preview: "Priekšskatījums" + save: "Saglabāt" + saved: "Saglabāts" + unfollow: "Iziet" + incoming_webhooks: + back: "Atpakaļ" + description: "Apraksts" + delete: "Dzēst" + emoji: "Smaidiņi" + name: "Vārds" + save: "Saglabāt" + edit: "Rediģēt" + system: "sistēma" + url: "URL" + username: "Lietotājvārds" + selection: + cancel: "Atcelt" + copy: "Kopēt" + new_topic: + title: "Pārvietot uz jaunu tēmu" + existing_topic: + title: "Pārvietot uz esošu tēmu" + emoji_picker: + objects: "Priekšmeti" + activities: "Aktivitātes" + flags: "Sūdzības" + symbols: "Simboli" + draft_channel_screen: + header: "Jauns ziņa" + cancel: "Atcelt" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Ziņa + review: + types: + reviewable_chat_message: + flagged_by: "Atzīmēja" diff --git a/plugins/chat/config/locales/client.nb_NO.yml b/plugins/chat/config/locales/client.nb_NO.yml new file mode 100644 index 0000000000..2739387de7 --- /dev/null +++ b/plugins/chat/config/locales/client.nb_NO.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: + js: + chat: + create: "Opprett" + cancel: "Avbryt" + channel_settings: + edit: "Endre" + add: "Legg til" + join: "Bli medlem" + leave: "Forlat" + close: "Lukk" + delete: "Slett" + edited: "redigert" + muted: "ignorert" + joined: "medlem fra" + email_frequency: + never: "Aldri" + flag: "Flagg" + join: "Bli medlem" + mention_warning: + dismiss: "forkast" + reply: "Svar" + edit: "Endre" + rebake_message: "Generer HTML på nytt" + bookmark_message: "Bokmerke" + bookmark_message_edit: "Rediger bokmerke" + save: "Lagre" + sounds: + none: "Ingen" + exit: "forrige" + channel_status: + closed: "Lukket" + open: "Åpne" + browse: + back: "Forrige" + filter_all: Alle + filter_closed: Lukket + chat_message_separator: + today: I dag + yesterday: I går + about_view: + title: Tittel + description: Beskrivelse + channel_info: + back_to_channel: "Forrige" + tabs: + about: Om + members: Medlemmer + settings: Instillinger + direct_message_creator: + title: Ny Melding + prefix: "Til:" + create_channel: + type: "Type" + types: + category: "Kategori" + topic: "Emne" + composer: + italic_text: "kursiv tekst" + bold_text: "sterk tekst" + notification_levels: + never: "Aldri" + settings: + follow: "Bli medlem" + followed: "Medlem fra" + notifications: "Varsler" + preview: "Forhåndsvis" + save: "Lagre" + saved: "Lagret" + unfollow: "Forlat" + incoming_webhooks: + back: "Forrige" + description: "Beskrivelse" + delete: "Slett" + emoji: "Emoji" + name: "Navn" + save: "Lagre" + edit: "Endre" + system: "system" + url: "URL" + username: "Brukernavn" + selection: + cancel: "Avbryt" + copy: "Kopier" + new_topic: + title: "Flytt til nytt emne" + existing_topic: + title: "Flytt til eksisterende emne" + new_message: + title: "Flytt til ny melding" + emoji_picker: + objects: "Objekter" + activities: "Aktiviteter" + flags: "Flagg" + symbols: "Symboler" + draft_channel_screen: + header: "Ny Melding" + cancel: "Avbryt" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Send + review: + types: + reviewable_chat_message: + flagged_by: "Rapportert av" diff --git a/plugins/chat/config/locales/client.nl.yml b/plugins/chat/config/locales/client.nl.yml new file mode 100644 index 0000000000..9fd5074bae --- /dev/null +++ b/plugins/chat/config/locales/client.nl.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + js: + chat: + create: "Aanmaken" + cancel: "Annuleren" + channel_settings: + edit: "Bewerken" + add: "Toevoegen" + join: "Toetreden" + leave: "Verlaten" + close: "Sluiten" + delete: "Verwijderen" + edited: "bewerkt" + muted: "gedempt" + joined: "lid sinds" + email_frequency: + never: "Nooit" + flag: "Markeren" + join: "Toetreden" + mention_warning: + dismiss: "negeren" + reply: "Antwoorden" + edit: "Bewerken" + rebake_message: "HTML opnieuw opbouwen" + bookmark_message: "Bladwijzer maken" + bookmark_message_edit: "Bladwijzer bewerken" + save: "Opslaan" + sounds: + none: "Geen" + exit: "vorige" + channel_status: + closed: "Gesloten" + open: "Openen" + browse: + filter_all: Alle + filter_closed: Gesloten + chat_message_separator: + today: Vandaag + yesterday: Gisteren + about_view: + title: Titel + description: Omschrijving + channel_info: + back_to_channel: "Vorige" + tabs: + about: Over + members: Leden + settings: Instellingen + direct_message_creator: + title: Nieuw bericht + prefix: "Aan:" + create_channel: + type: "Type" + types: + category: "Categorie" + topic: "Topic" + composer: + italic_text: "Cursieve tekst" + bold_text: "Vetgedrukte tekst" + notification_levels: + never: "Nooit" + settings: + follow: "Toetreden" + followed: "Lid sinds" + notifications: "Meldingen" + preview: "Voorbeeld" + save: "Opslaan" + saved: "Opgeslagen" + unfollow: "Verlaten" + incoming_webhooks: + back: "Vorige" + description: "Omschrijving" + delete: "Verwijderen" + emoji: "Emoji" + name: "Naam" + save: "Opslaan" + edit: "Bewerken" + system: "systeem" + url: "URL" + username: "Gebruikersnaam" + selection: + cancel: "Annuleren" + copy: "Kopiëren" + new_topic: + title: "Verplaatsen naar nieuw topic" + existing_topic: + title: "Verplaatsen naar bestaand topic" + new_message: + title: "Verplaatsen naar nieuw bericht" + emoji_picker: + objects: "Objecten" + activities: "Activiteiten" + flags: "Markeringen" + symbols: "Symbolen" + draft_channel_screen: + header: "Nieuw bericht" + cancel: "Annuleren" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Bericht + review: + types: + reviewable_chat_message: + flagged_by: "Gemarkeerd door" diff --git a/plugins/chat/config/locales/client.pl_PL.yml b/plugins/chat/config/locales/client.pl_PL.yml new file mode 100644 index 0000000000..887b41a1ec --- /dev/null +++ b/plugins/chat/config/locales/client.pl_PL.yml @@ -0,0 +1,332 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Zmieniono status kanału czatu" + chat_channel_delete: "Kanał czatu usunięty" + about: + chat_messages_count: "Wiadomości czatu" + chat_channels_count: "Kanały czatu" + chat_users_count: "Użytkownicy czatu" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Pokazuje wszystkie wiadomości" + already_enabled: "Czat jest już włączony w tym temacie. Spróbuj odświeżyć strone." + disabled_for_topic: "Czat jest wyłączony w tym temacie." + bot: "bot" + create: "Utwórz" + cancel: "Anuluj" + cancel_reply: "Anuluj odpowiedź" + chat_channels: "Kanały" + browse_all_channels: "Przeglądaj wszystkie kanały" + move_to_channel: + confirm_move: "Przenieś wiadomości" + channel_settings: + title: "Ustawienia kanału" + edit: "Edytuj" + add: "Dodaj" + close_channel: "Zamknij kanał" + open_channel: "Otwórz kanał" + delete_channel: "Usuń kanał" + join_channel: "Dołącz do kanału" + leave_channel: "Opuść kanał" + join: "Dołącz" + leave: "Opuść" + channel_open: + title: "Otwórz kanał" + channel_close: + title: "Zamknij kanał" + channel_delete: + title: "Usuń kanał" + confirm_channel_name: "Wpisz nazwę kanału" + channels_list_popup: + browse: "Przeglądaj kanały" + create: "Nowy kanał" + click_to_join: "Kliknij tutaj, aby wyświetlić dostępne kanały." + close: "Zamknij" + collapse: "Zwiń szufladę czatu" + confirm_flag: "Czy na pewno chcesz oflagować wiadomość od %{username}?" + deleted: "Wiadomość została usunięta. [view]" + hidden: "Wiadomość została ukryta. [view]" + delete: "Usuń" + edited: "edytowane" + muted: "wyciszono" + joined: "dołączył" + empty_state: + direct_message: "Możesz także rozpocząć osobisty czat z jednym lub kilkoma użytkownikami." + title: "Nie znaleziono żadnych kanałów" + email_frequency: + never: "Nigdy" + title: "Powiadomienia e-mail" + enable: "Włącz czat" + flag: "Oflaguj" + emoji: "Wstaw emoji" + flagged: "Ta wiadomość została oznaczona do sprawdzenia" + invalid_access: "Nie masz dostępu do tego kanału czatu" + in_reply_to: "W odpowiedzi na" + heading: "Czat" + join: "Dołącz" + new_messages: "nowe wiadomości" + mention_warning: + dismiss: "odrzuć" + invitations_sent: + one: "Zaproszenie wysłane" + few: "Zaproszenia wysłane" + many: "Zaproszenia wysłane" + other: "Zaproszenia wysłane" + invite: "Zaproś do kanału" + without_membership: + one: "%{usernames} nie dołączył do tego kanału." + few: "%{usernames} nie dołączyli do tego kanału." + many: "%{usernames} nie dołączyli do tego kanału." + other: "%{usernames} nie dołączyli do tego kanału." + aria_roles: + header: "Nagłówek czatu" + channels_list: "Lista kanałów czatu" + no_public_channels: "Nie dołączyłeś do żadnego kanału." + open: "Otwórz czat" + open_full_page: "Otwórz czat na pełnym ekranie" + open_message: "Otwórz wiadomość na czacie" + placeholder_self: "Zanotuj coś" + placeholder_others: "Czat z %{messageRecipient}" + placeholder_new_message_disallowed: "Kanał ma %{status}, nie możesz teraz wysyłać nowych wiadomości." + placeholder_silenced: "W tej chwili nie możesz wysyłać wiadomości." + remove_upload: "Usuń plik" + react: "Zareaguj z emoji" + reply: "Odpowiedz" + edit: "Edytuj" + copy_link: "Skopiuj link" + rebake_message: "Odśwież HTML" + retry_staged_message: + title: "Błąd sieci" + action: "Wyślij ponownie?" + bookmark_message: "Zakładka" + bookmark_message_edit: "Edytuj zakładkę" + restore: "Przywróć usuniętą wiadomość" + save: "Zapisz" + select: "Wybierz" + silence: "Wycisz użytkownika" + return_to_list: "Powrót do listy kanałów" + scroll_to_bottom: "Przewiń na dół" + scroll_to_new_messages: "Zobacz nowe wiadomości" + sound: + title: "Dźwięk powiadomienia czatu na pulpicie" + sounds: + none: "Brak" + bell: "Dzwonek" + ding: "Ding" + title: "czat" + title_capitalized: "Czat" + upload: "Dołącz plik" + uploaded_files: + one: "%{count} plik" + few: "%{count} pliki" + many: "%{count} plików" + other: "%{count} plików" + you_flagged: "Oflagowałeś tę wiadomość" + exit: "poprzednia" + channel_status: + read_only_header: "Kanał jest tylko do odczytu" + read_only: "Tylko do odczytu" + archived_header: "Kanał został zarchiwizowany" + archived: "Zarchiwizowany" + closed_header: "Kanał jest zamknięty" + closed: "Zamknięta" + open_header: "Kanał jest otwarty" + open: "Otwórz" + browse: + back: "Poprzednia" + title: Kanały + filter_all: Wszystkie + filter_closed: Zamknięta + filter_archived: Zarchiwizowany + chat_message_separator: + today: Dzisiaj + yesterday: Wczoraj + members_view: + filter_placeholder: Znajdź członków + about_view: + associated_topic: Powiązany temat + title: Tytuł + description: Opis + channel_info: + back_to_all_channels: "Wszystkie kanały" + back_to_channel: "Poprzednia" + tabs: + about: O stronie + members: Członkowie + settings: Ustawienia + channel_edit_title_modal: + title: Edytuj tytuł + input_placeholder: Dodaj tytuł + channel_edit_description_modal: + title: Edytuj opis + input_placeholder: Dodaj opis + direct_message_creator: + title: Nowa wiadomość + prefix: "Do:" + no_results: Brak wyników + channel_selector: + title: "Przejdź do kanału" + no_channels: "Żadne kanały nie pasują do Twojego wyszukiwania" + channel: + no_memberships_found: Nie znaleziono członków + memberships_count: + one: "%{count} członek" + few: "%{count} członków" + many: "%{count} członków" + other: "%{count} członków" + create_channel: + choose_category: + label: "Wybierz kategorię" + none: "wybierz jeden..." + default_hint: Zarządzaj dostępem, odwiedzając ustawienia bezpieczeństwa %{category} + create: "Utwórz kanał" + description: "Opis (opcjonalnie)" + name: "Nazwa kanału" + title: "Nowy kanał" + type: "Typ" + types: + category: "Kategoria" + topic: "Temat" + reviewable: + type: "Wiadomość na czacie" + reactions: + only_you: "Zareagowałeś z :%{emoji}:" + and_others: "Ty, %{usernames} zareagowaliście z :%{emoji}:" + only_others: "%{usernames} zareagowali z :%{emoji}:" + others_and_more: "%{usernames} i %{more} inni reagowali z :%{emoji}:" + you_others_and_more: "Ty, %{usernames} i %{more} inni zareagowaliście z :%{emoji}:" + composer: + toggle_toolbar: "Przełącz pasek narzędzi" + italic_text: "wyróżniony tekst" + bold_text: "pogrubiony tekst" + code_text: "kod" + quote: + copy_success: "Cytat z czatu skopiowany do schowka" + notification_levels: + never: "Nigdy" + mention: "Tylko dla wzmianek" + always: "Dla całej aktywności" + settings: + enable_auto_join_users: "Automatycznie dodawaj wszystkich ostatnio aktywnych użytkowników" + disable_auto_join_users: "Zatrzymaj automatyczne dodawanie użytkowników" + desktop_notification_level: "Powiadomienia na pulpicie" + follow: "Dołącz" + followed: "Dołączył" + mobile_notification_level: "Mobilne powiadomienia push" + mute: "Wycisz kanał" + notifications: "Powiadomienia" + preview: "Podgląd" + save: "Zapisz" + saved: "Zapisano" + unfollow: "Opuść" + admin: + title: "Czat" + direct_messages: + title: "Czat osobisty" + new: "Nowy osobisty czat" + leave: "Opuść ten osobisty czat" + incoming_webhooks: + back: "Poprzednia" + channel_placeholder: "Wybierz kanał" + confirm_destroy: "Czy na pewno chcesz usunąć tego przychodzącego webhooka? Tego nie można cofnąć." + current_emoji: "Aktualne emoji" + description: "Opis" + delete: "Usuń" + emoji: "Emoji" + emoji_instructions: "Awatar systemowy zostanie użyty, jeśli emotikon pozostanie pusty." + name: "Nazwa" + name_placeholder: "nazwa..." + new: "Nowy przychodzący webhook" + none: "Nie utworzono żadnych istniejących przychodzących webhooków." + no_emoji: "Nie wybrano emotikonów" + post_to: "Opublikuj w" + reset_emoji: "Zresetuj emotikony" + save: "Zapisz" + edit: "Edytuj" + select_emoji: "Wybierz emoji" + system: "system" + title: "Przychodzące webhooki" + url: "URL" + username: "Nazwa użytkownika" + username_instructions: "Nazwa użytkownika bota, który publikuje na kanale. Domyślnie \"system\" gdy pozostanie puste." + selection: + cancel: "Anuluj" + copy: "Kopiuj" + move_selection_to_channel: "Przejdź do kanału" + error: "Wystąpił błąd podczas przenoszenia wiadomości czatu" + title: "Przenieś czat do tematu" + new_topic: + title: "Przenieś do nowego tematu" + instructions: + one: "Zamierzasz utworzyć nowy temat i wypełnić go wybraną wiadomością czatu." + few: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + many: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + other: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + existing_topic: + title: "Przenieś do Istniejącego Tematu" + new_message: + title: "Przejdź do nowej wiadomości" + replying_indicator: + single_user: "%{username} pisze" + multiple_users: "%{commaSeparatedUsernames} i %{lastUsername} piszą" + retention_reminders: + public: "Historia kanału jest przechowywana przez %{days} dni." + dm: "Historia osobistego czatu jest przechowywana przez %{days} dni." + topic_button_title: "Czat" + emoji_picker: + smileys_&_emotion: "Buźki i emocje" + objects: "Obiekty" + people_&_body: "Ludzie i ciało" + travel_&_places: "Podróże i miejsca" + animals_&_nature: "Zwierzęta i przyroda" + food_&_drink: "Jedzenie i picie" + activities: "Aktywności" + flags: "Flagi" + symbols: "Symbolika" + search_placeholder: "Szukaj według nazwy emoji i aliasu..." + no_results: "Brak wyników" + draft_channel_screen: + header: "Nowa wiadomość" + cancel: "Anuluj" + notifications: + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'wspomniał o Tobie w "%{channel}"' + chat_message: "Nowa wiadomość na czacie" + chat_quoted: "%{username} zacytował Twoją wiadomość na czacie" + discourse_automation: + scriptables: + send_chat_message: + title: Wyślij wiadomość na czacie + fields: + chat_channel_id: + label: ID kanału czatu + message: + label: Wiadomość + sender: + label: Nadawca + review: + types: + reviewable_chat_message: + flagged_by: "Oflagowany przez" + keyboard_shortcuts_help: + chat: + title: "Czat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Przełącz kanał" + open_quick_channel_selector: "%{shortcut} Otwórz szybki selektor kanałów" + user_menu: + tabs: + chat_notifications: "Powiadomienia czatu" diff --git a/plugins/chat/config/locales/client.pt.yml b/plugins/chat/config/locales/client.pt.yml new file mode 100644 index 0000000000..c7d4d8b87a --- /dev/null +++ b/plugins/chat/config/locales/client.pt.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: + js: + chat: + create: "Criar" + cancel: "Cancelar" + channel_settings: + edit: "Editar" + add: "Adicionar" + join: "Entrar" + leave: "Sair" + close: "Fechar" + delete: "Eliminar" + edited: "editado" + muted: "silenciado" + joined: "juntou-se" + email_frequency: + never: "Nunca" + flag: "Denunciar" + join: "Entrar" + mention_warning: + dismiss: "marcar Visto" + reply: "Responder" + edit: "Editar" + rebake_message: "Reconstruir HTML" + bookmark_message: "Adicionar Marcador" + bookmark_message_edit: "Editar Favorito" + save: "Guardar" + sounds: + none: "Nenhuma" + exit: "retroceder" + channel_status: + closed: "Fechado" + open: "Abrir" + browse: + back: "Retroceder" + filter_all: Tudo + filter_closed: Fechado + chat_message_separator: + today: Hoje + yesterday: Ontem + about_view: + title: Título + description: Descrição + channel_info: + back_to_channel: "Retroceder" + tabs: + about: Sobre + members: Membros + settings: Configurações + direct_message_creator: + title: Nova Mensagem + prefix: "Para:" + create_channel: + type: "Tipo" + types: + category: "Categoria" + topic: "Tópico" + composer: + italic_text: "texto em itálico" + bold_text: "texto em negrito" + notification_levels: + never: "Nunca" + settings: + follow: "Entrar" + followed: "Juntou-se" + notifications: "Notificações" + preview: "Pré-visualização" + save: "Guardar" + saved: "Guardado" + unfollow: "Sair" + incoming_webhooks: + back: "Retroceder" + description: "Descrição" + delete: "Eliminar" + emoji: "Emoji" + name: "Nome" + save: "Guardar" + edit: "Editar" + system: "sistema" + url: "URL" + username: "Nome de Utilizador" + selection: + cancel: "Cancelar" + copy: "Copiar" + new_topic: + title: "Mover para um Novo Tópico" + existing_topic: + title: "Mover para Tópico Existente" + new_message: + title: "Mover para Nova Mensagem" + emoji_picker: + objects: "Objetos" + activities: "Atividades" + flags: "Sinalizações" + symbols: "Símbolos" + draft_channel_screen: + header: "Nova Mensagem" + cancel: "Cancelar" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Mensagem + review: + types: + reviewable_chat_message: + flagged_by: "Sinalizado por" diff --git a/plugins/chat/config/locales/client.pt_BR.yml b/plugins/chat/config/locales/client.pt_BR.yml new file mode 100644 index 0000000000..fc841e3308 --- /dev/null +++ b/plugins/chat/config/locales/client.pt_BR.yml @@ -0,0 +1,449 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Status do canal de chat alterado" + chat_channel_delete: "Canal de chat excluído" + api: + scopes: + descriptions: + chat: + create_message: "Crie uma mensagem de chat em um canal especificado." + about: + chat_messages_count: "Mensagens de chat" + chat_channels_count: "Canais de chat" + chat_users_count: "Usuários do chat" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostrando todas as mensagens" + already_enabled: "O chat já foi ativado neste tópico. Atualize." + disabled_for_topic: "O chat está desativado neste tópico." + bot: "robô" + create: "Criar" + cancel: "Cancelar" + cancel_reply: "Cancelar resposta" + chat_channels: "Canais" + browse_all_channels: "Navegar por todos os canais" + move_to_channel: + title: "Mover mensagens para o canal" + instructions: + one: "Você está movendo %{count} mensagem. Selecione um canal de destino. Uma mensagem de espaço reservado será criada no canal %{channelTitle} para indicar que esta mensagem foi movida." + other: "Você está movendo %{count} mensagens. Selecione um canal de destino. Uma mensagem de espaço reservado será criada no canal %{channelTitle} para indicar que essas mensagens foram movidas." + confirm_move: "Mover mensagens" + channel_settings: + title: "Definições do canal" + edit: "Editar" + add: "Adicionar" + close_channel: "Fechar canal" + open_channel: "Abrir canal" + archive_channel: "Arquivar canal" + delete_channel: "Excluir canal" + join_channel: "Entrar no canal" + leave_channel: "Sair do canal" + join: "Participar" + leave: "Sair" + channel_archive: + title: "Arquivar canal" + instructions: "

    O arquivamento de um canal o coloca em modo somente leitura e move todas as mensagens do canal para um tópico novo ou existente. Nenhuma mensagem nova pode ser enviada e nenhuma mensagem existente pode ser editada ou excluída.

    Tem certeza de que deseja arquivar o canal %{channelTitle}?

    " + process_started: "O processo de arquivamento foi iniciado. Este modal será encerrado em breve e você receberá uma mensagem pessoal quando o processo de arquivamento for concluído." + retry: "Tentar novamente" + channel_open: + title: "Abrir Canal" + instructions: "Reabre o canal, todos os usuários poderão enviar mensagens e editar suas mensagens existentes." + channel_close: + title: "Fechar Canal" + instructions: "Fechar o canal impede que usuários não funcionários enviem novas mensagens ou editem mensagens existentes. Tem certeza de que deseja fechar este canal?" + channel_delete: + title: "Excluir Canal" + instructions: "

    Exclui o canal %{name} e o histórico do chat. Todas as mensagens e dados relacionados, como reações e envios, serão excluídos permanentemente. Se você quiser preservar o histórico do canal e desativá-lo, talvez seja melhor arquivar o canal.

    Tem certeza de que deseja excluir permanentemente o canal? Para confirmar, digite o nome do canal na caixa abaixo.

    " + confirm: "Eu entendo as consequências, exclua o canal" + confirm_channel_name: "Digite o nome do canal" + process_started: "O processo para excluir o canal foi iniciado. Este modal será fechado em breve, você não verá mais o canal excluído em lugar algum." + channels_list_popup: + browse: "Navegar por canais" + create: "Novo canal" + click_to_join: "Clique aqui para visualizar os canais disponíveis." + close: "Fechar" + collapse: "Recolher gaveta de chat" + confirm_flag: "Tem certeza de que deseja sinalizar a mensagem de %{username}?" + deleted: "Uma mensagem foi excluída. [view]" + hidden: "Uma mensagem foi ocultada. [view]" + delete: "Excluir" + edited: "editou" + muted: "silenciado" + joined: "entrou" + empty_state: + direct_message_cta: "Inicie um chat pessoal" + direct_message: "Você também pode iniciar um chat pessoal com um(as) ou mais usuários(as)." + title: "Nenhum canal encontrado" + email_frequency: + description: "Só enviaremos um e-mail mediante ausência nos últimos 15 minutos." + never: "Nunca" + title: "Notificações por e-mail" + when_away: "Só quando estiver ausente" + enable: "Ativar chat" + flag: "Sinalizar" + emoji: "Inserir emoji" + flagged: "Esta mensagem foi sinalizada para revisão" + invalid_access: "Você não tem acesso para ver este canal de chat" + invitation_notification: "%{username} convidou você para entrar em um canal de chat" + in_reply_to: "Em resposta a" + heading: "Chat" + join: "Participar" + new_messages: "novas mensagens" + mention_warning: + cannot_see: + one: "%{usernames} não pode acessar este canal e não recebeu notificação." + other: "%{usernames} não podem acessar este canal e não receberam notificação." + dismiss: "ignorar" + invitations_sent: + one: "Convite enviado" + other: "Convites enviados" + invite: "convidar para canal" + without_membership: + one: "%{usernames} não entrou neste canal." + other: "%{usernames} não entraram neste canal." + aria_roles: + header: "Cabeçalho do chat" + composer: "Compositor de chat" + channels_list: "Lista de canais de chat" + no_public_channels: "Você não entrou em nenhum canal." + only_chat_push_notifications: + title: "Enviar apenas notificações por push" + description: "Bloquear envio de todas as notificações por push não relacionadas a chat" + ignore_channel_wide_mention: + title: "Ignorar menções em todo o canal" + description: "Não envie notificações para menções em todo o canal (@aqui e @todos)" + open: "Abrir chat" + open_full_page: "Abrir chat em tela cheia" + close_full_page: "Fechar chat em tela cheia" + open_message: "Abrir mensagem no chat" + placeholder_self: "Anotar algo" + placeholder_others: "Conversar com %{messageRecipient}" + placeholder_new_message_disallowed: "O canal é %{status}, você não pode enviar novas mensagens agora." + placeholder_silenced: "Você não pode enviar mensagens neste momento." + placeholder_start_conversation: Iniciar uma conversa com %{usernames} + remove_upload: "Remover arquivo" + react: "Reagir com emoji" + reply: "Responder" + edit: "Editar" + copy_link: "Copiar link" + rebake_message: "Reconstruir HTML" + retry_staged_message: + title: "Erro de rede" + action: "Enviar novamente?" + unreliable_network: "A rede não é confiável, enviar mensagens e salvar rascunho pode não funcionar" + bookmark_message: "Marcador" + bookmark_message_edit: "Editar marcador" + restore: "Restaurar mensagem excluída" + save: "Salvar" + select: "Selecionar" + silence: "Silenciar usuário(a)" + return_to_list: "Retornar para lista de canais" + scroll_to_bottom: "Rolar para a parte inferior" + scroll_to_new_messages: "Ver novas mensagens" + sound: + title: "Som de notificação do chat no desktop" + sounds: + none: "Nenhum" + bell: "Sino" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Anexar arquivo" + uploaded_files: + one: "%{count} arquivo" + other: "%{count} arquivos" + you_flagged: "Você sinalizou esta mensagem" + exit: "voltar" + channel_status: + read_only_header: "O canal é somente para leitura" + read_only: "Somente leitura" + archived_header: "O canal está arquivado" + archived: "Arquivados" + archive_failed: "Falha no canal de arquivamento. %{completed}/%{total} mensagens foram arquivadas no tópico de destino. Pressione repetir para tentar concluir o arquivo." + archive_completed: "Veja o tópico de arquivo" + closed_header: "Canal fechado" + closed: "Fechados" + open_header: "Canal aberto" + open: "Aberto" + browse: + title: Canais + filter_all: Todos + filter_open: Abertos + filter_closed: Fechados + filter_archived: Arquivados + filter_input_placeholder: Pesquisar canal por nome + chat_message_separator: + today: Hoje + yesterday: Ontem + members_view: + filter_placeholder: Encontrar membros + about_view: + associated_topic: Tópico vinculado + associated_category: Categoria vinculada + title: Título + description: Descrição + channel_info: + back_to_all_channels: "Todos os canais" + back_to_channel: "Voltar" + tabs: + about: Sobre + members: Membros + settings: Definições + channel_edit_title_modal: + title: Editar título + input_placeholder: Adicione um título + description: Dê um breve título descritivo ao seu canal + channel_edit_description_modal: + title: Editar descrição + input_placeholder: Adicione uma descrição + description: Diga às pessoas do que se trata este canal + direct_message_creator: + title: Novas mensagens + prefix: "Para:" + no_results: Nenhum resultado + selected_user_title: "Desmarcar %{username}" + channel_selector: + title: "Pular para o canal" + no_channels: "Nenhum canal corresponde à sua pesquisa" + channel: + no_memberships: Este canal não tem membros + no_memberships_found: Nenhum membro encontrado + memberships_count: + one: "%{count} membro" + other: "%{count} membros" + create_channel: + auto_join_users: + public_category_warning: "%{category} é uma categoria pública. Deseja adicionar automaticamente todos os usuários recentemente ativos a este canal?" + warning_groups: + one: Adicionar automaticamente %{members_count} usuários de %{group}? + other: Adicionar automaticamente %{members_count} usuários de %{group} e %{group_2}? + warning_multiple_groups: Adicionar automaticamente %{members_count} usuários de %{group_1} e outros %{count}? + choose_category: + label: "Escolha uma categoria" + none: "selecionar um..." + default_hint: Gerencie o acesso ao acessar as %{category} configurações de segurança + hint_groups: + one: Os usuários em %{hint} terão acesso a este canal de acordo com as configurações de segurança + other: Os usuários em %{hint} e %{hint_2} terão acesso a este canal de acordo com as configurações de segurança + hint_multiple_groups: Os usuários em %{hint_1} e %{count} terão acesso a este canal de acordo com as configurações de segurança + create: "Criar canal" + description: "Descrição (opcional)" + name: "Nome do canal" + title: "Novo canal" + type: "Tipo" + types: + category: "Categoria" + topic: "Tópico" + reviewable: + type: "Mensagem de chat" + reactions: + only_you: "Você reagiu com :%{emoji}:" + and_others: "Você, %{usernames} reagiu com :%{emoji}:" + only_others: "%{usernames} reagiu com :%{emoji}:" + others_and_more: "%{usernames} e mais %{more} reagiram com :%{emoji}:" + you_others_and_more: "Você, %{usernames} e mais %{more} reagiram com :%{emoji}:" + composer: + toggle_toolbar: "Ativar/desativar barra de ferramentas" + italic_text: "texto enfatizado" + bold_text: "texto forte" + code_text: "texto do código" + quote: + original_channel: 'Originalmente enviado em %{channel}' + copy_success: "Citação do chat copiada para área de transferência" + notification_levels: + never: "Nunca" + mention: "Apenas para menções" + always: "Para todas as atividades" + settings: + enable_auto_join_users: "Adicionar automaticamente todos os usuários ativos recentemente" + disable_auto_join_users: "Parar de adicionar usuários automaticamente" + auto_join_users_warning: "Todos os usuários que não são membros deste canal e têm acesso à categoria %{category} participarão. Tem certeza?" + desktop_notification_level: "Notificações do desktop" + follow: "Participar" + followed: "Entrou" + mobile_notification_level: "Notificações por push em dispositivos móveis" + mute: "Silenciar canal" + muted_on: "Ligado" + muted_off: "Desligado" + notifications: "Notificações" + preview: "Pré-visualizar" + save: "Salvar" + saved: "Salvou" + unfollow: "Sair" + admin: + title: "Chat" + direct_messages: + title: "Chat pessoal" + new: "Novo chat pessoal" + create: "Ir" + leave: "Sair deste chat pessoal" + cannot_create: "Desculpe, você não pode enviar mensagens diretas." + incoming_webhooks: + back: "Voltar" + channel_placeholder: "Selecione um canal" + confirm_destroy: "Tem certeza de que deseja excluir este webhook recebido? Isso não pode ser desfeito." + current_emoji: "Emoji atual" + description: "Descrição" + delete: "Excluir" + emoji: "Emoji" + emoji_instructions: "O avatar do sistema será usado se o emoji for deixado em branco." + name: "Nome" + name_placeholder: "nome..." + new: "Novo webhook recebido" + none: "Nenhum webhook recebido existente foi criado." + no_emoji: "Nenhum emoji selecionado" + post_to: "Postar para" + reset_emoji: "Redefinir emoji" + save: "Salvar" + edit: "Editar" + select_emoji: "Escolher emoji" + system: "sistema" + title: "Webhooks recebidos" + url: "URL" + url_instructions: "Esta URL contém um valor secreto - mantenha-o seguro." + username: "Nome de usuário(a)" + username_instructions: "Nome de usuário(a) de robô que posta no canal. Padrão para \"sistema\" quando deixado em branco." + instructions: "Os webhooks de entrada podem ser usados por sistemas externos para postar mensagens em um canal de chat designado como um usuário de bot por meio do endpoint /hooks/:key . A carga útil consiste em um único parâmetro text , limitado a 2.000 caracteres.

    Também oferecemos suporte limitado a parâmetros text formatados pelo Slack, extraindo links e menções com base no formato em https://api.slack.com/reference/surfaces/formatting, mas /hooks/:key/ O endpoint do slack deve ser usado para isso." + selection: + cancel: "Cancelar" + quote_selection: "Citar no tópico" + copy: "Copiar" + move_selection_to_channel: "Mover para o canal" + error: "Houve um erro ao mover as mensagens de chat" + title: "Mover chat para tópico" + new_topic: + title: "Mover para novo tópico" + instructions: + one: "Você está prestes a criar um novo tópico e preenchê-lo com a mensagem de chat selecionada." + other: "Você está prestes a criar um novo tópico e preenchê-lo com as %{count} mensagens de chat selecionadas." + instructions_channel_archive: "Você está prestes a criar um novo tópico e arquivar as mensagens do canal nele." + existing_topic: + title: "Mover para tópico existente" + instructions: + one: "Escolha o tópico para o qual você gostaria de mover a mensagem de chat." + other: "Escolha o tópico para o qual você gostaria de mover as %{count} mensagens de chat." + instructions_channel_archive: "Escolha o tópico para o qual você gostaria de arquivar as mensagens de canal." + new_message: + title: "Mover para nova mensagem" + instructions: + one: "Você está prestes a criar uma nova mensagem e preenchê-la com a mensagem de chat selecionada." + other: "Você está prestes a criar uma nova mensagem e preenchê-la com as %{count} mensagens de chat selecionadas." + replying_indicator: + single_user: "%{username} está digitando" + multiple_users: "%{commaSeparatedUsernames} e %{lastUsername} estão digitando" + many_users: + one: "%{commaSeparatedUsernames} e mais %{count} estão digitando" + other: "%{commaSeparatedUsernames} e mais %{count} estão digitando" + retention_reminders: + public: "Histórico do canal é mantido por %{days} dias." + dm: "Histórico de conversas pessoais é mantido por %{days} dias." + topic_button_title: "Chat" + flags: + off_topic: "Esta mensagem não é relevante para a discussão atual, conforme definido pelo título do canal, e provavelmente deve ser movida para outro lugar." + inappropriate: "Esta mensagem contém conteúdo que uma pessoa razoável consideraria ofensivo, abusivo ou uma violação de nossas diretrizes da comunidade." + spam: "Esta mensagem é uma propaganda, ou vandalismo. Não é útil ou relevante para o canal atual." + notify_user: "Quero falar com essa pessoa diretamente sobre sua mensagem." + notify_moderators: "Esta mensagem requer atenção da equipe por outro motivo não listado acima." + flagging: + action: "Sinalizar mensagem" + emoji_picker: + favorites: "Usado frequentemente" + smileys_&_emotion: "Smileys e emoções" + objects: "Objetos" + people_&_body: "Pessoas e corpo" + travel_&_places: "Viagens e lugares" + animals_&_nature: "Animais e natureza" + food_&_drink: "Comida e bebida" + activities: "Atividades" + flags: "Sinalizações" + symbols: "Símbolos" + search_placeholder: "Pesquisar por nome de emoji e codinomes..." + no_results: "Nenhum resultado" + draft_channel_screen: + header: "Novas mensagens" + cancel: "Cancelar" + notifications: + chat_invitation: "convidou você para participar de um canal de chat" + chat_invitation_html: "%{username} convidou você para entrar em um canal de chat" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'mencionou você em "%{channel}"' + direct_html: '%{username} convidou você em "%{channel}"' + other_plain: 'mencionou %{identifier} em "%{channel}"' + other_html: '%{username} mencionou %{identifier} em "%{channel}"' + direct_message_chat_mention: + direct: "mencionei você no chat pessoal" + direct_html: "%{username} mencionou você no chat pessoal" + other_plain: "mencionou %{identifier} no chat pessoal" + other_html: "%{username} mencionou %{identifier} no chat pessoal" + chat_message: "Nova mensagem de chat" + chat_quoted: "%{username} citou sua mensagem do chat" + titles: + chat_mention: "Menção no chat" + chat_invitation: "Convite para chat" + chat_quoted: "Chat citado" + action_codes: + chat: + enabled: '%{who} ativou o %{when}' + disabled: "%{who} fechou o chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensagem de chat + fields: + chat_channel_id: + label: ID do canal de chat + message: + label: Mensagem + sender: + label: Remetente + description: Padrões do sistema + review: + transcript: + view: "Ver transcrição de mensagens anteriores" + types: + reviewable_chat_message: + title: "Mensagem de chat sinalizada" + flagged_by: "Sinalizada por" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Mudar de canal" + open_quick_channel_selector: "%{shortcut} Abrir o seletor rápido de canais" + open_insert_link_modal: "%{shortcut} Inserir hiperlink (somente compositor)" + composer_bold: "%{shortcut} Negrito (somente compositor)" + composer_italic: "%{shortcut} Itálico (somente compositor)" + composer_code: "%{shortcut} Código (somente compositor)" + drawer_open: "%{shortcut} Abrir a gaveta do chat" + drawer_close: "%{shortcut} Fechar a gaveta do chat" + topic_statuses: + chat: + help: "O chat está sativado para este tópico" + user: + allow_private_messages: "Permitir que outros usuários me enviem mensagens pessoais e mensagens diretas no chat" + muted_users_instructions: "Suprimir todas as notificações, mensagens pessoais e mensagens diretas no chat desses usuários." + allowed_pm_users_instructions: "Permitir apenas mensagens pessoais ou mensagens diretas no chat desses usuários." + allow_private_messages_from_specific_users: "Permitir apenas que usuários específicos me enviem mensagens pessoais ou mensagens diretas no chat" + ignored_users_instructions: "Suprimir todas as postagens, mensagens, notificações, mensagens pessoais e mensagens diretas no chat desses usuários." + user_menu: + no_chat_notifications_title: "Você ainda não tem notificações no chat" + no_chat_notifications_body: > + Você receberá uma notificação neste painel quando alguém enviar uma mensagem ou @mencionar você no chat. As notificações também serão enviadas para o seu e-mail quando você não fizer login por um tempo.

    Clique no título na parte superior de qualquer canal de chat para configurar quais notificações você recebe nesse canal. Para saber mais, consulte suas preferências de notificação. + tabs: + chat_notifications: "Notificações do chat" + chat_notifications_with_unread: + one: "Notificações do chat - %{count} notificação não lida" + other: "Notificações do chat - %{count} notificações não lidas" diff --git a/plugins/chat/config/locales/client.ro.yml b/plugins/chat/config/locales/client.ro.yml new file mode 100644 index 0000000000..631792d30c --- /dev/null +++ b/plugins/chat/config/locales/client.ro.yml @@ -0,0 +1,111 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: + js: + chat: + create: "Creează" + cancel: "Anulare" + channel_settings: + edit: "Modifică" + add: "Adaugă" + join: "Alătură-te" + leave: "Părăsește" + close: "Închide sondajul" + delete: "Șterge" + muted: "silențios" + joined: "înscris" + email_frequency: + never: "Niciodată" + flag: "Marchează cu marcaj de avertizare" + join: "Alătură-te" + mention_warning: + dismiss: "renunță" + reply: "Răspunde" + edit: "Modifică" + rebake_message: "Reconstruieşte HTML" + bookmark_message: "Semn de carte" + bookmark_message_edit: "Editați marcajul" + save: "Salvare" + sounds: + none: "Nimeni" + exit: "înapoi" + channel_status: + closed: "Închis" + open: "Deschide sondajul" + browse: + back: "Înapoi" + filter_all: Toate + filter_closed: Închis + chat_message_separator: + today: Astăzi + yesterday: Ieri + about_view: + title: Titlu + description: Descriere + channel_info: + back_to_channel: "Înapoi" + tabs: + about: Despre + members: Membrii + settings: Opțiuni + direct_message_creator: + title: Mesaj nou + prefix: "Către:" + create_channel: + type: "Tip" + types: + category: "Categorie" + topic: "Discuție" + composer: + italic_text: "text italic" + bold_text: "text aldin" + notification_levels: + never: "Niciodată" + settings: + follow: "Alătură-te" + followed: "Înscris" + notifications: "Notificări" + preview: "Previzualizează" + save: "Salvare" + saved: "Salvat" + unfollow: "Părăsește" + incoming_webhooks: + back: "Înapoi" + description: "Descriere" + delete: "Șterge" + emoji: "Emoji" + name: "Nume" + save: "Salvare" + edit: "Modifică" + system: "sistem" + url: "URL" + username: "Nume utilizator" + selection: + cancel: "Anulare" + copy: "Copiază" + new_topic: + title: "Mutare în subiect nou." + existing_topic: + title: "Mută în subiect deja existent" + emoji_picker: + objects: "Obiecte" + flags: "Marcaje de avertizare" + draft_channel_screen: + header: "Mesaj nou" + cancel: "Anulare" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Mesaj + review: + types: + reviewable_chat_message: + flagged_by: "Semnalat de" diff --git a/plugins/chat/config/locales/client.ru.yml b/plugins/chat/config/locales/client.ru.yml new file mode 100644 index 0000000000..1e48e9caa9 --- /dev/null +++ b/plugins/chat/config/locales/client.ru.yml @@ -0,0 +1,474 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Изменение статуса канала чата" + chat_channel_delete: "Удаление канала чата" + api: + scopes: + descriptions: + chat: + create_message: "Создать сообщение чата в указанном канале." + about: + chat_messages_count: "Сообщения чата" + chat_channels_count: "Каналы чата" + chat_users_count: "Пользователи чата" + chat: + dates: + time_tiny: "ч:мм" + all_loaded: "Отобразить все сообщения" + already_enabled: "Чат в этой теме уже включён . Пожалуйста, обновите страницу." + disabled_for_topic: "Чат в этой теме отключён." + bot: "Бот" + create: "Создать" + cancel: "Отмена" + cancel_reply: "Отменить ответ" + chat_channels: "Каналы" + browse_all_channels: "Просмотреть все каналы" + move_to_channel: + title: "Переместить сообщения в канал" + instructions: + one: "Вы перемещаете %{count} сообщение. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что это сообщение было перемещено." + few: "Вы перемещаете %{count} сообщения. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + many: "Вы перемещаете %{count} сообщений. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + other: "Вы перемещаете %{count} сообщений. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + confirm_move: "Переместить сообщения" + channel_settings: + title: "Настройки канала" + edit: "Изменить" + add: "Добавить" + close_channel: "Закрыть канал" + open_channel: "Открыть канал" + archive_channel: "Архивировать канал" + delete_channel: "Удалить канал" + join_channel: "Подписаться на канал" + leave_channel: "Покинуть канал" + join: "Подписаться" + leave: "Отписаться" + channel_archive: + title: "Архивировать канал" + instructions: "

    Архивация канала переводит его в режим только для чтения и перемещает все сообщения из канала в новую или существующую тему. В этом режиме нельзя отправлять новые сообщения, а существующие сообщения нельзя редактировать или удалять.

    Вы действительно хотите заархивировать канал %{channelTitle}?

    " + process_started: "Процесс архивации запущен. Это окно закроется в ближайшее время, и вы получите личное сообщение, когда процесс архивации будет завершён." + retry: "Повторить" + channel_open: + title: "Открыть канал" + instructions: "Открытие канала; все пользователи смогут отправлять и редактировать свои сообщения." + channel_close: + title: "Закрыть канал" + instructions: "Закрытие канала; запрет пользователям, не являющихся сотрудниками, отправлять новые или редактировать существующие сообщения. Вы действительно хотите закрыть этот канал?" + channel_delete: + title: "Удалить канал" + instructions: "

    Удаление канала %{name} и истории чата. Все сообщения и связанные с ними данные, такие как эмодзи и загрузки, будут безвозвратно удалены. Если вы не хотите использовать канал, при этом сохранив его историю, вы можете его заархивировать.

    Вы действительно хотите навсегда удалить канал? Для подтверждения введите название канала в расположенное ниже поле.

    " + confirm: "Я понимаю последствия, удалить канал" + confirm_channel_name: "Введите название канала" + process_started: "Процесс удаления канала запущен. Это окно закроется в ближайшее время, вы больше не увидите удалённый канал." + channels_list_popup: + browse: "Просмотр каналов" + create: "Новый канал" + click_to_join: "Нажмите здесь для просмотра доступных каналов." + close: "Закрыть" + collapse: "Свернуть чат" + confirm_flag: "Вы действительно хотите пожаловаться на сообщение пользователя %{username}?" + deleted: "Сообщение было удалено. [view]" + hidden: "Сообщение было скрыто. [view]" + delete: "Удалить" + edited: "Отредактировано" + muted: "Отключённый" + joined: "подписан" + empty_state: + direct_message_cta: "Начать личный чат" + direct_message: "Вы также можете начать личный чат с одним или несколькими пользователями." + title: "Каналы не обнаружены" + email_frequency: + description: "Мы отправим вам письмо только в том случае, если вы не были онлайн последние 15 минут." + never: "Никогда" + title: "Настройка почтовых уведомлений" + when_away: "Если вы находитесь офлайн" + enable: "Включить чат" + flag: "Флаг" + emoji: "Вставить эмодзи" + flagged: "Это сообщение было отправлено на премодерацию" + invalid_access: "У вас нет доступа для просмотра этого канала" + invitation_notification: "Пользователь %{username} пригласил вас присоединиться к каналу" + in_reply_to: "В ответ на" + heading: "Чат" + join: "Подписаться" + new_messages: "Новые сообщения" + mention_warning: + cannot_see: + one: "Пользователь %{usernames} не может получить доступ к этому каналу и не был уведомлён." + few: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлены." + many: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлёны." + other: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлены." + dismiss: "Отклонить" + invitations_sent: + one: "Приглашение отправлено" + few: "Приглашения отправлены" + many: "Приглашений отправлены" + other: "Приглашений отправлены" + invite: "Пригласить в канал" + without_membership: + one: "Пользователь %{usernames} не присоединился к этому каналу." + few: "Пользователи %{usernames} не присоединились к этому каналу." + many: "Пользователи %{usernames} не присоединились к этому каналу." + other: "Пользователи %{usernames} не присоединились к этому каналу." + aria_roles: + header: "Заголовок чата" + composer: "Редактор чата" + channels_list: "Список каналов чата" + no_public_channels: "Вы не присоединились ни к одному каналу." + only_chat_push_notifications: + title: "Отправлять только push-уведомления в чате" + description: "Запретить отправку всех push-уведомлений, не связанных с чатом." + ignore_channel_wide_mention: + title: "Игнорировать на канале массовые упоминания" + description: "Не отправлять массовые уведомления при использовании на канале переменных @here и @all." + open: "Открыть чат" + open_full_page: "Открыть полноэкранный чат" + close_full_page: "Закрыть полноэкранный чат" + open_message: "Открыть сообщение в чате" + placeholder_self: "Напишите что-нибудь" + placeholder_others: "Чат с %{messageRecipient}" + placeholder_new_message_disallowed: "Канал %{status}, в данный момент вы не можете отправлять новые сообщения." + placeholder_silenced: "В настоящее время вы не можете отправлять сообщения." + placeholder_start_conversation: Начать беседу с пользователем %{usernames} + remove_upload: "Удалить файл" + react: "Реакция с помощью эмодзи" + reply: "Ответить" + edit: "Изменить" + copy_link: "Копировать ссылку" + rebake_message: "Перестроить HTML" + retry_staged_message: + title: "Ошибка сети" + action: "Отправить ещё раз?" + unreliable_network: "Сеть ненадёжна, отправка сообщений и сохранение черновиков могут не работать" + bookmark_message: "Закладка" + bookmark_message_edit: "Редактировать закладку" + restore: "Восстановить удаленное сообщение" + save: "Сохранить" + select: "Выбрать" + silence: "Заблокировать пользователя" + return_to_list: "Вернуться к списку каналов" + scroll_to_bottom: "Прокрутка вниз" + scroll_to_new_messages: "Новые сообщения" + sound: + title: "Звук уведомления" + sounds: + none: "Нет" + bell: "Колокольчик" + ding: "Звонок" + title: "чат" + title_capitalized: "Чат" + upload: "Прикрепить файл" + uploaded_files: + one: "%{count} файл" + few: "%{count} файла" + many: "%{count} файла" + other: "%{count} файла" + you_flagged: "Вы пожаловались на это сообщение" + exit: "Назад" + channel_status: + read_only_header: "Канал только для чтения" + read_only: "Только для чтения" + archived_header: "Канал заархивирован" + archived: "Архивные" + archive_failed: "Во время архивации поизошла ошибка. %{completed}/%{total} сообщений были заархивированы в целевой теме. Нажмите 'Повторить', чтобы попытаться завершить архивирование." + archive_completed: "См. архивную тему." + closed_header: "Канал закрыт" + closed: "Закрытые" + open_header: "Канал открыт" + open: "Открыт" + browse: + title: Каналы + filter_all: Все + filter_open: Открытые + filter_closed: Закрытые + filter_archived: Архивные + filter_input_placeholder: Поиск канала по названию + chat_message_separator: + today: Сегодня + yesterday: Вчера + members_view: + filter_placeholder: Найти участников + about_view: + associated_topic: Связанная тема + associated_category: Связанный раздел + title: Название + description: Описание + channel_info: + back_to_all_channels: "Все каналы" + back_to_channel: "Назад" + tabs: + about: Информация о канале + members: Участники + settings: Настройки + channel_edit_title_modal: + title: Изменить название + input_placeholder: Добавьте название + description: Дайте короткое описательное название вашему каналу + channel_edit_description_modal: + title: Изменить описание + input_placeholder: Добавить описание + description: Расскажите, о чем этот канал + direct_message_creator: + title: Новое сообщение + prefix: "Кому:" + no_results: Нет результатов + selected_user_title: "Отменить выбор пользователя %{username}" + channel_selector: + title: "Перейти на канал" + no_channels: "Нет каналов, соответствующих вашему запросу" + channel: + no_memberships: На этом канале нет участников + no_memberships_found: Участники не найдены + memberships_count: + one: "%{count} участник" + few: "%{count} участника" + many: "%{count} участников" + other: "%{count} участников" + create_channel: + auto_join_users: + public_category_warning: "Раздел %{category} является общедоступным. Автоматически добавлять в этот канал всех активных пользователей?" + warning_groups: + one: Автоматически добавить %{members_count} пользователя из группы %{group}? + few: Автоматически добавить %{members_count} пользователей из группы %{group} и группы %{group_2}? + many: Автоматически добавить %{members_count} пользователей из группы %{group} и группы %{group_2}? + other: Автоматически добавить %{members_count} пользователей из группы %{group} и группы %{group_2}? + warning_multiple_groups: Автоматически добавить %{members_count} пользователей из группы %{group_1} и ещё из %{count} групп? + choose_category: + label: "Выберите раздел" + none: "выберите раздел..." + default_hint: Управляйте доступом к разделу через %{category}настройки безопасности + hint_groups: + one: Пользователи группы %{hint} будут иметь доступ к этому каналу в соответствии с настройками безопасности + few: Пользователи групп %{hint} и %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + many: Пользователи групп %{hint} и %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + other: Пользователи групп %{hint} и %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + hint_multiple_groups: Пользователи группы %{hint_1} и ещё %{count} групп будут иметь доступ к этому каналу в соответствии с настройками безопасности + create: "Создать канал" + description: "Описание (необязательно)" + name: "Название канала" + title: "Новый канал" + type: "Тип" + types: + category: "Раздел" + topic: "Тема" + reviewable: + type: "Сообщение чата" + reactions: + only_you: "Вы отреагировали при помощи эмодзи :%{emoji}:" + and_others: "Вы, %{usernames}, отреагировали при помощи эмодзи :%{emoji}:" + only_others: "Пользователи %{usernames} отреагировали при помощи эмодзи %{emoji}:" + others_and_more: "Пользователи %{usernames} и %{more} отреагировали при помощи эмодзи %{emoji}:" + you_others_and_more: "Вы, %{usernames} и %{more}, отреагировали при помощи эмодзи %{emoji}:" + composer: + toggle_toolbar: "Переключить панель инструментов" + italic_text: "Курсив" + bold_text: "Жирный" + code_text: "Код" + quote: + original_channel: 'Первоначально отправлено в %{channel}' + copy_success: "Цитата из чата скопирована в буфер обмена" + notification_levels: + never: "Никогда" + mention: "Только для упоминаний" + always: "Для всех действий" + settings: + enable_auto_join_users: "Автоматически добавлять всех активных пользователей" + disable_auto_join_users: "Остановить автоматическое добавление пользователей" + auto_join_users_warning: "Любой пользователь, не являющийся участником этого канала, но имеющий доступ к разделу %{category} , будет автоматически подключён. Продолжить?" + desktop_notification_level: "Уведомления на рабочем столе" + follow: "Подписаться" + followed: "Подписан" + mobile_notification_level: "Мобильные push-уведомления" + mute: "Отключить канал" + muted_on: "Включено" + muted_off: "Выключено" + notifications: "Уведомления" + preview: "Предпросмотр" + save: "Сохранить" + saved: "Сохранено" + unfollow: "Отписаться" + admin: + title: "Чат" + direct_messages: + title: "Личный чат" + new: "Новый личный чат" + create: "Создать" + leave: "Выйти из личного чата" + cannot_create: "К сожалению, вы не можете отправлять прямые сообщения." + incoming_webhooks: + back: "Назад" + channel_placeholder: "Выберите канал" + confirm_destroy: "Вы действительно хотите удалить этот входящий вебхук? Это действие не может быть отменено." + current_emoji: "Текущие эмодзи" + description: "Описание" + delete: "Удалить" + emoji: "Эмодзи" + emoji_instructions: "Если оставить эмодзи пустым, будет использоваться системный аватар." + name: "Наименование" + name_placeholder: "наименование..." + new: "Новый входящий вебхук" + none: "Входящие вебхуки не созданы." + no_emoji: "Эмодзи не выбрано" + post_to: "Отправить в" + reset_emoji: "Сброс эмодзи" + save: "Сохранить" + edit: "Изменить" + select_emoji: "Выберите эмодзи" + system: "Системный" + title: "Входящие вебхуки" + url: "URL" + url_instructions: "Этот URL содержит секретное значение — берегите его." + username: "Имя пользователя" + username_instructions: "Имя бота, отправляющего сообщения на канал. Если оставить поле пустым, по умолчанию используется 'Системный'." + instructions: "Входящие вебхуки могут использоваться внешними системами для отправки сообщений в назначенный канал чата в качестве пользователя-бота через конечную точку /hooks/:key. Полезная нагрузка состоит из одного параметра text, который ограничен 2000 символами.

    Мы также поддерживаем ограниченные Slack-форматированные текстовые параметры, извлекая ссылки и упоминания на основе формата https://api.slack.com/reference/surfaces/formatting, но для этого необходимо использовать конечную точку /hooks/:key/slack." + selection: + cancel: "Отмена" + quote_selection: "Цитировать в теме" + copy: "Копировать" + move_selection_to_channel: "Переместить в канал" + error: "При перемещении сообщений чата произошла ошибка" + title: "Переместить чат в тему" + new_topic: + title: "Переместить в новую тему" + instructions: + one: "Вы собираетесь создать новую тему и заполнить её сообщением, которое вы выбрали." + few: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + many: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + other: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + instructions_channel_archive: "Вы собираетесь создать новую тему и заархивировать в неё сообщения канала." + existing_topic: + title: "Переместить в существующую тему" + instructions: + one: "Пожалуйста, выберите тему, в которую вы хотите переместить это сообщение." + few: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщения." + many: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщений." + other: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщений." + instructions_channel_archive: "Пожалуйста, выберите тему, в которую вы хотите заархивировать сообщения канала." + new_message: + title: "Переместить в новое сообщение" + instructions: + one: "Вы собираетесь создать новое сообщение и заполнить её сообщением чата, которое вы выбрали." + few: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + many: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + other: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + replying_indicator: + single_user: "%{username} печатает" + multiple_users: "%{commaSeparatedUsernames} и %{lastUsername} печатают" + many_users: + one: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователь" + few: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователя" + many: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" + other: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" + retention_reminders: + public: "История канала хранится %{days} дней." + dm: "История личного чата хранится %{days} дней." + topic_button_title: "Чат" + flags: + off_topic: "Это сообщение не имеет отношения к текущему обсуждению, как указано в названии канала, и, вероятно, его следует переместить в другое место." + inappropriate: "Это сообщение содержит контент, который разумный человек счёл бы недопустимым, оскорбительным или нарушающим основные принципы нашего сообщества." + spam: "Это сообщение является рекламой. Оно не несёт полезной нагрузки или не имеет отношения к текущему каналу." + notify_user: "Я хочу поговорить с этим человеком напрямую и обсудить его сообщение." + notify_moderators: "Это сообщение требует внимания модератора по причине, не указанной выше." + flagging: + action: "Пожаловаться на сообщение" + emoji_picker: + favorites: "Часто используемые" + smileys_&_emotion: "Смайлики и эмоции" + objects: "Объекты" + people_&_body: "Люди и части тел" + travel_&_places: "Путешествия и места" + animals_&_nature: "Животные и природа" + activities: "Деятельность" + flags: "Флаги" + symbols: "Символы" + search_placeholder: "Поиск по названию эмодзи и псевдониму..." + no_results: "Нет результатов" + draft_channel_screen: + header: "Новое сообщение" + cancel: "Отмена" + notifications: + chat_invitation: "пригласил вас присоединиться к каналу чата" + chat_invitation_html: "Пользователь %{username} пригласил вас присоединиться к каналу" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'упомянул вас в "%{channel}"' + direct_html: 'Пользователь %{username} упомянул вас на канале "%{channel}"' + other_plain: 'упомянул %{identifier} в канале "%{channel}"' + other_html: 'Пользователь %{username} упомянул %{identifier} на канале "%{channel}"' + direct_message_chat_mention: + direct: "упомянул вас в личном чате" + direct_html: "Пользователь %{username} упомянул вас в личном чате" + other_plain: "упомянул %{identifier} в личном чате" + other_html: "Пользователь %{username} упомянул @%{identifier} в личном чате" + chat_message: "Новое сообщение в чате" + chat_quoted: "Пользователь %{username} процитировал ваше сообщение в чате" + titles: + chat_mention: "Упоминание в чате" + chat_invitation: "Приглашение в чат" + chat_quoted: "Цитирование чата" + action_codes: + chat: + enabled: '%{who} включил %{when}' + disabled: "%{who} закрыл чат %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Отправить сообщение в чат + fields: + chat_channel_id: + label: ID канала чата + message: + label: Сообщение + sender: + label: Отправитель + description: Системные значения по умолчанию + review: + transcript: + view: "Просмотр предыдущих сообщений" + types: + reviewable_chat_message: + title: "Сообщение на премодерации" + flagged_by: "Жалоба от" + keyboard_shortcuts_help: + chat: + title: "Чат" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Переключить канал" + open_quick_channel_selector: "%{shortcut} Открыть переключатель каналов" + open_insert_link_modal: "%{shortcut} Вставить гиперссылку (только в редакторе)" + composer_bold: "%{shortcut} Жирный (только в редакторе)" + composer_italic: "%{shortcut} Курсив (только в редакторе)" + composer_code: "%{shortcut} Код (только в редакторе)" + drawer_open: "%{shortcut} Открыть панель чата" + drawer_close: "%{shortcut} Закрыть панель чата" + topic_statuses: + chat: + help: "Чат включён для этой темы" + user: + allow_private_messages: "Разрешить другим пользователям отправлять мне личные сообщения и прямые сообщения в чате" + muted_users_instructions: "Не показывать уведомления, личные сообщения и прямые сообщения в чате от этих пользователей." + allowed_pm_users_instructions: "Разрешить только личные сообщения или прямые сообщения в чате от этих пользователей." + allow_private_messages_from_specific_users: "Разрешить только определённым пользователям отправлять мне личные сообщения или прямые сообщения в чате" + ignored_users_instructions: "Не показывать сообщения, личные сообщения, уведомления, прямые и личные сообщения чата от этих пользователей." + user_menu: + no_chat_notifications_title: "У вас пока нет уведомлений чата" + no_chat_notifications_body: > + На этой панели появится уведомление, когда кто-то напишет вам напрямую или @упомянет вас в чате. Уведомления также будут отправлены на вашу электронную почту, если вы отсутствовали на форуме в течение некоторого времени.

    Кликните заголовок в верхней части любого канала чата, чтобы настроить уведомления, которые вы будете получать в этом канале. Для получения дополнительной информации см. настройки уведомлений. + tabs: + chat_notifications: "Уведомления чата" + chat_notifications_with_unread: + one: "Уведомления чата - %{count} непрочитанное уведомление" + few: "Уведомления чата - %{count} непрочитанных уведомления" + many: "Уведомления чата - %{count} непрочитанных уведомлений" + other: "Уведомления чата - %{count} непрочитанных уведомлений" diff --git a/plugins/chat/config/locales/client.sk.yml b/plugins/chat/config/locales/client.sk.yml new file mode 100644 index 0000000000..8334d004da --- /dev/null +++ b/plugins/chat/config/locales/client.sk.yml @@ -0,0 +1,104 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: + js: + chat: + create: "Vytvoriť" + cancel: "Zrušiť" + channel_settings: + edit: "Upraviť" + add: "Pridať" + join: "Pridať sa" + leave: "Opustiť" + close: "Zavrieť" + delete: "Odstrániť" + muted: "ignorovaní" + joined: "vytvorený" + email_frequency: + never: "Nikdy" + flag: "Označenie" + join: "Pridať sa" + mention_warning: + dismiss: "zahodiť" + reply: "Odpoveď" + edit: "Upraviť" + rebake_message: "Pregenerovať HTML" + bookmark_message: "Záložka" + save: "Uložiť" + sounds: + none: "Žiadny" + exit: "späť" + channel_status: + closed: "Zatvorené" + open: "Zahájiť" + browse: + back: "Späť" + filter_all: Všetky + filter_closed: Zatvorené + chat_message_separator: + today: Dnes + yesterday: Včera + about_view: + title: Názov + description: Popis + channel_info: + back_to_channel: "Späť" + tabs: + about: O stránke + members: Členovia + settings: Nastavenia + direct_message_creator: + title: Nová správa + prefix: "Komu:" + create_channel: + type: "Typ" + types: + category: "Kategória" + topic: "Témy" + composer: + italic_text: "zdôraznený text" + bold_text: "výrazný text" + notification_levels: + never: "Nikdy" + settings: + follow: "Pridať sa" + followed: "Vytvorený" + notifications: "Upozornenia" + preview: "Ukážka" + save: "Uložiť" + saved: "Uložené" + unfollow: "Opustiť" + incoming_webhooks: + back: "Späť" + description: "Popis" + delete: "Odstrániť" + emoji: "Emoji" + name: "Meno" + save: "Uložiť" + edit: "Upraviť" + system: "systém" + url: "URL" + username: "Používateľské meno" + selection: + cancel: "Zrušiť" + copy: "Kopírovať" + new_topic: + title: "Presuň na novú tému" + existing_topic: + title: "Presuň do existujúcej témy." + emoji_picker: + objects: "Objekty" + flags: "Označenia" + draft_channel_screen: + header: "Nová správa" + cancel: "Zrušiť" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Správa diff --git a/plugins/chat/config/locales/client.sl.yml b/plugins/chat/config/locales/client.sl.yml new file mode 100644 index 0000000000..1f9ced9dba --- /dev/null +++ b/plugins/chat/config/locales/client.sl.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: + js: + chat: + create: "Ustvari" + cancel: "Prekliči" + channel_settings: + edit: "Uredi" + add: "Dodaj" + join: "Pridruži se" + leave: "Zapusti" + close: "Zapri" + delete: "Izbriši" + edited: "urejen" + muted: "utišani" + joined: "pridružen" + email_frequency: + never: "Nikoli" + flag: "Prijavi" + join: "Pridruži se" + mention_warning: + dismiss: "opusti" + reply: "Odgovori" + edit: "Uredi" + rebake_message: "Obnovi HTML" + bookmark_message: "Zaznamek" + save: "Shrani" + sounds: + none: "Brez" + exit: "nazaj" + channel_status: + closed: "Zaprto" + open: "Odpri" + browse: + back: "Nazaj" + filter_all: Vse + filter_closed: Zaprto + chat_message_separator: + today: Danes + yesterday: Včeraj + about_view: + title: Naziv + description: Opis + channel_info: + back_to_channel: "Nazaj" + tabs: + about: O nas + members: Člani + settings: Nastavitve + direct_message_creator: + title: Novo zasebno sporočilo + prefix: "Do:" + create_channel: + type: "Tip" + types: + category: "Kategorija" + topic: "Tema" + composer: + italic_text: "poudarjeno" + bold_text: "krepko" + notification_levels: + never: "Nikoli" + settings: + follow: "Pridruži se" + followed: "Pridružen" + notifications: "Obvestila" + preview: "Predogled" + save: "Shrani" + saved: "Shranjeno" + unfollow: "Zapusti" + incoming_webhooks: + back: "Nazaj" + description: "Opis" + delete: "Izbriši" + emoji: "Emoji" + name: "Ime" + save: "Shrani" + edit: "Uredi" + system: "sistem" + url: "URL" + username: "Uporabniško ime" + selection: + cancel: "Prekliči" + copy: "Kopiraj" + new_topic: + title: "Prestavi v novo temo" + existing_topic: + title: "Prestavi v obstoječo temo" + new_message: + title: "Prestavi v novo ZS" + emoji_picker: + objects: "Stvari" + activities: "Dejavnosti" + flags: "Zastave" + symbols: "Simboli" + draft_channel_screen: + header: "Novo zasebno sporočilo" + cancel: "Prekliči" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Opozorilo + review: + types: + reviewable_chat_message: + flagged_by: "Prijavljen od" diff --git a/plugins/chat/config/locales/client.sq.yml b/plugins/chat/config/locales/client.sq.yml new file mode 100644 index 0000000000..9bd366f3be --- /dev/null +++ b/plugins/chat/config/locales/client.sq.yml @@ -0,0 +1,95 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: + js: + chat: + cancel: "Anulo" + channel_settings: + edit: "Redakto" + add: "Shto" + close: "Mbyll" + delete: "Fshij" + muted: "të heshtur" + joined: "anëtarësuar" + email_frequency: + never: "Asnjëherë" + flag: "Sinjalizoni" + mention_warning: + dismiss: "hiqe" + reply: "Përgjigju" + edit: "Redakto" + rebake_message: "Rindërtoni HTML" + bookmark_message: "Shto tek të preferuarat" + save: "Ruaj" + sounds: + none: "Asnjë" + exit: "kthehu mbrapa" + channel_status: + open: "Fillo" + browse: + back: "Kthehu mbrapa" + filter_all: Të Gjithë + chat_message_separator: + today: Sot + yesterday: Dje + about_view: + title: Titulli + description: Përshkrimi + channel_info: + back_to_channel: "Kthehu mbrapa" + tabs: + about: Rreth + members: Anëtarë + settings: Rregullimet + direct_message_creator: + title: Mesazh i ri + prefix: "Për:" + create_channel: + type: "Lloji" + types: + category: "Kategori" + topic: "Topic" + composer: + italic_text: "tekst i theksuar" + bold_text: "tekst i trashë" + notification_levels: + never: "Asnjëherë" + settings: + followed: "Anëtarësuar" + notifications: "Njoftimet" + preview: "Parashikimi" + save: "Ruaj" + saved: "U ruajt" + incoming_webhooks: + back: "Kthehu mbrapa" + description: "Përshkrimi" + delete: "Fshije" + emoji: "Emoji" + name: "Emri juaj" + save: "Ruaj" + edit: "Redakto" + system: "sistem" + url: "URL" + username: "Emri i përdoruesit" + selection: + cancel: "Anulo" + copy: "Kopjo" + new_topic: + title: "Ktheje në një temë të re" + existing_topic: + title: "Transfero tek një Temë tjetër" + emoji_picker: + flags: "Sinjalizime" + draft_channel_screen: + header: "Mesazh i ri" + cancel: "Anulo" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Mesazh diff --git a/plugins/chat/config/locales/client.sr.yml b/plugins/chat/config/locales/client.sr.yml new file mode 100644 index 0000000000..6659cccd58 --- /dev/null +++ b/plugins/chat/config/locales/client.sr.yml @@ -0,0 +1,96 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: + js: + chat: + dates: + time_tiny: "h:mm" + cancel: "Odustani" + channel_settings: + edit: "Izmeni" + add: "Dodaj" + close: "Zatvori" + delete: "Obriši" + muted: "utišano" + joined: "pridružen" + email_frequency: + never: "Nikad" + flag: "Označi Zastavom" + mention_warning: + dismiss: "одбаци" + reply: "Odgovori" + edit: "Izmeni" + rebake_message: "Popravi HTML" + bookmark_message: "Markiraj" + bookmark_message_edit: "Uredi marker" + save: "Sačuvaj" + sounds: + none: "Ništa" + exit: "nazad" + channel_status: + open: "Otvori" + browse: + back: "Nazad" + filter_all: sve + chat_message_separator: + today: Danas + yesterday: Juče + about_view: + title: Naslov + description: Opis + channel_info: + back_to_channel: "Nazad" + tabs: + about: O nama + members: Članovi + settings: Podešavanja + direct_message_creator: + title: Nova privatna poruka + create_channel: + type: "Tip" + types: + category: "Kategorija" + topic: "Tema" + composer: + italic_text: "italic tekst" + bold_text: "boldovan tekst" + notification_levels: + never: "Nikad" + settings: + followed: "Pridružio" + notifications: "Obaveštenja" + save: "Sačuvaj" + saved: "Sačuvano" + incoming_webhooks: + back: "Nazad" + description: "Opis" + delete: "Obriši" + emoji: "Emoji" + name: "Ime foruma" + save: "Sačuvaj" + edit: "Izmeni" + system: "sistem" + url: "URL" + username: "Korisničko Ime" + selection: + cancel: "Odustani" + copy: "Kopija" + new_topic: + title: "Prebaci u Novu Temu" + existing_topic: + title: "Prebaci u Postojeću Temu" + emoji_picker: + flags: "Zastave" + draft_channel_screen: + header: "Nova privatna poruka" + cancel: "Odustani" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Privatna poruka diff --git a/plugins/chat/config/locales/client.sv.yml b/plugins/chat/config/locales/client.sv.yml new file mode 100644 index 0000000000..f0f030f5bf --- /dev/null +++ b/plugins/chat/config/locales/client.sv.yml @@ -0,0 +1,450 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Chattkanalens status har ändrats" + chat_channel_delete: "Chattkanal raderad" + api: + scopes: + descriptions: + chat: + create_message: "Skapa ett chattmeddelande i en angiven kanal." + about: + chat_messages_count: "Chattmeddelanden" + chat_channels_count: "Chattkanaler" + chat_users_count: "Chattanvändare" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Visar alla meddelanden" + already_enabled: "Chatt är redan aktiverat för detta ämne. Vänligen uppdatera." + disabled_for_topic: "Chatt är inaktiverat för detta ämne." + bot: "bot" + create: "Skapa" + cancel: "Avbryt" + cancel_reply: "Avbryt svar" + chat_channels: "Kanaler" + browse_all_channels: "Bläddra bland alla kanaler" + move_to_channel: + title: "Flytta meddelanden till kanal" + instructions: + one: "Du flyttar %{count} meddelande. Välj en destinationskanal. Ett platshållarmeddelande kommer att skapas i kanalen %{channelTitle} för att indikera att detta meddelande har flyttats." + other: "Du flyttar %{count} meddelanden. Välj en destinationskanal. Ett platshållarmeddelande kommer att skapas i kanalen %{channelTitle} för att indikera att dessa meddelanden har flyttats." + confirm_move: "Flytta meddelanden" + channel_settings: + title: "Kanalinställningar" + edit: "Redigera" + add: "Lägg till" + close_channel: "Stäng kanal" + open_channel: "Öppna kanal" + archive_channel: "Arkivera kanal" + delete_channel: "Radera kanal" + join_channel: "Gå med i kanal" + leave_channel: "Lämna kanal" + join: "Gå med" + leave: "Lämna" + channel_archive: + title: "Arkivera kanal" + instructions: "

    Arkivering av en kanal sätter den i skrivskyddat läge och flyttar alla meddelanden från kanalen till ett nytt eller existerande ämne. Inga nya meddelanden kan skickas och inga befintliga meddelanden kan redigeras eller raderas.

    Är du säker på att du vill arkivera kanalen %{channelTitle}?

    " + process_started: "Arkiveringsprocessen har påbörjats. Denna modal kommer snart att stängas och du får ett personligt meddelande när arkiveringen är klar." + retry: "Försök igen" + channel_open: + title: "Öppna kanal" + instructions: "Öppnar kanalen igen, alla användare kommer att kunna skicka meddelanden och redigera sina befintliga meddelanden." + channel_close: + title: "Stäng kanal" + instructions: "Genom att stänga kanalen hindras användare som inte är personal att skicka nya meddelanden eller redigera befintliga meddelanden. Är du säker på att du vill stänga denna kanal?" + channel_delete: + title: "Radera kanal" + instructions: "

    Tar bort %{name} kanalen och chatthistoriken. Alla meddelanden och relaterad data, såsom reaktioner och uppladdningar, kommer att raderas permanent. Om du vill bevara kanalhistoriken men avveckla den, kanske du vill arkivera kanalen istället.

    Är du säker på att du permanent vill ta bort kanalen? För att bekräfta, skriv in namnet på kanalen i rutan nedan.

    " + confirm: "Jag förstår konsekvenserna, radera kanalen" + confirm_channel_name: "Ange kanalnamn" + process_started: "Processen för att radera kanalen har påbörjats. Denna modal kommer att stängas inom kort och du kommer inte längre att se den raderade kanalen någonstans." + channels_list_popup: + browse: "Bläddra bland kanaler" + create: "Ny kanal" + click_to_join: "Klicka här för att se tillgängliga kanaler." + close: "Stäng" + collapse: "Komprimera chattruta" + confirm_flag: "Är du säker på att du vill flagga %{username}:s meddelande?" + deleted: "Ett meddelande raderades. [view]" + hidden: "Ett meddelande doldes. [view]" + delete: "Radera" + edited: "redigerad" + muted: "tystat" + joined: "gick med" + empty_state: + direct_message_cta: "Starta en personlig chatt" + direct_message: "Du kan också starta en personlig chatt med en eller flera användare." + title: "Inga kanaler hittades" + email_frequency: + description: "Vi skickar bara e-post till dig om vi inte har sett dig under de senaste 15 minuterna." + never: "Aldrig" + title: "E-postaviseringar" + when_away: "Endast när du är borta" + enable: "Aktivera chatt" + flag: "Flagga" + emoji: "Infoga emoji" + flagged: "Detta meddelande har flaggats för granskning" + invalid_access: "Du har inte tillgång till den här chattkanalen" + invitation_notification: "%{username} bjöd in dig att gå med i en chattkanal" + in_reply_to: "Som svar på" + heading: "Chatt" + join: "Gå med" + new_messages: "nya meddelanden" + mention_warning: + cannot_see: + one: "%{usernames} kan inte komma åt den här kanalen och har inte blivit meddelad." + other: "%{usernames} kan inte komma åt den här kanalen och har inte blivit meddelade." + dismiss: "avfärda" + invitations_sent: + one: "Inbjudan skickad" + other: "Inbjudningar skickade" + invite: "Bjud in till kanal" + without_membership: + one: "%{usernames} har inte gått med i den här kanalen." + other: "%{usernames} har inte gått med i den här kanalen." + aria_roles: + header: "Chatthuvud" + composer: "Chattkompositör" + channels_list: "Lista över chattkanaler" + no_public_channels: "Du har inte gått med i några kanaler." + only_chat_push_notifications: + title: "Skicka bara push-meddelanden för chatt" + description: "Blockera alla push-meddelanden som inte är chattmeddelanden från att skickas" + ignore_channel_wide_mention: + title: "Ignorera omnämnanden i hela kanalen" + description: "Skicka inte meddelanden för kanalövergripande omnämnanden (@here och @all)." + open: "Öppna chatt" + open_full_page: "Öppna chatt i helskärmsläge" + close_full_page: "Stäng helskärmschatt" + open_message: "Öppna meddelande i chatten" + placeholder_self: "Gör en anteckning" + placeholder_others: "Chatta med %{messageRecipient}" + placeholder_new_message_disallowed: "Kanalen är %{status}, du kan inte skicka nya meddelanden just nu." + placeholder_silenced: "Du kan inte skicka meddelanden just nu." + placeholder_start_conversation: Starta en konversation med %{usernames} + remove_upload: "Ta bort fil" + react: "Reagera med emoji" + reply: "Svara" + edit: "Redigera" + copy_link: "Kopiera länk" + rebake_message: "Generera HTML" + retry_staged_message: + title: "Nätverksfel" + action: "Skicka igen?" + unreliable_network: "Nätverket är opålitligt, det fungerar kanske inte att skicka meddelanden och spara utkast" + bookmark_message: "Bokmärk" + bookmark_message_edit: "Redigera bokmärke" + restore: "Återställ raderat meddelande" + save: "Spara" + select: "Välj" + silence: "Tysta användare" + return_to_list: "Återgå till listan över kanaler" + scroll_to_bottom: "Scrolla till botten" + scroll_to_new_messages: "Se nya meddelanden" + sound: + title: "Meddelandeljud för chatt via dator" + sounds: + none: "Ingen" + bell: "Bell" + ding: "Ding" + title: "chatt" + title_capitalized: "Chatt" + upload: "Bifoga en fil" + uploaded_files: + one: "%{count} fil" + other: "%{count} filer" + you_flagged: "Du flaggade detta meddelande" + exit: "tillbaka" + channel_status: + read_only_header: "Kanalen är skrivskyddad" + read_only: "Endast läsning" + archived_header: "Kanalen är arkiverad" + archived: "Arkiverad" + archive_failed: "Arkivering av kanalen misslyckades. %{completed}/%{total} meddelanden har arkiverats i destinationsämnet. Tryck på försök igen för att försöka slutföra arkivet." + archive_completed: "Se det arkiverade ämnet" + closed_header: "Kanalen är stängd" + closed: "Stängd" + open_header: "Kanalen är öppen" + open: "Öppna" + browse: + back: "Tillbaka" + title: Kanaler + filter_all: Alla + filter_open: Öppnad + filter_closed: Stängd + filter_archived: Arkiverad + filter_input_placeholder: Sök kanal efter namn + chat_message_separator: + today: Idag + yesterday: Igår + members_view: + filter_placeholder: Hitta medlemmar + about_view: + associated_topic: Länkat ämne + associated_category: Länkad kategori + title: Rubrik + description: Beskrivning + channel_info: + back_to_all_channels: "Alla kanaler" + back_to_channel: "Tillbaka" + tabs: + about: Om + members: Medlemmar + settings: Inställningar + channel_edit_title_modal: + title: Redigera titel + input_placeholder: Lägg till en titel + description: Ge en kort beskrivande titel till din kanal + channel_edit_description_modal: + title: Redigera beskrivning + input_placeholder: Lägg till en beskrivning + description: Berätta för folk vad den här kanalen handlar om + direct_message_creator: + title: Nytt meddelande + prefix: "Till:" + no_results: Inga resultat + selected_user_title: "Avmarkera %{username}" + channel_selector: + title: "Byt till kanal" + no_channels: "Inga kanaler matchar din sökning" + channel: + no_memberships: Denna kanal har inga medlemmar + no_memberships_found: Inga medlemmar hittades + memberships_count: + one: "%{count} medlem" + other: "%{count} medlemmar" + create_channel: + auto_join_users: + public_category_warning: "%{category} är en offentlig kategori. Vill du automatiskt lägga till alla nyligen aktiva användare till den här kanalen?" + warning_groups: + one: Lägg automatiskt till %{members_count} användare från %{group}? + other: Lägg automatiskt till %{members_count} användare från %{group} och %{group_2}? + warning_multiple_groups: Lägg automatiskt till %{members_count} användare från %{group_1} och %{count} andra? + choose_category: + label: "Välj en kategori" + none: "Välj en..." + default_hint: Hantera åtkomst genom att besöka %{category} säkerhetsinställningar + hint_groups: + one: Användare i %{hint} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningar + other: Användare i %{hint} och %{hint_2} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna + hint_multiple_groups: Användare i %{hint_1} och %{count} andra grupper kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna + create: "Skapa kanal" + description: "Beskrivning (valfritt)" + name: "Kanalnamn" + title: "Ny kanal" + type: "Typ" + types: + category: "Kategori" + topic: "Ämne" + reviewable: + type: "Chatt meddelande" + reactions: + only_you: "Du reagerade med :%{emoji}:" + and_others: "Du, %{usernames} reagerade med :%{emoji}:" + only_others: "%{usernames} reagerade med :%{emoji}:" + others_and_more: "%{usernames} och %{more} andra reagerade med :%{emoji}:" + you_others_and_more: "Du, %{usernames} och %{more} andra reagerade med :%{emoji}:" + composer: + toggle_toolbar: "Växla verktygsfält" + italic_text: "kursiv text" + bold_text: "fet text" + code_text: "kod text" + quote: + original_channel: 'Ursprungligen skickad i %{channel}' + copy_success: "Chattcitat kopierat till urklipp" + notification_levels: + never: "Aldrig" + mention: "Endast för omnämnanden" + always: "För all aktivitet" + settings: + enable_auto_join_users: "Lägg automatiskt till alla nyligen aktiva användare" + disable_auto_join_users: "Sluta lägga till användare automatiskt" + auto_join_users_warning: "Varje användare som inte är medlem i den här kanalen och har tillgång till kategorin %{category} kommer att gå med. Är du säker?" + desktop_notification_level: "Skrivbordsaviseringar" + follow: "Gå med" + followed: "Gick med" + mobile_notification_level: "Mobila push-meddelanden" + mute: "Tysta kanal" + muted_on: "På" + muted_off: "Av" + notifications: "Aviseringar" + preview: "Förhandsgranska" + save: "Spara" + saved: "Sparad" + unfollow: "Lämna" + admin: + title: "Chatt" + direct_messages: + title: "Personlig chatt" + new: "Ny personlig chatt" + create: "Kör" + leave: "Lämna den här personliga chatten" + cannot_create: "Tyvärr kan du inte skicka direktmeddelanden." + incoming_webhooks: + back: "Tillbaka" + channel_placeholder: "Välj en kanal" + confirm_destroy: "Är du säker på att du vill ta bort denna inkommande webhook? Detta kan inte ångras." + current_emoji: "Nuvarande emoji" + description: "Beskrivning" + delete: "Ta bort" + emoji: "Emoji" + emoji_instructions: "Systemavatar kommer att användas om emoji lämnas tom." + name: "Namn" + name_placeholder: "namn..." + new: "Ny inkommande webhook" + none: "Inga befintliga inkommande webhooks har skapats." + no_emoji: "Ingen emoji har valts" + post_to: "Posta till" + reset_emoji: "Återställ emoji" + save: "Spara" + edit: "Redigera" + select_emoji: "Välj emoji" + system: "system" + title: "Inkommande webhooks" + url: "URL" + url_instructions: "Den här webbadressen innehåller ett hemligt värde - håll det säkert." + username: "Användarnamn" + username_instructions: "Användarnamn på bot som gör inlägg på kanalen. Standardinställning är 'system' när det lämnas tomt." + instructions: "Inkommande webhooks kan användas av externa system för att skicka meddelanden till en utsedd chattkanal som botanvändare via /hooks/:key -slutpunkten. Försändelsen består av en enda text-parameter, som är begränsad till 2000 tecken.

    Vi stöder också begränsade Slack-formaterade text-parametrar, extraherar länkar och omnämnanden baserat på formatet på https://api.slack.com/reference/surfaces/formatting, men /hooks/:key/ slack-slutpunkten måste användas för detta." + selection: + cancel: "Avbryt" + quote_selection: "Citat i ämne" + copy: "Kopiera" + move_selection_to_channel: "Flytta till kanal" + error: "Det uppstod ett fel när chattmeddelanden skulle flyttas" + title: "Flytta chatt till ämne" + new_topic: + title: "Flytta till nytt ämne" + instructions: + one: "Du håller på att skapa ett nytt ämne och fylla det med chattmeddelandet du har valt." + other: "Du håller på att skapa ett nytt ämne och fylla det med de %{count} chattmeddelanden du har valt." + instructions_channel_archive: "Du håller på att skapa ett nytt ämne och arkivera kanalmeddelandena till det." + existing_topic: + title: "Flytta till befintligt ämne" + instructions: + one: "Välj det ämne du vill flytta chattmeddelandet till." + other: "Välj det ämne du vill flytta dessa %{count} chattmeddelanden till." + instructions_channel_archive: "Välj vilket ämne du vill arkivera kanalmeddelanden till." + new_message: + title: "Flytta till nytt meddelande" + instructions: + one: "Du håller på att skapa ett nytt meddelande och fylla det med chattmeddelandet du har valt." + other: "Du håller på att skapa ett nytt ämne och fylla det med de %{count} chattmeddelanden du har valt." + replying_indicator: + single_user: "%{username} skriver" + multiple_users: "%{commaSeparatedUsernames} och %{lastUsername} skriver" + many_users: + one: "%{commaSeparatedUsernames} och %{count} skriver" + other: "%{commaSeparatedUsernames} och %{count} andra skriver" + retention_reminders: + public: "Kanalhistoriken behålls i %{days} dagar." + dm: "Personlig chatthistorik behålls i %{days} dagar." + topic_button_title: "Chatt" + flags: + off_topic: "Det här meddelandet är inte relevant för den aktuella diskussionen enligt kanaltiteln och bör förmodligen flyttas någon annanstans." + inappropriate: "Detta meddelande innehåller saker som en förnuftig person skulle anse vara stötande, kränkande eller en överträdelse av vårt forums riktlinjer." + spam: "Det här meddelandet är en annons eller vandalism. Det är inte lämpligt eller relevant med avseende på den aktuella kanalen." + notify_user: "Jag vill prata med den här personen direkt och privat om meddelandet." + notify_moderators: "Detta meddelande kräver personalens uppmärksamhet av en annan anledning som inte anges ovan." + flagging: + action: "Flagga meddelande" + emoji_picker: + favorites: "Används ofta" + smileys_&_emotion: "Smileys och emojis" + objects: "Objekt" + people_&_body: "Människor och kropp" + travel_&_places: "Resor och platser" + animals_&_nature: "Djur och natur" + food_&_drink: "Mat och dryck" + activities: "Aktiviteter" + flags: "Flaggor" + symbols: "Symboler" + search_placeholder: "Sök efter emojinamn och alias..." + no_results: "Inga resultat" + draft_channel_screen: + header: "Nytt meddelande" + cancel: "Avbryt" + notifications: + chat_invitation: "bjöd in dig att gå med i en chattkanal" + chat_invitation_html: "%{username} bjöd in dig att gå med i en chattkanal" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'nämnde dig i "%{channel}"' + direct_html: '%{username} nämnde dig i "%{channel}"' + other_plain: 'nämnde %{identifier} i "%{channel}"' + other_html: '%{username} nämnde %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "nämnde dig i personlig chatt" + direct_html: "%{username} nämnde dig i en personlig chatt" + other_plain: "nämnde %{identifier} i personlig chatt" + other_html: "%{username} nämnde %{identifier} i personlig chatt" + chat_message: "Nytt chattmeddelande" + chat_quoted: "%{username} citerade ditt chattmeddelande" + titles: + chat_mention: "Chatt omnämnande" + chat_invitation: "Chattinbjudan" + chat_quoted: "Chatt citerad" + action_codes: + chat: + enabled: '%{who} aktiverade %{when}' + disabled: "%{who} stängde chatten %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Skicka chattmeddelande + fields: + chat_channel_id: + label: Chattkanal-ID + message: + label: Meddelande + sender: + label: Avsändare + description: Standard är system + review: + transcript: + view: "Visa tidigare meddelandens avskrift" + types: + reviewable_chat_message: + title: "Flaggat chattmeddelande" + flagged_by: "Flaggat av" + keyboard_shortcuts_help: + chat: + title: "Chatt" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Byt kanal" + open_quick_channel_selector: "%{shortcut} Öppna snabbval av kanal" + open_insert_link_modal: "%{shortcut} Infoga hyperlänk (endast kompositör)" + composer_bold: "%{shortcut} Fet (endast kompositör)" + composer_italic: "%{shortcut} Kursiv (endast kompositör)" + composer_code: "%{shortcut} Kod (endast kompositör)" + drawer_open: "%{shortcut} Öppna chattmenyn" + drawer_close: "%{shortcut} Stäng chattmenyn" + topic_statuses: + chat: + help: "Chatt är aktiverat för detta ämne" + user: + allow_private_messages: "Tillåt andra användare att skicka mig personliga meddelanden och chattmeddelanden" + muted_users_instructions: "Neka alla meddelanden, personliga meddelanden och direkta chattmeddelanden från dessa användare." + allowed_pm_users_instructions: "Tillåt endast personliga meddelanden eller chattmeddelanden från dessa användare." + allow_private_messages_from_specific_users: "Tillåt endast specifika användare att skicka personliga meddelanden eller chattmeddelanden" + ignored_users_instructions: "Neka alla inlägg, meddelanden, notifikationer, personliga meddelanden eller direkta chattmeddelanden från dessa användare." + user_menu: + no_chat_notifications_title: "Du har inga chattaviseringar än" + no_chat_notifications_body: > + Du kommer att aviseras i den här panelen när någon skickar direktmeddelanden till dig eller @nämner dig i chatten. Aviseringar kommer också att skickas till din e-postadress när du inte har loggat in på ett tag.

    Klicka på titeln överst på valfri chattkanal för att konfigurera vilka aviseringar du får i den kanalen. För mer, se dina aviseringsinställningar. + tabs: + chat_notifications: "Chattaviseringar" + chat_notifications_with_unread: + one: "Chattaviseringar - %{count} oläst avisering" + other: "Chattaviseringar - %{count} olästa aviseringar" diff --git a/plugins/chat/config/locales/client.sw.yml b/plugins/chat/config/locales/client.sw.yml new file mode 100644 index 0000000000..394c238c4d --- /dev/null +++ b/plugins/chat/config/locales/client.sw.yml @@ -0,0 +1,104 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: + js: + chat: + create: "Tengeneza" + cancel: "Ghairi" + channel_settings: + edit: "Hariri" + add: "ongeza" + join: "Jiunge" + leave: "Ondoka" + close: "Funga" + delete: "Futa" + muted: "kunyamazisha" + joined: "alijiunga" + email_frequency: + never: "Kamwe" + flag: "Bendera" + join: "Jiunge" + mention_warning: + dismiss: "ondosha..." + reply: "Jibu" + edit: "Hariri" + rebake_message: "Tengeneza upya HTML" + bookmark_message: "Alamisha" + save: "Hifadhi" + sounds: + none: "Hakuna" + exit: "iliyopita" + channel_status: + closed: "Imefungwa" + open: "Fungua" + browse: + back: "Iliyopita" + filter_all: Vyote + filter_closed: Imefungwa + chat_message_separator: + today: Leo + yesterday: Jana + about_view: + title: Kichwa cha Habari + description: Elezo + channel_info: + back_to_channel: "Iliyopita" + tabs: + about: Kuhusu + members: Wanachama + settings: Mipangilio + direct_message_creator: + title: Ujumbe Mpya + prefix: "Kwenda:" + create_channel: + type: "Aina" + types: + category: "Kikundi" + topic: "Mada" + composer: + italic_text: "Maneno yaliyo tiliwa mkazo" + bold_text: "Maneno yaliyokolezwa" + notification_levels: + never: "Kamwe" + settings: + follow: "Jiunge" + followed: "Alijiunga" + notifications: "Taarifa" + preview: "Kihakiki" + save: "Hifadhi" + saved: "Imehifadhiwa" + unfollow: "Ondoka" + incoming_webhooks: + back: "Iliyopita" + description: "Elezo" + delete: "Futa" + emoji: "Emoji" + name: "Jina" + save: "Hifadhi" + edit: "Hariri" + system: "mfumo" + url: "Anwani ya mtandao" + username: "Jina la mtumiaji" + selection: + cancel: "Ghairi" + copy: "Nakili" + new_topic: + title: "Hamisha kwenda Mada Mpya" + existing_topic: + title: "Hamisha kwenda kwenye Mada Iliyopo" + emoji_picker: + objects: "Vitu" + flags: "Bendera" + draft_channel_screen: + header: "Ujumbe Mpya" + cancel: "Ghairi" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Ujumbe diff --git a/plugins/chat/config/locales/client.te.yml b/plugins/chat/config/locales/client.te.yml new file mode 100644 index 0000000000..37bc30b0b3 --- /dev/null +++ b/plugins/chat/config/locales/client.te.yml @@ -0,0 +1,73 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: + js: + chat: + cancel: "రద్దుచేయి" + channel_settings: + edit: "సవరణ" + add: "కలుపు" + close: "మూసివేయి" + delete: "తొలగించు" + muted: "నిశ్శబ్దం" + joined: "చేరినారు" + flag: "కేతనం" + reply: "జవాబు" + edit: "సవరణ" + rebake_message: "హెచే టీ యం యల్ పునర్నిర్మించు" + bookmark_message: "పేజీక" + save: "భద్రపరుచు" + exit: "వెనుకకు" + browse: + back: "వెనుకకు" + filter_all: అన్ని + chat_message_separator: + today: ఈరోజు + yesterday: నిన్న + about_view: + title: శీర్షిక + description: వివరణ + channel_info: + back_to_channel: "వెనుకకు" + tabs: + about: గురించి + members: సభ్యులు + settings: అమరికలు + create_channel: + type: "రకం" + types: + category: "వర్గం" + topic: "విషయం" + composer: + italic_text: "వాలు పాఠ్యం" + bold_text: "బొద్దు పాఠ్యం" + settings: + followed: "చేరినారు" + notifications: "ప్రకటనలు" + save: "భద్రపరుచు" + incoming_webhooks: + back: "వెనుకకు" + description: "వివరణ" + delete: "తొలగించు" + emoji: "ఇమోజి" + name: "పేరు" + save: "భద్రపరుచు" + edit: "సవరణ" + system: "వ్వవస్థ" + url: "యూఆర్ యల్" + username: "సభ్యనామం" + selection: + cancel: "రద్దుచేయి" + copy: "నకలు" + new_topic: + title: "కొత్త విషయానికి జరుపు" + existing_topic: + title: "ఇప్పటికే ఉన్న విషయానికి జరుపు" + emoji_picker: + flags: "కేతనాలు" + draft_channel_screen: + cancel: "రద్దుచేయి" diff --git a/plugins/chat/config/locales/client.th.yml b/plugins/chat/config/locales/client.th.yml new file mode 100644 index 0000000000..dfc7d4e570 --- /dev/null +++ b/plugins/chat/config/locales/client.th.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: + js: + chat: + create: "สร้าง" + cancel: "ยกเลิก" + channel_settings: + edit: "แก้ไข" + add: "เพิ่ม" + join: "เข้าร่วม" + leave: "ออก" + close: "ปิด" + delete: "ลบ" + edited: "ถูกแก้ไข" + muted: "ปิดการแจ้งเตือน" + joined: "สมัครสมาชิกเมื่อ" + email_frequency: + never: "ไม่เคย" + flag: "ธง" + join: "เข้าร่วม" + mention_warning: + dismiss: "ซ่อน" + reply: "ตอบ" + edit: "แก้ไข" + bookmark_message: "บุ๊คมาร์ค" + bookmark_message_edit: "แก้ไขบุ๊กมาร์ก" + save: "บันทึก" + sounds: + none: "ไม่มี" + exit: "กลับ" + channel_status: + closed: "ปิด" + open: "เปิด" + browse: + back: "กลับ" + filter_all: ทั้งหมด + filter_closed: ปิด + chat_message_separator: + today: วันนี้ + yesterday: เมื่อวาน + about_view: + title: ชื่อเรื่อง + description: รายละเอียด + channel_info: + back_to_channel: "กลับ" + tabs: + about: เกี่ยวกับ + members: สมาชิก + settings: การตั้งค่า + direct_message_creator: + title: สร้างข้อความใหม่ + prefix: "ถึง:" + create_channel: + type: "ชนิด" + types: + category: "หมวดหมู่" + topic: "หัวข้อ" + composer: + italic_text: "ตัวอักษรเอียง" + bold_text: "ตัวอักษรหนา" + notification_levels: + never: "ไม่เคย" + settings: + follow: "เข้าร่วม" + followed: "สมัครสมาชิกเมื่อ" + notifications: "การแจ้งเตือน" + preview: "แสดงตัวอย่าง" + save: "บันทึก" + saved: "บันทึกแล้ว" + unfollow: "ออก" + incoming_webhooks: + back: "กลับ" + description: "รายละเอียด" + delete: "ลบ" + emoji: "Emoji" + name: "ชื่อ" + save: "บันทึก" + edit: "แก้ไข" + system: "ระบบ" + url: "URL" + username: "ชื่อผู้ใช้" + selection: + cancel: "ยกเลิก" + copy: "คัดลอก" + new_topic: + title: "ย้ายไปกระทู้ใหม่" + existing_topic: + title: "ย้ายไปกระทู้ที่มีอยู่แล้ว" + new_message: + title: "ย้ายไปข้อความใหม่" + emoji_picker: + objects: "วัตถุ" + activities: "กิจกรรม" + flags: "ธง" + symbols: "สัญลักษณ์" + draft_channel_screen: + header: "สร้างข้อความใหม่" + cancel: "ยกเลิก" + notifications: + chat_quoted: "%{username}%{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: ข้อความ + review: + types: + reviewable_chat_message: + flagged_by: "ถูกปักธงโดย" diff --git a/plugins/chat/config/locales/client.tr_TR.yml b/plugins/chat/config/locales/client.tr_TR.yml new file mode 100644 index 0000000000..ce1dcfe818 --- /dev/null +++ b/plugins/chat/config/locales/client.tr_TR.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + js: + chat: + create: "Oluştur" + cancel: "İptal et" + channel_settings: + edit: "Düzenle" + add: "Ekle" + join: "Katıl" + leave: "Ayrıl" + close: "Bitir" + delete: "Sil" + edited: "düzenlendi" + muted: "sessiz" + joined: "katılma Tarihi" + email_frequency: + never: "Asla" + flag: "Bayrak Koy" + join: "Katıl" + mention_warning: + dismiss: "kapat" + reply: "Yanıtla" + edit: "Düzenle" + rebake_message: "HTML'i Yeniden Yapılandır" + bookmark_message: "Yer İmi" + bookmark_message_edit: "Yer İmini Düzenle" + save: "Kaydet" + sounds: + none: "Yok" + exit: "geri" + channel_status: + closed: "Kapanmış" + open: "Başlat" + browse: + filter_all: Hepsi + filter_closed: Kapanmış + chat_message_separator: + today: Bugün + yesterday: Dün + about_view: + title: Başlık + description: Açıklama + channel_info: + back_to_channel: "Geri" + tabs: + about: Hakkında + members: Üyeler + settings: Ayarlar + direct_message_creator: + title: Yeni İleti + prefix: "Kime:" + create_channel: + type: "Tür" + types: + category: "Kategori" + topic: "Konu" + composer: + italic_text: "vurgulanan yazı" + bold_text: "güçlü metin" + notification_levels: + never: "Asla" + settings: + follow: "Katıl" + followed: "Katılma Tarihi" + notifications: "Bildirimler" + preview: "Önizleme" + save: "Kaydet" + saved: "Kaydedildi" + unfollow: "Ayrıl" + incoming_webhooks: + back: "Geri" + description: "Açıklama" + delete: "Sil" + emoji: "Emoji" + name: "Ad" + save: "Kaydet" + edit: "Düzenle" + system: "sistem" + url: "URL" + username: "Kullanıcı Adı" + selection: + cancel: "İptal et" + copy: "Kopyala" + new_topic: + title: "Yeni Konuya Geç" + existing_topic: + title: "Var Olan Bir Konuya Taşı" + new_message: + title: "Yeni Mesajlara Taşı" + emoji_picker: + objects: "Nesneler" + activities: "Faaliyetler" + flags: "Bildirilenler" + symbols: "Semboller" + draft_channel_screen: + header: "Yeni İleti" + cancel: "İptal et" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: İleti + review: + types: + reviewable_chat_message: + flagged_by: "Bildiren" diff --git a/plugins/chat/config/locales/client.uk.yml b/plugins/chat/config/locales/client.uk.yml new file mode 100644 index 0000000000..c0b3963211 --- /dev/null +++ b/plugins/chat/config/locales/client.uk.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: + js: + chat: + create: "Створити" + cancel: "Скасувати" + channel_settings: + edit: "Редагувати" + add: "Додати" + join: "Приєднатися" + leave: "Покинути" + close: "Закрити" + delete: "Видалити" + edited: "відредагований" + muted: "ігноровані" + joined: "приєднався(лась)" + email_frequency: + never: "Ніколи" + flag: "Поскаржитися" + join: "Приєднатися" + mention_warning: + dismiss: "відкласти" + reply: "Відповідь" + edit: "Редагувати" + rebake_message: "Перебудувати HTML" + bookmark_message: "Додати закладку" + bookmark_message_edit: "Редагувати закладку" + save: "Зберегти" + sounds: + none: "Немає" + exit: "назад" + channel_status: + closed: "Закриті" + open: "Відкрити" + browse: + back: "Назад" + filter_all: Все + filter_closed: Закриті + chat_message_separator: + today: Сьогодні + yesterday: Вчора + about_view: + title: Назва + description: Опис + channel_info: + back_to_channel: "Назад" + tabs: + about: Про + members: Учасники + settings: Налаштування + direct_message_creator: + title: Нове повідомлення + prefix: "До:" + create_channel: + type: "Тип" + types: + category: "Розділ" + topic: "Тема" + composer: + italic_text: "виділення тексту курсивом" + bold_text: "Сильне виділення тексту" + notification_levels: + never: "Ніколи" + settings: + follow: "Приєднатися" + followed: "Приєднався(лась)" + notifications: "Сповіщення" + preview: "Попередній перегляд" + save: "Зберегти" + saved: "Збережено" + unfollow: "Покинути" + incoming_webhooks: + back: "Назад" + description: "Опис" + delete: "Видалити" + emoji: "Смайли" + name: "Назва" + save: "Зберегти" + edit: "Редагувати" + system: "системні" + url: "Посилання" + username: "Ім’я користувача" + selection: + cancel: "Скасувати" + copy: "Копіювати" + new_topic: + title: "Перенесення до нової теми" + existing_topic: + title: "Перенесення до наявної теми" + new_message: + title: "Перейти до нового повідомленням" + emoji_picker: + objects: "Objects" + activities: "Діяльність" + flags: "Скарги" + symbols: "Атрибутика" + draft_channel_screen: + header: "Нове повідомлення" + cancel: "Скасувати" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Повідомлення + review: + types: + reviewable_chat_message: + flagged_by: "Позначено" diff --git a/plugins/chat/config/locales/client.ur.yml b/plugins/chat/config/locales/client.ur.yml new file mode 100644 index 0000000000..55dc84702b --- /dev/null +++ b/plugins/chat/config/locales/client.ur.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: + js: + chat: + create: "بنائیں" + cancel: "منسوخ" + channel_settings: + edit: "ترمیم کریں" + add: "اضافہ کریں" + join: "شمولیت اختیار کریں" + leave: "چھوڑ دیں" + close: "بند کریں" + delete: "مٹائیں" + edited: "ترمیم کردہ" + muted: "خاموش کِیا ہوا" + joined: "شمولیت اختیار کی" + email_frequency: + never: "کبھی نہیں" + flag: "فلَیگ" + join: "شمولیت اختیار کریں" + mention_warning: + dismiss: "بر خاست کریں" + reply: "جواب" + edit: "ترمیم کریں" + rebake_message: "HTML دوبارہ بِلڈ کریں" + bookmark_message: "بُک مارک" + bookmark_message_edit: "بک مارک میں ترمیم کریں" + save: "محفوظ کریں" + sounds: + none: "کوئی نہیں" + exit: "واپس" + channel_status: + closed: "بند" + open: "کھولیں" + browse: + back: "واپس" + filter_all: تمام + filter_closed: بند + chat_message_separator: + today: آج + yesterday: کَل + about_view: + title: عنوان + description: تفصیل + channel_info: + back_to_channel: "واپس" + tabs: + about: بارے میں + members: ممبران + settings: ترتیبات + direct_message_creator: + title: نیا پیغام + prefix: "کے لئے:" + create_channel: + type: "قِسم" + types: + category: "زمرہ" + topic: "ٹاپک" + composer: + italic_text: "زور دیا گیا ٹَیکسٹ" + bold_text: "گہرا ٹَیکسٹ" + notification_levels: + never: "کبھی نہیں" + settings: + follow: "شمولیت اختیار کریں" + followed: "شمولیت اختیار کی" + notifications: "اطلاعات" + preview: "پیشگی دیکھیں" + save: "محفوظ کریں" + saved: "محفوظ کر لیا گیا" + unfollow: "چھوڑ دیں" + incoming_webhooks: + back: "واپس" + description: "تفصیل" + delete: "مٹائیں" + emoji: "اِیمَوجی" + name: "نام" + save: "محفوظ کریں" + edit: "ترمیم کریں" + system: "سِسٹَم" + url: "URL" + username: "صارف نام" + selection: + cancel: "منسوخ" + copy: "کاپی" + new_topic: + title: "نئے ٹاپک پر منتقل کریں" + existing_topic: + title: "موجودہ ٹاپک میں منتقل کریں" + new_message: + title: "نئے پیغام پر منتقل کریں" + emoji_picker: + objects: "اشیاء" + activities: "سرگرمیاں" + flags: "فلَیگز" + symbols: "علامات" + draft_channel_screen: + header: "نیا پیغام" + cancel: "منسوخ" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: پیغام + review: + types: + reviewable_chat_message: + flagged_by: "کی طرف سے فلَیگ کردہ" diff --git a/plugins/chat/config/locales/client.vi.yml b/plugins/chat/config/locales/client.vi.yml new file mode 100644 index 0000000000..d698006d06 --- /dev/null +++ b/plugins/chat/config/locales/client.vi.yml @@ -0,0 +1,116 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: + js: + chat: + create: "Tạo" + cancel: "Huỷ" + channel_settings: + edit: "Sửa" + add: "Thêm" + join: "Tham gia" + leave: "Rời nhóm" + close: "Đóng" + delete: "Xóa" + edited: "đã chỉnh sửa" + muted: "im lặng" + joined: "đã tham gia" + email_frequency: + never: "Không bao giờ" + flag: "Gắn cờ" + join: "Tham gia" + mention_warning: + dismiss: "bỏ qua" + reply: "Trả lời" + edit: "Sửa" + rebake_message: "Tạo lại HTML" + bookmark_message: "Đánh dấu chỉ mục" + bookmark_message_edit: "Chỉnh sửa Dấu trang" + save: "Lưu lại" + sounds: + none: "Không có gì" + exit: "quay lại" + channel_status: + closed: "Đã " + open: "Mở" + browse: + back: "Quay lại" + filter_all: All + filter_closed: Đã + chat_message_separator: + today: Hôm nay + yesterday: Hôm qua + about_view: + title: Tiêu đề + description: Mô tả + channel_info: + back_to_channel: "Quay lại" + tabs: + about: Giới thiệu + members: Các thành viên + settings: Cài đặt + direct_message_creator: + title: Tin nhắn mới + prefix: "Tới:" + create_channel: + type: "Loại" + types: + category: "Chuyên mục" + topic: "Chủ đề" + composer: + italic_text: "văn bản nhấn mạnh" + bold_text: "chữ in đậm" + notification_levels: + never: "Không bao giờ" + settings: + follow: "Tham gia" + followed: "Đã tham gia" + notifications: "Thông báo" + preview: "Xem trước" + save: "Lưu lại" + saved: "Lưu trữ" + unfollow: "Rời nhóm" + incoming_webhooks: + back: "Quay lại" + description: "Mô tả" + delete: "Xoá" + emoji: "Emoji" + name: "T" + save: "Lưu lại" + edit: "Sửa" + system: "hệ thống" + url: "URL" + username: "Tên tài khoản" + selection: + cancel: "Huỷ" + copy: "Sao chép" + new_topic: + title: "Di chuyển tới Chủ đề mới" + existing_topic: + title: "Di chuyển tới chủ đề đang tồn tại" + new_message: + title: "Chuyển đến tin nhắn mới" + emoji_picker: + objects: "Vật thể" + activities: "Hoạt động" + flags: "Dấu cờ - Flags" + symbols: "Ký hiệu" + draft_channel_screen: + header: "Tin nhắn mới" + cancel: "Huỷ" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: Tin nhắn + review: + types: + reviewable_chat_message: + flagged_by: "Gắn cờ bởi" diff --git a/plugins/chat/config/locales/client.zh_CN.yml b/plugins/chat/config/locales/client.zh_CN.yml new file mode 100644 index 0000000000..9974d2731d --- /dev/null +++ b/plugins/chat/config/locales/client.zh_CN.yml @@ -0,0 +1,436 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "聊天频道状态已更改" + chat_channel_delete: "聊天频道已删除" + api: + scopes: + descriptions: + chat: + create_message: "在指定频道创建聊天消息。" + about: + chat_messages_count: "聊天消息" + chat_channels_count: "聊天频道" + chat_users_count: "聊天用户" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "显示所有消息" + already_enabled: "此话题已启用聊天。请刷新。" + disabled_for_topic: "此话题已禁用聊天。" + bot: "机器人" + create: "创建" + cancel: "取消" + cancel_reply: "取消回复" + chat_channels: "频道" + browse_all_channels: "浏览所有频道" + move_to_channel: + title: "将消息移至频道" + instructions: + other: "您正在移动 %{count} 条消息。选择一个目标频道。 将在%{channelTitle}频道中创建一条占位符消息,以表明这些消息已被移动。" + confirm_move: "移动消息" + channel_settings: + title: "频道设置" + edit: "编辑" + add: "添加" + close_channel: "关闭频道" + open_channel: "打开频道" + archive_channel: "归档频道" + delete_channel: "删除频道" + join_channel: "加入频道" + leave_channel: "离开频道" + join: "加入" + leave: "离开" + channel_archive: + title: "归档频道" + instructions: "

    归档频道会使它进入只读模式,并将该频道的所有消息移至一个新的或现有的话题。将无法发送新消息,也不能编辑或删除现有消息。

    确定要归档%{channelTitle}频道吗?

    " + process_started: "归档过程已经开始。此对话框将很快关闭,当归档过程完成后,您会收到一条个人消息。" + retry: "重试" + channel_open: + title: "打开频道" + instructions: "重新打开频道,所有用户将能够发送消息和编辑他们现有的消息。" + channel_close: + title: "关闭频道" + instructions: "关闭该频道会阻止非管理人员用户发送新消息或编辑现有消息。确定要关闭此频道吗?" + channel_delete: + title: "删除频道" + instructions: "

    删除%{name}频道和聊天历史。所有消息和相关数据,例如回应和上传,将被永久删除。如果您想保留频道历史记录,并将其停用,您可能想要归档频道。

    确定要永久删除该频道吗?要确认,请在下面的框中输入频道的名称。

    " + confirm: "我明白后果,删除频道" + confirm_channel_name: "输入频道名称" + process_started: "删除频道的过程已经开始。此对话框将很快关闭,您将无法再查看被删除的频道。" + channels_list_popup: + browse: "浏览频道" + create: "新建频道" + click_to_join: "点击此处查看可用频道。" + close: "关闭" + collapse: "折叠聊天抽屉" + confirm_flag: "确定要举报 %{username} 的消息吗?" + deleted: "一条消息已被删除。[查看]" + hidden: "一条消息已被隐藏。[查看]" + delete: "删除" + edited: "已编辑" + muted: "已设为免打扰" + joined: "已加入" + empty_state: + direct_message_cta: "开始个人聊天" + direct_message: "您还可以与一位或多位用户开始个人聊天。" + title: "找不到频道" + email_frequency: + description: "只有在过去 15 分钟内没有见到您,我们才会给您发送电子邮件。" + never: "永不" + title: "电子邮件通知" + when_away: "仅在离开时" + enable: "启用聊天" + flag: "举报" + emoji: "插入表情符号" + flagged: "此消息已被举报,等待审核" + invalid_access: "您无权查看此聊天频道" + invitation_notification: "%{username}邀请您加入一个聊天频道" + in_reply_to: "回复" + heading: "聊天" + join: "加入" + new_messages: "新消息" + mention_warning: + cannot_see: + other: "%{usernames} 无法访问此频道且未收到通知。" + dismiss: "关闭" + invitations_sent: + other: "已发送邀请" + invite: "邀请加入频道" + without_membership: + other: "%{usernames} 尚未加入此频道。" + aria_roles: + header: "聊天标题" + composer: "聊天输入框" + channels_list: "聊天频道列表" + no_public_channels: "您还没有加入任何频道。" + only_chat_push_notifications: + title: "只发送聊天推送通知" + description: "阻止发送所有非聊天推送通知" + ignore_channel_wide_mention: + title: "忽略频道范围内的提及" + description: "不发送频道范围内提及(@here 和 @all)的通知" + open: "打开聊天" + open_full_page: "打开全屏聊天" + close_full_page: "关闭全屏聊天" + open_message: "在聊天中打开消息" + placeholder_self: "做些记录" + placeholder_others: "在 %{messageRecipient} 聊天" + placeholder_new_message_disallowed: "频道的状态为%{status},您现在无法发送新消息。" + placeholder_silenced: "您目前无法发送消息。" + placeholder_start_conversation: 开始与 %{usernames} 的对话 + remove_upload: "移除文件" + react: "使用表情符号回复" + reply: "回复" + edit: "编辑" + copy_link: "复制链接" + rebake_message: "重建 HTML" + retry_staged_message: + title: "网络错误" + action: "重新发送?" + unreliable_network: "网络不稳定,发送消息和保存草稿可能无法正常工作" + bookmark_message: "书签" + bookmark_message_edit: "编辑书签" + restore: "恢复已删除的消息" + save: "保存" + select: "选择" + silence: "将用户禁言" + return_to_list: "返回频道列表" + scroll_to_bottom: "滚动到底部" + scroll_to_new_messages: "查看新消息" + sound: + title: "桌面聊天通知声音" + sounds: + none: "无" + bell: "钟声" + ding: "叮叮" + title: "聊天" + title_capitalized: "聊天" + upload: "附加文件" + uploaded_files: + other: "%{count} 个文件" + you_flagged: "您已举报此消息" + exit: "返回" + channel_status: + read_only_header: "频道为只读" + read_only: "只读" + archived_header: "频道已被归档" + archived: "已归档" + archive_failed: "频道归档失败。%{completed} 条(共 %{total} 条)消息已被归档到目标话题。按“重试”,尝试完成存档。" + archive_completed: "请参阅归档话题" + closed_header: "频道已被关闭" + closed: "已关闭" + open_header: "频道处于开放状态" + open: "开放" + browse: + title: 频道 + filter_all: 所有 + filter_open: 已打开 + filter_closed: 已关闭 + filter_archived: 已归档 + filter_input_placeholder: 按名称搜索频道 + chat_message_separator: + today: 今天 + yesterday: 昨天 + members_view: + filter_placeholder: 查找成员 + about_view: + associated_topic: 链接的话题 + associated_category: 链接的类别 + title: 标题 + description: 描述 + channel_info: + back_to_all_channels: "所有频道" + back_to_channel: "返回" + tabs: + about: 关于 + members: 成员 + settings: 设置 + channel_edit_title_modal: + title: 编辑标题 + input_placeholder: 添加标题 + description: 为您的频道提供简短的描述性标题 + channel_edit_description_modal: + title: 编辑描述 + input_placeholder: 添加描述 + description: 告诉人们这个频道的主题 + direct_message_creator: + title: 新消息 + prefix: "至:" + no_results: 没有结果 + selected_user_title: "取消选择 %{username}" + channel_selector: + title: "跳转到频道" + no_channels: "没有频道符合您的搜索" + channel: + no_memberships: 此频道没有成员 + no_memberships_found: 找不到成员 + memberships_count: + other: "%{count} 个成员" + create_channel: + auto_join_users: + public_category_warning: "%{category}是公共类别。是否自动将所有最近活跃的用户添加到此频道?" + warning_groups: + other: 自动从%{group}和%{group_2}添加 %{members_count} 个用户? + warning_multiple_groups: 自动从%{group_1}和其他 %{count} 个群组添加 %{members_count} 个用户? + choose_category: + label: "选择一个类别" + none: "选择一个…" + default_hint: 访问%{category}安全设置管理访问权限 + hint_groups: + other: 根据安全设置,%{hint}和%{hint_2}中的用户将可以访问此频道 + hint_multiple_groups: 根据安全设置,%{hint_1}和其他 %{count} 个群组中的用户将有权访问此频道 + create: "创建频道" + description: "描述(可选)" + name: "频道名称" + title: "新建频道" + type: "类型" + types: + category: "类别" + topic: "话题" + reviewable: + type: "聊天消息" + reactions: + only_you: "您回应了 :%{emoji}:" + and_others: "您,%{usernames} 回应了 :%{emoji}:" + only_others: "%{usernames} 回应了 :%{emoji}:" + others_and_more: "%{usernames} 和其他 %{more} 人回应了 :%{emoji}:" + you_others_and_more: "您,%{usernames} 和其他 %{more} 人回应了 :%{emoji}:" + composer: + toggle_toolbar: "切换工具栏" + italic_text: "斜体" + bold_text: "粗体" + code_text: "代码文本" + quote: + original_channel: '最初在%{channel}中发送。' + copy_success: "聊天引用已复制到剪贴板" + notification_levels: + never: "永不" + mention: "仅限提及" + always: "所有活动" + settings: + enable_auto_join_users: "自动添加所有最近活跃的用户" + disable_auto_join_users: "停止自动添加用户" + auto_join_users_warning: "所有不是此频道成员且有权访问%{category}类别的用户都将加入。确定吗?" + desktop_notification_level: "桌面通知" + follow: "加入" + followed: "已加入" + mobile_notification_level: "移动推送通知" + mute: "将频道设为免打扰" + muted_on: "开" + muted_off: "关" + notifications: "通知" + preview: "预览" + save: "保存" + saved: "已保存" + unfollow: "离开" + admin: + title: "聊天" + direct_messages: + title: "个人聊天" + new: "新的个人聊天" + create: "开始" + leave: "离开此个人聊天" + cannot_create: "抱歉,您无法发送直接消息。" + incoming_webhooks: + back: "返回" + channel_placeholder: "选择一个频道" + confirm_destroy: "确定要删除此传入网络钩子吗?此操作无法撤消。" + current_emoji: "当前表情符号" + description: "描述" + delete: "删除" + emoji: "表情符号" + emoji_instructions: "如果表情符号留空,将使用系统头像。" + name: "名称" + name_placeholder: "名称…" + new: "新的传入网络钩子" + none: "未创建现有的传入网络钩子。" + no_emoji: "未选择表情符号" + post_to: "发布到" + reset_emoji: "重置表情符号" + save: "保存" + edit: "编辑" + select_emoji: "选择表情符号" + system: "系统" + title: "传入网络钩子" + url: "URL" + url_instructions: "此 URL 包含一个秘密值 - 请安全保管。" + username: "用户名" + username_instructions: "发布到频道的机器人的用户名。留空时默认为“系统”。" + instructions: "外部系统可以使用传入网络钩子以机器人用户身份通过 /hooks/:key 端点将消息发布到指定的聊天频道。有效负荷由单个 text 参数组成,限制为 2000 个字符。

    我们还支持有限 Slack 格式的 text 参数以及基于 https://api.slack.com/reference/surfaces/formatting 中的格式提取链接和提及,但是必须为此使用 /hooks/:key/slack 端点。" + selection: + cancel: "取消" + quote_selection: "话题中的引用" + copy: "复制" + move_selection_to_channel: "移至频道" + error: "移动聊天消息时出错" + title: "将聊天移动到话题" + new_topic: + title: "移动到新话题" + instructions: + other: "您将创建一个新话题并使用您选择的 %{count} 条聊天消息进行填充。" + instructions_channel_archive: "您将要创建一个新话题并将频道消息归档到该话题。" + existing_topic: + title: "移动到现有话题" + instructions: + other: "请选择您要将这 %{count} 条聊天消息移动到的话题。" + instructions_channel_archive: "请选择您要将频道消息归档到的话题。" + new_message: + title: "移动到新消息" + instructions: + other: "您将创建一条新消息并使用您选择的 %{count} 条聊天消息进行填充。" + replying_indicator: + single_user: "%{username} 正在输入" + multiple_users: "%{commaSeparatedUsernames} 和 %{lastUsername} 正在输入" + many_users: + other: "%{commaSeparatedUsernames} 和其他 %{count} 人正在输入" + retention_reminders: + public: "频道历史记录保留 %{days} 天。" + dm: "个人聊天记录保留 %{days} 天。" + topic_button_title: "聊天" + flags: + off_topic: "此消息与频道标题定义的当前讨论无关,可能应当移至其他地方。" + inappropriate: "此消息包含理性的人会认为具有攻击性、辱骂性或违反我们的社区准则的内容。" + spam: "此消息是广告或破坏行为。它对当前频道没有用,也不相关。" + notify_user: "我想亲自直接与此人谈谈他们的消息。" + notify_moderators: "由于上面未列出的另一个原因,此消息需要管理人员加以注意。" + flagging: + action: "举报消息" + emoji_picker: + favorites: "常用" + smileys_&_emotion: "笑脸和情感" + objects: "物体" + people_&_body: "人和身体" + travel_&_places: "旅行和地点" + animals_&_nature: "动物和自然" + food_&_drink: "食品和饮料" + activities: "活动" + flags: "旗帜" + symbols: "符号" + search_placeholder: "按表情符号名称和别名搜索…" + no_results: "没有结果" + draft_channel_screen: + header: "新消息" + cancel: "取消" + notifications: + chat_invitation: "邀请您加入聊天频道" + chat_invitation_html: "%{username}邀请您加入一个聊天频道" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: '在“%{channel}”中提及您' + direct_html: '%{username} 在“%{channel}”中提及您' + other_plain: '在“%{channel}”中提及“%{identifier}”' + other_html: '%{username} 在“%{channel}”中提及“%{identifier}”' + direct_message_chat_mention: + direct: "在个人聊天中提及您" + direct_html: "%{username} 在个人聊天中提及您" + other_plain: "在个人聊天中提及“%{identifier}”" + other_html: "%{username} 在个人聊天中提及“%{identifier}”" + chat_message: "新的聊天消息" + chat_quoted: "%{username} 引用了您的聊天消息" + titles: + chat_mention: "聊天提及" + chat_invitation: "聊天邀请" + chat_quoted: "已引用聊天" + action_codes: + chat: + enabled: '%{who} 于 %{when} 启用了' + disabled: "%{who} 于 %{when} 关闭了聊天" + discourse_automation: + scriptables: + send_chat_message: + title: 发送聊天消息 + fields: + chat_channel_id: + label: 聊天频道 ID + message: + label: 私信 + sender: + label: 发送人 + description: 默认为“系统” + review: + transcript: + view: "查看以前的消息副本" + types: + reviewable_chat_message: + title: "举报的聊天消息" + flagged_by: "举报者" + keyboard_shortcuts_help: + chat: + title: "聊天" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} 切换频道" + open_quick_channel_selector: "%{shortcut} 打开快速频道选择器" + open_insert_link_modal: "%{shortcut} 插入超链接(仅输入框)" + composer_bold: "%{shortcut} 粗体(仅输入框)" + composer_italic: "%{shortcut} 斜体(仅输入框)" + composer_code: "%{shortcut} 代码(仅输入框)" + drawer_open: "%{shortcut} 打开聊天抽屉" + drawer_close: "%{shortcut} 关闭聊天抽屉" + topic_statuses: + chat: + help: "已为此话题启用聊天" + user: + allow_private_messages: "允许其他用户向我发送个人消息和聊天直接消息" + muted_users_instructions: "禁止来自这些用户的所有通知、个人消息和聊天消息。" + allowed_pm_users_instructions: "仅允许来自这些用户的个人消息或聊天直接消息。" + allow_private_messages_from_specific_users: "只允许特定用户向我发送个人消息或聊天直接消息" + ignored_users_instructions: "禁止来自这些用户的所有帖子、消息、通知、个人消息和聊天直接消息。" + user_menu: + no_chat_notifications_title: "您还没有任何聊天通知" + no_chat_notifications_body: > + 当有人在聊天中直接向您发送消息或提及 (@) 您时,您将在此面板中收到通知。当您有一段时间没有登录时,通知也会发送到您的电子邮件。

    点击任何聊天频道顶部的标题以配置您在该频道中接收的通知。有关详情,请参阅您的通知偏好设置。 + tabs: + chat_notifications: "聊天通知" + chat_notifications_with_unread: + other: "聊天通知 - %{count} 个未读通知" diff --git a/plugins/chat/config/locales/client.zh_TW.yml b/plugins/chat/config/locales/client.zh_TW.yml new file mode 100644 index 0000000000..e37dc401fe --- /dev/null +++ b/plugins/chat/config/locales/client.zh_TW.yml @@ -0,0 +1,115 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: + js: + chat: + create: "創建" + cancel: "取消" + channel_settings: + edit: "編輯" + add: "加入" + join: "加入" + leave: "離開" + close: "關閉" + delete: "刪除" + muted: "靜音" + joined: "建立日期" + email_frequency: + never: "永不" + flag: "檢舉" + join: "加入" + mention_warning: + dismiss: "忽略" + reply: "回覆" + edit: "編輯" + rebake_message: "重建 HTML" + bookmark_message: "書籤" + bookmark_message_edit: "編輯書籤" + save: "保存" + sounds: + none: "無" + exit: "上一步" + channel_status: + closed: "不公開" + open: "開啟" + browse: + back: "上一步" + filter_all: 全部 + filter_closed: 不公開 + chat_message_separator: + today: 今天 + yesterday: 昨天 + about_view: + title: 標題 + description: 簡述 + channel_info: + back_to_channel: "上一步" + tabs: + about: 關於 + members: 成員 + settings: 設定 + direct_message_creator: + title: 新訊息 + prefix: "發至:" + create_channel: + type: "類型" + types: + category: "分類" + topic: "話題" + composer: + italic_text: "斜體字" + bold_text: "粗體字" + notification_levels: + never: "永不" + settings: + follow: "加入" + followed: "建立日期" + notifications: "通知" + preview: "預覽" + save: "保存" + saved: "已儲存" + unfollow: "離開" + incoming_webhooks: + back: "上一步" + description: "簡述" + delete: "刪除" + emoji: "Emoji" + name: "姓名" + save: "保存" + edit: "編輯" + system: "系統" + url: "網址" + username: "使用者名稱" + selection: + cancel: "取消" + copy: "複製" + new_topic: + title: "移至新話題" + existing_topic: + title: "移至已存在的話題" + new_message: + title: "移動到新訊息" + emoji_picker: + objects: "物品" + activities: "活動" + flags: "投訴" + symbols: "象徵
    " + draft_channel_screen: + header: "新訊息" + cancel: "取消" + notifications: + chat_quoted: "%{username} %{description}" + discourse_automation: + scriptables: + send_chat_message: + fields: + message: + label: 訊息 + review: + types: + reviewable_chat_message: + flagged_by: "標記由" diff --git a/plugins/chat/config/locales/server.ar.yml b/plugins/chat/config/locales/server.ar.yml new file mode 100644 index 0000000000..ada4db21dc --- /dev/null +++ b/plugins/chat/config/locales/server.ar.yml @@ -0,0 +1,216 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + site_settings: + chat_allowed_groups: "يمكن للمستخدمين في هذه المجموعات الدردشة. لاحظ أن أعضاء فريق العمل يمكنهم دائمًا الوصول إلى الدردشة." + chat_channel_retention_days: "سيتم الاحتفاظ برسائل الدردشة في القنوات العادية لهذا العدد من الأيام. اضبط القيمة على '0' للاحتفاظ بالرسائل إلى الأبد." + chat_dm_retention_days: "سيتم الاحتفاظ برسائل الدردشة في قنوات الدردشة الشخصية لهذا العدد من الأيام. اضبط القيمة على '0' للاحتفاظ بالرسائل إلى الأبد." + chat_auto_silence_duration: "عدد الدقائق التي سيتم فيها كتم المستخدمين عندما يتجاوزون حد معدل إنشاء رسائل الدردشة. اضبط القيمة على '0' لإيقاف الكتم التلقائي." + chat_allowed_messages_for_trust_level_0: "عدد الرسائل المسموح للمستخدمين من مستوى الثقة 0 بإرسالها خلال 30 ثانية. اضبط القيمة على '0' لإيقاف الحد." + chat_allowed_messages_for_other_trust_levels: "عدد الرسائل المسموح للمستخدمين من مستوى الثقة 1-4 بإرسالها خلال 30 ثانية. اضبط القيمة على '0' لإيقاف الحد." + chat_silence_user_sensitivity: "احتمالية أن يتم كتم المستخدم الذي تم الإبلاغ عنه في الدردشة تلقائيًا." + chat_auto_silence_from_flags_duration: "عدد الدقائق التي سيتم كتم المستخدمين تلقائيًا خلالها بسبب رسائل الدردشة التي تم الإبلاغ عنها." + chat_default_channel_id: "قناة الدردشة التي سيتم فتحها بشكلٍ افتراضي عندما لا يكون لدى المستخدم رسائل أو إشارات غير مقروءة في قنوات أخرى." + chat_duplicate_message_sensitivity: "احتمالية حظر الرسائل المكررة خلال فترة قصيرة من المُرسل نفسه. رقم عشري بين 0 و1.0، مع كون 1.0 هو أعلى إعداد (يحظر الرسائل بشكلٍ أكثر تكرارًا في فترة زمنية أقصر). اضبط القيمة على `0` للسماح بالرسائل المكررة." + chat_minimum_message_length: "الحد الأدنى لعدد الأحرف لرسالة دردشة." + chat_allow_uploads: "السماح بالتحميلات في قنوات الدردشة العامة وقنوات الرسائل المباشرة." + chat_archive_destination_topic_status: "الحالة التي يجب أن يكون عليها الموضوع المستهدف بعد اكتمال أرشيف القناة. ينطبق ذلك فقط عندما يكون الموضوع المستهدف موضوعًا جديدًا وليس موضوعًا موجودًا." + default_emoji_reactions: "تفاعلات الرموز التعبيرية الافتراضية لرسائل الدردشة. أضِف ما يصل إلى 5 رموز تعبيرية للتفاعل السريع." + direct_message_enabled_groups: "السماح للمستخدمين في تلك المجموعات بإنشاء دردشات شخصية بين مستخدم وآخر. ملاحظة: يمكن للموظفين دائمًا إنشاء دردشات شخصية، وسيتمكن المستخدمون من الرد على الدردشات الشخصية التي بدأها مستخدمون لديهم إذن بإنشائها." + chat_message_flag_allowed_groups: "السماح للمستخدمين في تلك المجموعات بالإبلاغ عن رسائل الدردشة." + errors: + chat_default_channel: "يجب أن تكون قناة الدردشة الافتراضية قناةً عامة." + direct_message_enabled_groups_invalid: "يجب عليك تحديد مجموعة واحدة على الأقل لهذا الإعداد. إذا كنت لا تريد أن يقوم أي شخص باستثناء فريق العمل بإرسال رسائل مباشرة، فاختر مجموعة فريق العمل." + chat_upload_not_allowed_secure_uploads: "غير مسموح بتحميلات الدردشة عندما يكون إعداد الموقع المسؤول عن التحميلات الآمنة مفعَّلًا." + system_messages: + chat_channel_archive_complete: + title: "اكتمل أرشيف قناة الدردشة" + subject_template: "اكتمل أرشيف قناة الدردشة بنجاح" + text_body_template: | + اكتملت أرشفة قناة الدردشة **\#%{channel_name}** بنجاح. وتم نسخ الرسائل إلى الموضوع [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "فشلت أرشفة قناة الدردشة" + subject_template: "فشلت أرشفة قناة الدردشة" + text_body_template: | + فشلت أرشفة قناة الدردشة **\#%{channel_name}**. تم وضع الرسائل %{messages_archived} في الأرشيف. تم نسخ الرسائل المؤرشفة جزئيًا في الموضوع [%{topic_title}](%{topic_url}). انتقل إلى القناة في %{channel_url} لإعادة المحاولة. + chat: + deleted_chat_username: تم الحذف + errors: + channel_exists_for_category: "توجد قناة بالفعل في هذه الفئة وبهذا الاسم" + channel_new_message_disallowed: "القناة %{status}، لا يمكن إرسال رسائل جديدة" + channel_modify_message_disallowed: "القناة %{status}، لا يمكن تعديل أو حذف أي رسائل" + user_cannot_send_message: "لا يمكنك إرسال رسائل في الوقت الحالي." + rate_limit_exceeded: "تم تجاوز حد رسائل الدردشة التي يمكن إرسالها خلال 30 ثانية" + auto_silence_from_flags: "تم الإبلاغ عن الرسالة عددًا كافيًا من المرات لكتم المستخدم." + channel_cannot_be_archived: "لا يمكن أرشفة القناة في الوقت الحالي، يجب أن تكون إما مغلقة أو مفتوحة للأرشفة." + duplicate_message: "لقد نشرت رسالة مماثلة مؤخرًا." + delete_channel_failed: "فشل حذف القناة، يُرجى إعادة المحاولة." + minimum_length_not_met: "الرسالة قصيرة جدًا، ويجب ألا تقل عن %{minimum} من الأحرف." + max_reactions_limit_reached: "غير مسموح بتفاعلات جديدة على هذه الرسالة." + message_move_invalid_channel: "يجب أن تكون القناة المصدر والمستهدفة قناتين عامتين." + message_move_no_messages_found: "لم يتم العثور على رسائل بمعرِّفات الرسائل المقدَّمة." + cant_update_direct_message_channel: "لا يمكن تحديث خصائص قناة الرسائل المباشرة مثل الاسم والوصف." + not_accepting_dms: "عذرًا، لا يقبل %{username} الرسائل في الوقت الحالي." + actor_ignoring_target_user: "أنت تتجاهل %{username}؛ لذا لا يمكنك إرسال رسائل إليه." + actor_muting_target_user: "أنت تكتم %{username}؛ لذا لا يمكنك إرسال رسائل إليه." + actor_disallowed_dms: "لقد اخترت منع المستخدمين من إرسال رسائل خاصة ومباشرة إليك؛ لذا لا يمكنك إنشاء رسائل مباشرة جديدة." + actor_preventing_target_user_from_dm: "لقد اخترت منع %{username} من إرسال رسائل خاصة ومباشرة إليك؛ لذا لا يمكنك إنشاء رسائل مباشرة جديدة إليه." + user_cannot_send_direct_messages: "عذرًا، لا يمكنك إرسال الرسائل المباشرة." + reviewables: + message_already_handled: "شكرًا، لكننا راجعنا هذه الرسالة بالفعل وقرَّرنا أنك لست بحاجة إلى الإبلاغ عنها مرة أخرى." + actions: + agree: + title: "موافقة..." + agree_and_keep_message: + title: "الاحتفاظ بالرسالة" + description: "يمكنك الموافقة على البلاغ والاحتفاظ بالرسالة دون تغيير." + agree_and_keep_deleted: + title: "ترك الرسالة محذوفة" + description: "ويمكنك الموافقة على البلاغ وترك الرسالة محذوفة." + agree_and_suspend: + title: "تعليق المستخدم" + description: "يمكنك الموافقة على البلاغ وتعليق المستخدم." + agree_and_silence: + title: "كتم المستخدم" + description: "يمكنك الموافقة على البلاغ وكتم المستخدم." + agree_and_restore: + title: "استعادة الرسالة" + description: "يمكنك استعادة الرسالة حتى يتمكن المستخدمون من رؤيتها." + agree_and_delete: + title: "حذف الرسالة" + description: "يمكنك حذف الرسالة حتى لا يتمكن المستخدمون من رؤيتها." + delete_and_agree: + title: "حذف الرسالة" + disagree_and_restore: + title: "عدم الموافقة واستعادة الرسالة" + description: "يمكنك استعادة الرسالة حتى يتمكن جميع المستخدمين من رؤيتها." + disagree: + title: "عدم الموافقة" + ignore: + title: "تجاهل" + direct_messages: + transcript_title: "نص الرسائل السابقة في %{channel_name}" + transcript_body: "لمنحك مزيدًا من السياق، فقد ضمَّنا نسخة من الرسائل السابقة في هذه المحادثة (حتى عشر رسائل):\n\n%{transcript}" + channel: + statuses: + read_only: "للقراءة فقط" + archived: "مؤرشفة" + closed: "مغلقة" + open: "مفتوحة" + archive: + first_post_raw: "هذا الموضوع عبارة عن أرشيف لقناة الدردشة [%{channel_name}] (%{channel_url})." + messages_moved: + zero: "نقل @%{acting_username} %{count} رسائل إلى القناة [%{channel_name}](%{first_moved_message_url})." + one: "نقل @%{acting_username} رسالة واحدة إلى القناة [%{channel_name}](%{first_moved_message_url})." + two: "نقل @%{acting_username} رسالتين (%{count}) إلى القناة [%{channel_name}](%{first_moved_message_url})." + few: "نقل @%{acting_username} %{count} رسائل إلى القناة [%{channel_name}](%{first_moved_message_url})." + many: "نقل @%{acting_username} %{count} رسائل إلى القناة [%{channel_name}](%{first_moved_message_url})." + other: "نقل @%{acting_username} %{count} رسائل إلى القناة [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} و%{leftover} آخرين" + bookmarkable: + notification_title: "رسالة في %{channel_name}" + personal_chat: "الدردشة الشخصية" + onebox: + inline_to_message: "الرسالة #%{message_id} بواسطة %{username} - #%{chat_channel}" + inline_to_channel: "الدردشة #%{chat_channel}" + inline_to_topic_channel: "دردشة للموضوع %{topic_title}" + x_members: + zero: "%{count} عضوًا" + one: "عضو واحد (%{count})" + two: "عضوان (%{count})" + few: "%{count} أعضاء" + many: "%{count} عضوًا" + other: "%{count} عضوًا" + and_x_others: + zero: "و%{count} آخرين" + one: "و%{count} آخر" + two: "و%{count} آخران" + few: "و%{count} آخرين" + many: "و%{count} آخرين" + other: "و%{count} آخرين" + discourse_push_notifications: + popup: + chat_mention: + direct: 'أشار %{username} إليك في "%{channel}"' + other_type: 'أشار %{username} إلى %{identifier} في "%{channel}"' + direct_message_chat_mention: + direct: "أشار %{username} إليك في الدردشة الشخصية" + other_type: "أشار %{username} إلى %{identifier} في الدردشة الشخصية" + new_chat_message: 'أرسل %{username} رسالة في "%{channel}"' + new_direct_chat_message: "أرسل %{username} رسالة في الدردشة الشخصية" + discourse_automation: + scriptables: + send_chat_message: + title: إرسال رسالة دردشة + reviewable_score_types: + needs_review: + title: "بحاجة إلى المراجعة" + notify_user: + chat_pm_title: 'رسالة الدردشة الخاصة بك في "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'هناك رسالة دردشة في "%{channel_name}" تتطلب انتباه فريق العمل' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "يعتقد أحد أعضاء فريق العمل أن رسالة الدردشة هذه تحتاج إلى المراجعة." + user_notifications: + chat_summary: + deleted_user: "تم حذف المستخدم" + description: + zero: "لديك رسائل دردشة جديدة" + one: "لديك رسالة دردشة جديدة" + two: "لديك رسالتا دردشة جديدتان" + few: "لديك رسائل دردشة جديدة" + many: "لديك رسائل دردشة جديدة" + other: "لديك رسائل دردشة جديدة" + from: "%{site_name}" + subject: + direct_message: + zero: "[%{email_prefix}] رسالتان جديدتان من %{message_title} و%{others}" + one: "[%{email_prefix}] رسالة جديدة من %{message_title}" + two: "[%{email_prefix}] رسالتان جديدتان من %{message_title} و%{others}" + few: "[%{email_prefix}] رسالتان جديدتان من %{message_title} و%{others}" + many: "[%{email_prefix}] رسالتان جديدتان من %{message_title} و%{others}" + other: "[%{email_prefix}] رسالتان جديدتان من %{message_title} و%{others}" + chat_channel: + zero: "[%{email_prefix}] رسالتان جديدتان في %{message_title} و%{others}" + one: "[%{email_prefix}] رسالة جديدة في %{message_title}" + two: "[%{email_prefix}] رسالتان جديدتان في %{message_title} و%{others}" + few: "[%{email_prefix}] رسائل جديدة في %{message_title} و%{others}" + many: "[%{email_prefix}] رسالتان جديدتان في %{message_title} و%{others}" + other: "[%{email_prefix}] رسالتان جديدتان في %{message_title} و%{others}" + other_direct_message: "من %{message_title}" + others: "%{count} آخرين" + unsubscribe: "يتم إرسال ملخص الدردشة هذا من %{site_link} عندما تكون غائبًا. غيِّر %{email_preferences_link} أو %{unsubscribe_link} لإلغاء الاشتراك." + unsubscribe_no_link: "يتم إرسال ملخص الدردشة هذا من %{site_link} عندما تكون غائبًا. غيِّر %{email_preferences_link} لديك." + view_messages: + zero: "عرض %{count} رسائل" + one: "عرض رسالة واحدة (%{count})" + two: "عرض رسالتين (%{count})" + few: "عرض %{count} رسائل" + many: "عرض %{count} رسائل" + other: "عرض %{count} رسائل" + view_more: + zero: "عرض %{count} رسائل إضافية" + one: "عرض رسالة واحدة (%{count}) إضافية" + two: "عرض رسالتين (%{count}) إضافيتين" + few: "عرض %{count} رسائل إضافية" + many: "عرض %{count} رسائل إضافية" + other: "عرض %{count} رسائل إضافية" + your_chat_settings: "تفضيل مدى تكرار رسائل البريد الإلكتروني للدردشة" + unsubscribe: + chat_summary: + select_title: "حدِّد مدى تكرار الرسائل الإلكترونية لملخص الدردشة:" + never: أبدًا + when_away: عندما أكون غائبًا فقط + category: + cannot_delete: + has_chat_channels: "لا يمكن حذف هذه الفئة لأنها تحتوي قنوات دردشة." diff --git a/plugins/chat/config/locales/server.be.yml b/plugins/chat/config/locales/server.be.yml new file mode 100644 index 0000000000..0be1dcea5e --- /dev/null +++ b/plugins/chat/config/locales/server.be.yml @@ -0,0 +1,36 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: + chat: + deleted_chat_username: аддалены + errors: + not_accepting_dms: "На жаль, %{username}не прымае паведамленні ў дадзены момант." + reviewables: + actions: + agree: + title: "Пагадзіцеся ..." + agree_and_suspend: + title: "прыпыненне карыстальніка" + description: "Пагадзіцеся са сцягамі і падвесіць карыстальнік." + agree_and_silence: + title: "Silence Карыстальнік" + description: "Пагадзіцеся са сцягамі і маўчанне карыстальніка." + disagree: + title: "не згаджацца" + ignore: + title: "ігнараваць" + channel: + statuses: + closed: "Закрыта" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: ніколі diff --git a/plugins/chat/config/locales/server.bg.yml b/plugins/chat/config/locales/server.bg.yml new file mode 100644 index 0000000000..7f6d844e41 --- /dev/null +++ b/plugins/chat/config/locales/server.bg.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: + chat: + deleted_chat_username: изтрит + reviewables: + actions: + agree_and_suspend: + title: "Преустанови потребител" + disagree: + title: "Не съм съгласен " + ignore: + title: "Игнорирай" + channel: + statuses: + closed: "Затворена" + open: "Отвори" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} ви спомена в "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Никога diff --git a/plugins/chat/config/locales/server.bs_BA.yml b/plugins/chat/config/locales/server.bs_BA.yml new file mode 100644 index 0000000000..1060aaa9bc --- /dev/null +++ b/plugins/chat/config/locales/server.bs_BA.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: + chat: + deleted_chat_username: obrisano + reviewables: + actions: + agree_and_suspend: + title: "Suspend User" + description: "Složij se sa prijavom i suspendiraj prijavljenog korisnika (suspend)" + agree_and_silence: + title: "Stišaj korisnika" + description: "Složij se sa prijavom i utišaj prijavljenog korisnika (silence)" + disagree: + title: "Disagree" + ignore: + title: "Zanemari" + channel: + statuses: + closed: "Zatvoreno" + open: "Otvori" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} vas je spomenuo/la u "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Nikad diff --git a/plugins/chat/config/locales/server.ca.yml b/plugins/chat/config/locales/server.ca.yml new file mode 100644 index 0000000000..c0e7b4def3 --- /dev/null +++ b/plugins/chat/config/locales/server.ca.yml @@ -0,0 +1,44 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: + chat: + deleted_chat_username: suprimit + errors: + not_accepting_dms: "De moment %{username} no accepta missatges." + reviewables: + actions: + agree: + title: "D'acord..." + agree_and_suspend: + title: "Suspèn l'usuari" + description: "D'acord amb la bandera i suspèn l'usuari. " + agree_and_silence: + title: "Silencia l'usuari" + description: "D'acord amb la bandera i silencia l'usuari. " + disagree: + title: "En desacord" + ignore: + title: "Ignora" + channel: + statuses: + closed: "Tancat" + open: "Obre" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} us ha mencionat en "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Mai diff --git a/plugins/chat/config/locales/server.cs.yml b/plugins/chat/config/locales/server.cs.yml new file mode 100644 index 0000000000..e81b47dc6d --- /dev/null +++ b/plugins/chat/config/locales/server.cs.yml @@ -0,0 +1,39 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + chat: + deleted_chat_username: smazáno + errors: + not_accepting_dms: "Bohužel, %{username} v současnosti nepřijímá zprávy." + reviewables: + actions: + agree_and_suspend: + title: "Zakázat uživatele" + description: "Schválit nahlášení a pozastavit uživatele." + agree_and_silence: + title: "Ztišit uživatele" + description: "Schválit nahlášení a ztišit uživatele." + disagree: + title: "Neschvaluji" + ignore: + title: "ignorovat" + channel: + statuses: + closed: "Uzavřeno" + open: "Otevřít" + discourse_push_notifications: + popup: + chat_mention: + direct: 'Uživatel %{username} vás zmínil v "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\\n\\n%{message}\n" + notify_moderators: + chat_pm_body: "%{link}\\n\\n%{message}\n" + unsubscribe: + chat_summary: + never: Nikdy diff --git a/plugins/chat/config/locales/server.da.yml b/plugins/chat/config/locales/server.da.yml new file mode 100644 index 0000000000..961aa543b6 --- /dev/null +++ b/plugins/chat/config/locales/server.da.yml @@ -0,0 +1,69 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + chat: + deleted_chat_username: slettet + errors: + not_accepting_dms: "Beklager, %{username} accepterer ikke meddelelser i øjeblikket." + reviewables: + actions: + agree: + title: "Enig..." + agree_and_suspend: + title: "Suspendér Bruger" + agree_and_silence: + title: "Ignorer Bruger" + agree_and_restore: + title: "Gendan Besked" + description: "Gendan meddelelsen, så brugerne kan se den." + agree_and_delete: + title: "Slet Besked" + description: "Slet meddelelsen, så brugerne ikke kan se den." + delete_and_agree: + title: "Slet Besked" + disagree: + title: "Uenig" + ignore: + title: "Ignorér" + channel: + statuses: + read_only: "Kun læsning" + archived: "Arkiveret" + closed: "Lukket" + open: "Åbn" + archive: + first_post_raw: "Dette emne er et arkiv af chatkanalen [%{channel_name}] (%{channel_url})." + bookmarkable: + notification_title: "besked i %{channel_name}" + personal_chat: "personlig chat" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nævnte dig i "%{channel}"' + other_type: '%{username} nævnte %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "%{username} nævnte dig i personlig chat" + other_type: "%{username} nævnte %{identifier} i personlig chat" + new_chat_message: '%{username} sendte en besked i "%{channel}"' + new_direct_chat_message: "%{username} sendte en besked i personlig chat" + discourse_automation: + scriptables: + send_chat_message: + title: Send chatbesked + reviewable_score_types: + needs_review: + title: "Behøver Gennemgang" + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Aldrig diff --git a/plugins/chat/config/locales/server.de.yml b/plugins/chat/config/locales/server.de.yml new file mode 100644 index 0000000000..bc885328b5 --- /dev/null +++ b/plugins/chat/config/locales/server.de.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + site_settings: + chat_allowed_groups: "Benutzer in diesen Gruppen können chatten. Bitte beachte, dass Teammitglieder jederzeit auf den Chat zugreifen können." + chat_channel_retention_days: "Chat-Nachrichten in regulären Kanälen werden so viele Tage lang aufbewahrt. Auf „0“ setzen, um Nachrichten für immer aufzubewahren." + chat_dm_retention_days: "Chat-Nachrichten in persönlichen Chat-Kanälen werden so viele Tage lang aufbewahrt. Auf „0“ setzen, um Nachrichten für immer aufzubewahren." + chat_auto_silence_duration: "Anzahl der Minuten, für die Benutzer stummgeschaltet werden, wenn sie das Limit für die Erstellung von Chat-Nachrichten überschreiten. Auf „0“ setzen, um die automatische Stummschaltung zu deaktivieren." + chat_allowed_messages_for_trust_level_0: "Anzahl der Nachrichten, die Benutzer mit Vertrauensstufe 0 in 30 Sekunden senden dürfen. Auf „0“ setzen, um das Limit zu deaktivieren." + chat_allowed_messages_for_other_trust_levels: "Anzahl der Nachrichten, die Benutzer mit den Vertrauensstufen 1–4 in 30 Sekunden senden dürfen. Auf „0“ setzen, um das Limit zu deaktivieren." + chat_silence_user_sensitivity: "Die Wahrscheinlichkeit, dass ein im Chat markierter Benutzer automatisch stummgeschaltet wird." + chat_auto_silence_from_flags_duration: "Anzahl der Minuten, für die Benutzer stummgeschaltet werden, wenn sie aufgrund markierter Chat-Nachrichten automatisch stummgeschaltet werden." + chat_default_channel_id: "Der Chat-Kanal, der standardmäßig geöffnet wird, wenn ein Benutzer keine ungelesenen Nachrichten oder Erwähnungen in anderen Kanälen hat." + chat_duplicate_message_sensitivity: "Die Wahrscheinlichkeit, dass eine doppelte Nachricht desselben Absenders innerhalb eines kurzen Zeitraums blockiert wird. Dezimalzahl zwischen 0 und 1,0, wobei 1,0 die höchste Einstellung ist (Nachrichten werden häufiger in kürzerer Zeit blockiert). Auf `0` setzen, um doppelte Nachrichten zuzulassen." + chat_minimum_message_length: "Mindestanzahl von Zeichen für eine Chat-Nachricht." + chat_allow_uploads: "Uploads in öffentlichen Chat-Kanälen und Direktnachrichtenkanälen zulassen." + chat_archive_destination_topic_status: "Der Status, den das Zielthema haben soll, sobald eine Kanalarchivierung abgeschlossen ist. Dies gilt nur, wenn das Zielthema ein neues Thema ist und kein vorhandenes." + default_emoji_reactions: "Standard-Emoji-Reaktionen für Chat-Nachrichten. Füge bis zu 5 Emojis für schnelle Reaktionen hinzu." + direct_message_enabled_groups: "Benutzern in diesen Gruppen erlauben, persönliche Benutzer-zu-Benutzer-Chats zu erstellen. Hinweis: Teammitglieder können immer persönliche Chats erstellen und Benutzer können auf persönliche Chats antworten, die von Benutzern initiiert wurden, die die Berechtigung haben, sie zu erstellen." + chat_message_flag_allowed_groups: "Benutzer in diesen Gruppen dürfen Chat-Nachrichten markieren." + errors: + chat_default_channel: "Der Standard-Chat-Kanal muss ein öffentlicher Kanal sein." + direct_message_enabled_groups_invalid: "Du musst mindestens eine Gruppe für diese Einstellung angeben. Wenn du nicht möchtest, dass andere Personen als Teammitglieder Direktnachrichten senden, wähle die Gruppe für Teammitglieder aus." + chat_upload_not_allowed_secure_uploads: "Chat-Uploads sind nicht erlaubt, wenn die Website-Einstellung für sichere Uploads aktiviert ist." + system_messages: + chat_channel_archive_complete: + title: "Chat-Kanal-Archivierung abgeschlossen" + subject_template: "Chat-Kanal-Archivierung erfolgreich abgeschlossen" + text_body_template: | + Die Archivierung des Chat-Kanals **\#%{channel_name}** wurde erfolgreich abgeschlossen. Die Nachrichten wurden in das Thema [%{topic_title}](%{topic_url}) kopiert. + chat_channel_archive_failed: + title: "Chat-Kanal-Archivierung fehlgeschlagen" + subject_template: "Chat-Kanal-Archivierung fehlgeschlagen" + text_body_template: | + Die Archivierung des Chat-Kanals **\#%{channel_name}** ist fehlgeschlagen. %{messages_archived} Nachrichten wurden archiviert. Teilweise archivierte Nachrichten wurden in das Thema [%{topic_title}](%{topic_url}) kopiert. Besuche den Kanal unter %{channel_url}, um es erneut zu versuchen. + chat: + deleted_chat_username: gelöscht + errors: + channel_exists_for_category: "Für diese Kategorie und diesen Namen existiert bereits ein Kanal" + channel_new_message_disallowed: "Der Kanal ist %{status}, es können keine neuen Nachrichten gesendet werden" + channel_modify_message_disallowed: "Der Kanal ist %{status}, es können keine Nachrichten bearbeitet oder gelöscht werden" + user_cannot_send_message: "Du kannst derzeit keine Nachrichten senden." + rate_limit_exceeded: "Das Limit der Chat-Nachrichten, die innerhalb von 30 Sekunden gesendet werden können, wurde überschritten" + auto_silence_from_flags: "Chat-Nachricht wurde mit einer Punktzahl markiert, die hoch genug ist, um den Benutzer stummzuschalten." + channel_cannot_be_archived: "Der Kanal kann zu diesem Zeitpunkt nicht archiviert werden, er muss entweder geschlossen oder geöffnet sein." + duplicate_message: "Du hast vor Kurzem eine identische Nachricht gepostet." + delete_channel_failed: "Löschen des Kanals fehlgeschlagen, bitte versuche es erneut." + minimum_length_not_met: "Die Nachricht ist zu kurz, sie muss mindestens %{minimum} Zeichen lang sein." + max_reactions_limit_reached: "Neue Reaktionen auf diese Nachricht sind nicht erlaubt." + message_move_invalid_channel: "Quell- und Zielkanal müssen öffentliche Kanäle sein." + message_move_no_messages_found: "Es wurden keine Nachrichten mit den angegebenen Nachrichten-IDs gefunden." + cant_update_direct_message_channel: "Eigenschaften des Direktnachrichtenkanals wie Name und Beschreibung können nicht aktualisiert werden." + not_accepting_dms: "%{username} akzeptiert derzeit leider keine Nachrichten." + actor_ignoring_target_user: "Du ignorierst %{username}, daher kannst du keine Nachrichten an die Person senden." + actor_muting_target_user: "Du hast %{username} stummgeschaltet, daher kannst du keine Nachrichten an die Person senden." + actor_disallowed_dms: "Du hast dich dafür entschieden, dass Benutzer dir keine privaten und Direktnachrichten schicken können, daher kannst du keine neuen Direktnachrichten erstellen." + actor_preventing_target_user_from_dm: "Du hast dich dafür entschieden, dass %{username} dir keine privaten und Direktnachrichten schicken kann, daher kannst du keine neuen Direktnachrichten an diese Person erstellen." + user_cannot_send_direct_messages: "Du kannst leider keine Direktnachrichten senden." + reviewables: + message_already_handled: "Danke, aber wir haben diese Nachricht bereits überprüft und festgestellt, dass sie nicht erneut markiert werden muss." + actions: + agree: + title: "Zustimmen …" + agree_and_keep_message: + title: "Nachricht behalten" + description: "Markierung zustimmen und die Nachricht unverändert beibehalten." + agree_and_keep_deleted: + title: "Nachricht gelöscht lassen" + description: "Markierung zustimmen und die Nachricht gelöscht lassen." + agree_and_suspend: + title: "Benutzer sperren" + description: "Markierung zustimmen und den Benutzer sperren." + agree_and_silence: + title: "Benutzer stummschalten" + description: "Markierung zustimmen und den Benutzer stummschalten." + agree_and_restore: + title: "Nachricht wiederherstellen" + description: "Nachricht wiederherstellen, damit Benutzer sie sehen können." + agree_and_delete: + title: "Nachricht löschen" + description: "Nachricht löschen, damit Benutzer sie nicht sehen können." + delete_and_agree: + title: "Nachricht löschen" + disagree_and_restore: + title: "Ablehnen und Nachricht wiederherstellen" + description: "Nachricht wiederherstellen, damit alle Benutzer sie sehen können." + disagree: + title: "Ablehnen" + ignore: + title: "Ignorieren" + direct_messages: + transcript_title: "Transkript früherer Nachrichten in %{channel_name}" + transcript_body: "Um dir mehr Kontext zu geben, haben wir ein Transkript der vorherigen Nachrichten in dieser Unterhaltung beigefügt (bis zu zehn):\n\n%{transcript}" + channel: + statuses: + read_only: "Schreibgeschützt" + archived: "Archiviert" + closed: "Geschlossen" + open: "Offen" + archive: + first_post_raw: "Dieses Thema ist ein Archiv des Chat-Kanals [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} hat eine Nachricht in den Kanal [%{channel_name}](%{first_moved_message_url}) verschoben." + other: "@%{acting_username} hat %{count} Nachrichten in den Kanal [%{channel_name}](%{first_moved_message_url}) verschoben." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} und %{leftover} andere" + bookmarkable: + notification_title: "Nachricht in %{channel_name}" + personal_chat: "persönlicher Chat" + onebox: + inline_to_message: "Nachricht #%{message_id} von %{username} – #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Chat für Thema %{topic_title}" + x_members: + one: "%{count} Mitglied" + other: "%{count} Mitglieder" + and_x_others: + one: "und %{count} andere Person" + other: "und %{count} andere" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} hat dich in „%{channel}“ erwähnt' + other_type: '%{username} hat %{identifier} in „%{channel}“ erwähnt' + direct_message_chat_mention: + direct: "%{username} hat dich im persönlichen Chat erwähnt" + other_type: "%{username} hat %{identifier} im persönlichen Chat erwähnt" + new_chat_message: '%{username} hat eine Nachricht in „%{channel}“ gesendet' + new_direct_chat_message: "%{username} hat eine Nachricht im persönlichen Chat gesendet" + discourse_automation: + scriptables: + send_chat_message: + title: Chat-Nachricht senden + reviewable_score_types: + needs_review: + title: "Überprüfung erforderlich" + notify_user: + chat_pm_title: 'Deine Chat-Nachricht in „%{channel_name}“' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Eine Chat-Nachricht in „%{channel_name}“ erfordert die Aufmerksamkeit des Teams' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Ein Teammitglied ist der Meinung, dass diese Chat-Nachricht überprüft werden muss." + user_notifications: + chat_summary: + deleted_user: "Gelöschter Benutzer" + description: + one: "Du hast eine neue Chat-Nachricht" + other: "Du hast neue Chat-Nachrichten" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Neue Nachricht von %{message_title}" + other: "[%{email_prefix}] Neue Nachrichten von %{message_title} und %{others}" + chat_channel: + one: "[%{email_prefix}] Neue Nachricht in %{message_title}" + other: "[%{email_prefix}] Neue Nachrichten in %{message_title} und %{others}" + other_direct_message: "von %{message_title}" + others: "%{count} andere" + unsubscribe: "Diese Chat-Zusammenfassung wird dir von %{site_link} gesendet, wenn du abwesend bist. Ändere deine %{email_preferences_link} oder %{unsubscribe_link}, um dich abzumelden." + unsubscribe_no_link: "Diese Chat-Zusammenfassung wird dir von %{site_link} gesendet, wenn du abwesend bist. Ändere deine %{email_preferences_link}." + view_messages: + one: "Nachricht anzeigen" + other: "%{count} Nachrichten anzeigen" + view_more: + one: "%{count} weitere Nachricht anzeigen" + other: "%{count} weitere Nachrichten anzeigen" + your_chat_settings: "Chat-E-Mail-Häufigkeitspräferenz" + unsubscribe: + chat_summary: + select_title: "Häufigkeit von Chat-Zusammenfassungs-E-Mails festlegen auf:" + never: Niemals + when_away: Nur bei Abwesenheit + category: + cannot_delete: + has_chat_channels: "Diese Kategorie kann nicht gelöscht werden, da sie Chat-Kanäle hat." diff --git a/plugins/chat/config/locales/server.el.yml b/plugins/chat/config/locales/server.el.yml new file mode 100644 index 0000000000..8f772e805f --- /dev/null +++ b/plugins/chat/config/locales/server.el.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: + chat: + deleted_chat_username: διεγράφη + errors: + not_accepting_dms: "Λυπούμαστε, ο/η %{username} δεν δέχεται μηνύματα αυτή τη στιγμή." + reviewables: + actions: + agree_and_suspend: + title: "Αποβολή Χρήστη" + agree_and_silence: + title: "Σίγαση Χρήστη" + disagree: + title: "Διαφωνώ" + ignore: + title: "Αγνόηση" + channel: + statuses: + closed: "Κλειστό" + open: "Ξεκίνημα" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} σε ανέφερε στο "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Ποτέ diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml new file mode 100644 index 0000000000..ea2349f2b7 --- /dev/null +++ b/plugins/chat/config/locales/server.en.yml @@ -0,0 +1,196 @@ +en: + site_settings: + chat_enabled: "Enable the chat plugin." + chat_allowed_groups: "Users in these groups can chat. Note that staff can always access chat." + chat_channel_retention_days: "Chat messages in regular channels will be retained for this many days. Set to '0' to retain messages forever." + chat_dm_retention_days: "Chat messages in personal chat channels will be retained for this many days. Set to '0' to retain messages forever." + chat_auto_silence_duration: "Number of minutes that users will be silenced for when they exceed the chat message creation rate limit. Set to '0' to disable auto-silencing." + chat_allowed_messages_for_trust_level_0: "Number of messages that trust level 0 users is allowed to send in 30 seconds. Set to '0' to disable limit." + chat_allowed_messages_for_other_trust_levels: "Number of messages that users with trust levels 1-4 is allowed to send in 30 seconds. Set to '0' to disable limit." + chat_silence_user_sensitivity: "The likelihood that a user flagged in chat will be automatically silenced." + chat_auto_silence_from_flags_duration: "Number of minutes that users will be silenced for when they are automatically silenced due to flagged chat messages." + chat_default_channel_id: "The chat channel that will be opened by default when a user has no unread messages or mentions in other channels." + chat_duplicate_message_sensitivity: "The likelihood that a duplicate message by the same sender will be blocked in a short period. Decimal number between 0 and 1.0, with 1.0 being the highest setting (blocks messages more frequently in a shorter amount of time). Set to `0` to allow duplicate messages." + chat_minimum_message_length: "Minimum number of characters for a chat message." + chat_allow_uploads: "Allow uploads in public chat channels and direct message channels." + chat_archive_destination_topic_status: "The status that the destination topic should be once a channel archive is completed. This only applies when the destination topic is a new topic, not an existing one." + default_emoji_reactions: "Default emoji reactions for chat messages. Add up to 5 emojis for quick reaction." + direct_message_enabled_groups: "Allow users within these groups to create user-to-user Personal Chats. Note: staff can always create Personal Chats, and users will be able to reply to Personal Chats initiated by users who have permission to create them." + chat_message_flag_allowed_groups: "Users in these groups are allowed to flag chat messages." + errors: + chat_default_channel: "The default chat channel must be a public channel." + direct_message_enabled_groups_invalid: "You must specify at least one group for this setting. If you do not want anyone except staff to send direct messages, choose the staff group." + chat_upload_not_allowed_secure_uploads: "Chat uploads are not allowed when secure uploads site setting is enabled." + system_messages: + chat_channel_archive_complete: + title: "Chat Channel Archive Complete" + subject_template: "Chat channel archive completed successfully" + text_body_template: | + Archiving the chat channel **\#%{channel_name}** has been completed successfully. The messages were copied into the topic [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Chat Channel Archive Failed" + subject_template: "Chat channel archive failed" + text_body_template: | + Archiving the chat channel **\#%{channel_name}** has failed. %{messages_archived} messages have been archived. Partially archived messages were copied into the topic [%{topic_title}](%{topic_url}). Visit the channel at %{channel_url} to retry. + + chat: + deleted_chat_username: deleted + errors: + channel_exists_for_category: "A channel already exists for this category and name" + channel_new_message_disallowed: "The channel is %{status}, no new messages can be sent" + channel_modify_message_disallowed: "The channel is %{status}, no messages can be edited or deleted" + user_cannot_send_message: "You cannot send messages at this time." + rate_limit_exceeded: "Exceeded the limit of chat messages that can be sent within 30 seconds" + auto_silence_from_flags: "Chat message flagged with score high enough to silence user." + channel_cannot_be_archived: "The channel cannot be archived at this time, it must be either closed or open to archive." + duplicate_message: "You posted an identical message too recently." + delete_channel_failed: "Delete channel failed, please try again." + minimum_length_not_met: "Message is too short, must have a minimum of %{minimum} characters." + max_reactions_limit_reached: "New reactions are not allowed on this message." + message_move_invalid_channel: "The source and destination channel must be public channels." + message_move_no_messages_found: "No messages were found with the provided message IDs." + cant_update_direct_message_channel: "Direct message channel properties like name and description can’t be updated." + not_accepting_dms: "Sorry, %{username} is not accepting messages at the moment." + actor_ignoring_target_user: "You are ignoring %{username}, so you cannot send messages to them." + actor_muting_target_user: "You are muting %{username}, so you cannot send messages to them." + actor_disallowed_dms: "You have chosen to prevent users from sending you private and direct messages, so you cannot create new direct messages." + actor_preventing_target_user_from_dm: "You have chosen to prevent %{username} from sending you private and direct messages, so you cannot create new direct messages to them." + user_cannot_send_direct_messages: "Sorry, you cannot send direct messages." + reviewables: + message_already_handled: "Thanks, but we've already reviewed this message and determined it does not need to be flagged again." + actions: + agree: + title: "Agree..." + agree_and_keep_message: + title: "Keep Message" + description: "Agree with flag and keep the message unchanged." + agree_and_keep_deleted: + title: "Keep Message Deleted" + description: "Agree with flag and leave the message deleted." + agree_and_suspend: + title: "Suspend User" + description: "Agree with flag and suspend the user." + agree_and_silence: + title: "Silence User" + description: "Agree with flag and silence the user." + agree_and_restore: + title: "Restore Message" + description: "Restore the message so that users can see it." + agree_and_delete: + title: "Delete Message" + description: "Delete the message so that users cannot see it." + delete_and_agree: + title: "Delete Message" + disagree_and_restore: + title: "Disagree and Restore Message" + description: "Restore the message so that all users can see it." + disagree: + title: "Disagree" + ignore: + title: "Ignore" + direct_messages: + transcript_title: "Transcript of previous messages in %{channel_name}" + transcript_body: "To give you more context, we included a transcript of the previous messages in this conversation (up to ten):\n\n%{transcript}" + channel: + statuses: + read_only: "Read Only" + archived: "Archived" + closed: "Closed" + open: "Open" + archive: + first_post_raw: "This topic is an archive of the [%{channel_name}](%{channel_url}) chat channel." + messages_moved: + one: "@%{acting_username} moved a message to the [%{channel_name}](%{first_moved_message_url}) channel." + other: "@%{acting_username} moved %{count} messages to the [%{channel_name}](%{first_moved_message_url}) channel." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} and %{leftover} others" + + category_channel: + errors: + slug_contains_non_ascii_chars: "contains non-ascii characters" + is_already_in_use: "is already in use" + + bookmarkable: + notification_title: "message in %{channel_name}" + + personal_chat: "personal chat" + + onebox: + inline_to_message: "Message #%{message_id} by %{username} – #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Chat for Topic %{topic_title}" + + x_members: + one: "%{count} member" + other: "%{count} members" + + and_x_others: + one: "and %{count} other" + other: "and %{count} others" + + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mentioned you in "%{channel}"' + other_type: '%{username} mentioned %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "%{username} mentioned you in personal chat" + other_type: "%{username} mentioned %{identifier} in personal chat" + new_chat_message: '%{username} sent a message in "%{channel}"' + new_direct_chat_message: "%{username} sent a message in personal chat" + + discourse_automation: + scriptables: + send_chat_message: + title: Send chat message + + reviewable_score_types: + needs_review: + title: "Needs Review" + notify_user: + chat_pm_title: 'Your chat message in "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'A chat message in "%{channel_name}" requires staff attention' + chat_pm_body: "%{link}\n\n%{message}" + + reviewables: + reasons: + chat_message_queued_by_staff: "A staff member thinks this chat message needs review." + user_notifications: + chat_summary: + deleted_user: "Deleted user" + description: + one: "You have a new chat message" + other: "You have new chat messages" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] New message from %{message_title}" + other: "[%{email_prefix}] New messages from %{message_title} and %{others}" + chat_channel: + one: "[%{email_prefix}] New message in %{message_title}" + other: "[%{email_prefix}] New messages in %{message_title} and %{others}" + other_direct_message: "from %{message_title}" + others: "%{count} others" + unsubscribe: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}, or %{unsubscribe_link} to unsubscribe." + unsubscribe_no_link: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}." + view_messages: + one: "View message" + other: "View %{count} messages" + view_more: + one: "View %{count} more message" + other: "View %{count} more messages" + your_chat_settings: "chat email frequency preference" + + unsubscribe: + chat_summary: + select_title: "Set chat summary emails frequency to:" + never: Never + when_away: Only when away + + category: + cannot_delete: + has_chat_channels: "Can't delete this category because it has chat channels." diff --git a/plugins/chat/config/locales/server.en_GB.yml b/plugins/chat/config/locales/server.en_GB.yml new file mode 100644 index 0000000000..2d4fa180ec --- /dev/null +++ b/plugins/chat/config/locales/server.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/chat/config/locales/server.es.yml b/plugins/chat/config/locales/server.es.yml new file mode 100644 index 0000000000..69c7e21729 --- /dev/null +++ b/plugins/chat/config/locales/server.es.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + site_settings: + chat_allowed_groups: "Los usuarios de estos grupos pueden chatear. Ten en cuenta que el personal siempre puede acceder al chat." + chat_channel_retention_days: "Los mensajes del chat en los canales regulares se conservarán durante este número de días. Poner a «0» para retener los mensajes para siempre." + chat_dm_retention_days: "Los mensajes de chat en los canales de chat personales se conservarán durante este número de días. Ponlo en «0» para retener los mensajes para siempre." + chat_auto_silence_duration: "Número de minutos que los usuarios serán silenciados cuando superen el límite de creación de mensajes de chat. Ponlo en «0» para desactivar el silenciamiento automático." + chat_allowed_messages_for_trust_level_0: "Número de mensajes que los usuarios de nivel de confianza 0 pueden enviar en 30 segundos. Ponlo en «0» para desactivar el límite." + chat_allowed_messages_for_other_trust_levels: "Número de mensajes que los usuarios con niveles de confianza 1-4 pueden enviar en 30 segundos. Ponlo en «0» para desactivar el límite." + chat_silence_user_sensitivity: "La probabilidad de que un usuario denunciado en el chat sea silenciado automáticamente." + chat_auto_silence_from_flags_duration: "Número de minutos que los usuarios serán silenciados cuando sean silenciados automáticamente debido a mensajes de chat denunciados." + chat_default_channel_id: "El canal de chat que se abrirá por defecto cuando un usuario no tenga mensajes no leídos o menciones en otros canales." + chat_duplicate_message_sensitivity: "La probabilidad de que un mensaje duplicado por el mismo remitente sea bloqueado en un corto periodo de tiempo. Número decimal entre 0 y 1,0, siendo 1,0 el ajuste más alto (bloquea los mensajes con más frecuencia en un periodo de tiempo más corto). Ponlo en «0» para permitir los mensajes duplicados." + chat_minimum_message_length: "Número mínimo de caracteres para un mensaje de chat." + chat_allow_uploads: "Permitir las subidas en los canales de chat públicos y en los canales de mensajes directos." + chat_archive_destination_topic_status: "El estado que debe tener el tema de destino una vez completado el archivo de un canal. Esto solo se aplica cuando el tema de destino es un tema nuevo, no uno existente." + default_emoji_reactions: "Reacciones emoji por defecto para los mensajes de chat. Añade hasta 5 emojis para una reacción rápida." + direct_message_enabled_groups: "Permite a los usuarios de estos grupos crear Chats Personales de usuario a usuario. Nota: el personal siempre puede crear Chats Personales, y los usuarios podrán responder a los Chats Personales iniciados por los usuarios que tienen permiso para crearlos." + chat_message_flag_allowed_groups: "Los usuarios de estos grupos pueden marcar los mensajes del chat." + errors: + chat_default_channel: "El canal de chat por defecto debe ser un canal público." + direct_message_enabled_groups_invalid: "Debes especificar al menos un grupo para esta configuración. Si no quieres que nadie, excepto el personal, envíe mensajes directos, elige el grupo del personal." + chat_upload_not_allowed_secure_uploads: "Las subidas por chat no están permitidas cuando la configuración del sitio de subidas seguras está activada." + system_messages: + chat_channel_archive_complete: + title: "Archivado del canal de chat completado" + subject_template: "El archivado del canal de chat se ha completado con éxito" + text_body_template: | + El archivado del canal de chat **\#%{channel_name}** se completó con éxito. Los mensajes se copiaron en el tema [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "No se pudo archivar el canal" + subject_template: "No se pudo archivar el canal" + text_body_template: | + El archivo del canal de chat **\#%{channel_name}** ha fallado. Se han archivado los mensajes de %{messages_archived}. Los mensajes parcialmente archivados se han copiado en el tema [%{topic_title}](%{topic_url}). Visita el canal en %{channel_url} para volver a intentarlo. + chat: + deleted_chat_username: eliminado + errors: + channel_exists_for_category: "Ya existe un canal para esta categoría y nombre" + channel_new_message_disallowed: "El canal es %{status}, no se pueden enviar nuevos mensajes" + channel_modify_message_disallowed: "El canal está %{status}, no se pueden editar ni eliminar mensajes" + user_cannot_send_message: "No puedes enviar mensajes en este momento." + rate_limit_exceeded: "Se ha superado el límite de mensajes de chat que se pueden enviar en 30 segundos" + auto_silence_from_flags: "Mensaje de chat marcado con una puntuación lo suficientemente alta como para silenciar al usuario." + channel_cannot_be_archived: "El canal no se puede archivar en este momento, debe estar cerrado o abierto para ser archivado." + duplicate_message: "Tú también publicaste un mensaje idéntico hace poco." + delete_channel_failed: "No se pudo eliminar el canal, inténtalo de nuevo." + minimum_length_not_met: "El mensaje es demasiado corto, debe tener un mínimo de %{minimum} caracteres." + max_reactions_limit_reached: "No se permiten nuevas reacciones en este mensaje." + message_move_invalid_channel: "El canal de origen y el de destino deben ser canales públicos." + message_move_no_messages_found: "No se ha encontrado ningún mensaje con los identificadores de mensaje proporcionados." + cant_update_direct_message_channel: "Las propiedades del canal de mensajes directos, como el nombre y la descripción, no se pueden actualizar." + not_accepting_dms: "Lo siento, %{username} no acepta mensajes en este momento." + actor_ignoring_target_user: "Estás ignorando a %{username}, por lo que no puedes enviarle mensajes." + actor_muting_target_user: "Estás silenciando a %{username}, por lo que no puedes enviarle mensajes." + actor_disallowed_dms: "Has elegido impedir que los usuarios te envíen mensajes privados y directos, por lo que no puedes crear nuevos mensajes directos." + actor_preventing_target_user_from_dm: "Has elegido impedir que %{username} te envíe mensajes privados y directos, por lo que no puedes crear nuevos mensajes directos para ellos." + user_cannot_send_direct_messages: "Lo sentimos, no puedes enviar mensajes directos." + reviewables: + message_already_handled: "Gracias, pero ya hemos revisado este mensaje y hemos determinado que no es necesario marcarlo de nuevo." + actions: + agree: + title: "De acuerdo..." + agree_and_keep_message: + title: "Conservar mensaje" + description: "Aceptar la denuncia y conservar el mensaje sin cambios." + agree_and_keep_deleted: + title: "Conservar el mensaje eliminado" + description: "Aceptar la denuncia y dejar el mensaje eliminado." + agree_and_suspend: + title: "Suspender al usuario" + description: "Aceptar la denuncia y suspender al usuario." + agree_and_silence: + title: "Silenciar al usuario" + description: "Aceptar la denuncia y silenciar al usuario." + agree_and_restore: + title: "Restaurar mensaje" + description: "Restaura el mensaje para que los usuarios puedan verlo." + agree_and_delete: + title: "Eliminar mensaje" + description: "Elimina el mensaje para que los usuarios no puedan verlo." + delete_and_agree: + title: "Eliminar mensaje" + disagree_and_restore: + title: "No aceptar y restaurar el mensaje" + description: "Restaura el mensaje para que todos los usuarios puedan verlo." + disagree: + title: "No estoy de acuerdo" + ignore: + title: "Ignorar" + direct_messages: + transcript_title: "Transcripción de los mensajes anteriores en %{channel_name}" + transcript_body: "Para darte más contexto, incluimos una transcripción de los mensajes anteriores de esta conversación (hasta diez):\n\n%{transcript}" + channel: + statuses: + read_only: "Solo lectura" + archived: "Archivado" + closed: "Cerrado" + open: "Abierto" + archive: + first_post_raw: "Este tema es un archivo del canal de chat de [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} movió un mensaje al canal [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} movió %{count} mensajes al canal [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} y %{leftover} otros" + bookmarkable: + notification_title: "mensaje en %{channel_name}" + personal_chat: "chat personal" + onebox: + inline_to_message: "Mensaje #%{message_id} de %{username} - #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Chat para el tema %{topic_title}" + x_members: + one: "%{count} miembro" + other: "%{count} miembros" + and_x_others: + one: "y %{count} otros" + other: "y %{count} otros" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} te mencionó en «%{channel}»' + other_type: '%{username} mencionó %{identifier} en «%{channel}»' + direct_message_chat_mention: + direct: "%{username} te mencionó en el chat personal" + other_type: "%{username} mencionó %{identifier} en el chat personal" + new_chat_message: '%{username} envió un mensaje en «%{channel}»' + new_direct_chat_message: "%{username} envió un mensaje en el chat personal" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensaje de chat + reviewable_score_types: + needs_review: + title: "Necesita revisión" + notify_user: + chat_pm_title: 'Tu mensaje de chat en «%{channel_name}»' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Un mensaje de chat en «%{channel_name}» requiere atención del personal' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Un miembro del personal cree que este mensaje de chat necesita revisión." + user_notifications: + chat_summary: + deleted_user: "Usuario eliminado" + description: + one: "Tienes un nuevo mensaje de chat" + other: "Tienes nuevos mensajes de chat" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nuevo mensaje de %{message_title}" + other: "[%{email_prefix}] Nuevos mensajes de %{message_title} y %{others}" + chat_channel: + one: "[%{email_prefix}] Nuevo mensaje en %{message_title}" + other: "[%{email_prefix}] Nuevos mensajes en %{message_title} y %{others}" + other_direct_message: "de %{message_title}" + others: "%{count} otros" + unsubscribe: "Este resumen del chat se envía desde %{site_link} cuando estás fuera. Cambia tu %{email_preferences_link}, o %{unsubscribe_link} para darte de baja." + unsubscribe_no_link: "Este resumen del chat se envía desde %{site_link} cuando estás fuera. Cambia tu %{email_preferences_link}." + view_messages: + one: "Ver mensaje" + other: "Ver %{count} mensajes" + view_more: + one: "Ver %{count} mensaje más" + other: "Ver %{count} mensajes más" + your_chat_settings: "preferencia de la frecuencia del correo electrónico del chat" + unsubscribe: + chat_summary: + select_title: "Establece la frecuencia de los correos electrónicos de resumen del chat:" + never: Nunca + when_away: Solo cuando estés ausente + category: + cannot_delete: + has_chat_channels: "No se puede eliminar esta categoría porque tiene canales de chat." diff --git a/plugins/chat/config/locales/server.et.yml b/plugins/chat/config/locales/server.et.yml new file mode 100644 index 0000000000..432df77dfc --- /dev/null +++ b/plugins/chat/config/locales/server.et.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: + chat: + deleted_chat_username: kustutatud + errors: + not_accepting_dms: "Kahjuks ei aktsepteeri %{username} hetkel sõnumeid." + reviewables: + actions: + agree_and_suspend: + title: "Peata kasutaja" + agree_and_silence: + title: "Vaigista kasutaja" + disagree: + title: "Ei nõustu" + ignore: + title: "Ignoreeri" + channel: + statuses: + closed: "Suletud" + open: "Ava" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mainis Sind teemas "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Mitte kunagi diff --git a/plugins/chat/config/locales/server.fa_IR.yml b/plugins/chat/config/locales/server.fa_IR.yml new file mode 100644 index 0000000000..7bdced30a9 --- /dev/null +++ b/plugins/chat/config/locales/server.fa_IR.yml @@ -0,0 +1,108 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + site_settings: + chat_allowed_groups: "کاربران در این گروه‌ها می‌توانند گفتگو کنند. توجه داشته باشید که کارکنان همیشه می‌توانند به گفتگو دسترسی داشته باشند." + chat_allow_uploads: "بارگذاری در کانال‌های گفتگو عمومی و کانال‌های پیام مستقیم مجاز است." + default_emoji_reactions: "واکنش‌های شکلک پیش‌فرض برای پیام‌های گفتگو. برای واکنش سریع تا ۵ شکلک اضافه کنید." + chat_message_flag_allowed_groups: "کاربران در این گروه‌ها مجاز به گزارش دادن، پیام‌های گفتگو هستند." + errors: + chat_upload_not_allowed_secure_uploads: "وقتی که در تنظیمات سایت آپلودهای ایمن فعال باشد، آپلود گفتگو مجاز نیست." + chat: + deleted_chat_username: حذف شد + errors: + channel_exists_for_category: "یک کانال دیگر از قبل برای این دسته‌بندی و نام وجود دارد" + cant_update_direct_message_channel: "ویژگی پیام مستقیم کانال مانند نام و توضیحات را نمی‌توان به‌روز کرد." + not_accepting_dms: "با عرض پوزش، کاربر %{username} در حال حاضر پیام نمی‌پذیرد." + actor_ignoring_target_user: "شما در حال نادیده گرفتن %{username} هستید، بنابراین نمی‌توانید پیامی را برای او ارسال کنید." + actor_muting_target_user: "شما در حال بی‌صدا کردن %{username} هستید، بنابراین نمی‌توانید پیامی را برای آنها ارسال کنید." + actor_disallowed_dms: "شما انتخاب کرده‌اید که از ارسال پیام‌های خصوصی و پیام‌های مستقیم در گفتگو توسط کاربران دیگر به شما جلوگیری کنیم، بنابراین نمی‌توانید پیام‌های مستقیم جدید در گفتگو ارسال کنید." + actor_preventing_target_user_from_dm: "شما انتخاب کرده‌اید که %{username}، از ارسال پیام‌های خصوصی و پیام‌های مستقیم در گفتگو برای شما جلوگیری کنیم، بنابراین نمی‌توانید پیام مستقیم جدیدی در گفتگو برای او ارسال کنید." + user_cannot_send_direct_messages: "با عرض پوزش، شما نمی‌توانید پیام مستقیم ارسال کنید." + reviewables: + message_already_handled: "با تشکر از شما، اما ما در حال حاضر این پیام را بررسی کرده‌ایم و تشخیص داده‌ایم که نیازی به گزارش دوباره ندارد." + actions: + agree: + title: "موافقم..." + agree_and_suspend: + title: "کاربر تعلیق شده" + agree_and_delete: + title: "حذف پیام" + description: "پیام را حذف کنید تا کاربران نتوانند آن را ببینند." + delete_and_agree: + title: "حذف پیام" + disagree_and_restore: + title: "مخالفت و بازگرداندن پیام" + description: "پیام را بازیابی کنید تا همه کاربران بتوانند آن را ببینند." + disagree: + title: "مخالف" + ignore: + title: "چشم پوشی" + direct_messages: + transcript_title: "رونوشت پیام‌های قبلی در %{channel_name}" + transcript_body: "برای ارائه متن بیشتر به شما، رونوشتی از پیام‌های قبلی را در این گفتگو (حداکثر ده مورد) قرار دادیم:\n\n%{transcript}" + channel: + statuses: + read_only: "فقط خواندنی" + archived: "بایگانی شد" + closed: "بسته" + open: "باز" + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} و %{leftover} نفر دیگر" + bookmarkable: + notification_title: "پیام در %{channel_name}" + personal_chat: "گفتگوی شخصی" + onebox: + inline_to_channel: "گفتگو #%{chat_channel}" + inline_to_topic_channel: "گفتگو برای موضوع %{topic_title}" + x_members: + one: "%{count} عضو" + other: "%{count} عضو" + and_x_others: + one: "و %{count} نفر دیگر" + other: "و %{count} نفر دیگر" + discourse_automation: + scriptables: + send_chat_message: + title: ارسال پیام + reviewable_score_types: + needs_review: + title: "نیاز به بررسی دارد" + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + deleted_user: "کاربر حذف شده" + description: + one: "شما یک پیام گفتگو جدیدی دارید" + other: "شما پیام‌های گفتگو جدیدی دارید" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] پیام جدید از %{message_title}" + other: "[%{email_prefix}] پیام جدید %{message_title} و %{others}" + chat_channel: + one: "[%{email_prefix}] پیام جدید در %{message_title}" + other: "[%{email_prefix}] پیام جدید در %{message_title} و %{others}" + other_direct_message: "از %{message_title}" + others: "%{count} نفر دیگر" + view_messages: + one: "مشاهده پیام" + other: "مشاهده %{count} پیام" + view_more: + one: "مشاهده %{count} پیام بیشتر" + other: "مشاهده %{count} پیام بیشتر" + unsubscribe: + chat_summary: + never: هرگز + category: + cannot_delete: + has_chat_channels: "نمی‌توان این دسته‌بندی را حذف کرد، چون دارای کانال‌های گفتگو است" diff --git a/plugins/chat/config/locales/server.fi.yml b/plugins/chat/config/locales/server.fi.yml new file mode 100644 index 0000000000..1b557934e6 --- /dev/null +++ b/plugins/chat/config/locales/server.fi.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + site_settings: + chat_allowed_groups: "Näiden ryhmien käyttäjät voivat keskustella chatissa. Huomaa, että henkilökunnalla on aina chatin käyttöoikeus." + chat_channel_retention_days: "Tavallisten kanavien chat-viestit säilytetään näin monta päivää. Viestit säilytetään ikuisesti, jos asetat arvoksi 0." + chat_dm_retention_days: "Henkilökohtaisten chat-kanavien chat-viestit säilytetään näin monta päivää. Viestit säilytetään ikuisesti, jos asetat arvoksi 0." + chat_auto_silence_duration: "Määrä minuutteina, jonka ajaksi käyttäjät hiljennetään heidän ylittäessään chat-viestien luontirajan. Voit poistaa automaattisen hiljentämisen käytöstä asettamalla arvoksi 0." + chat_allowed_messages_for_trust_level_0: "Viestien määrä, jonka luottamustason 0 käyttäjät voivat lähettää 30 sekunnin aikana. Voit poistaa rajan käytöstä asettamalla arvoksi 0." + chat_allowed_messages_for_other_trust_levels: "Viestien määrä, jonka luottamustasojen 1–4 käyttäjät voivat lähettää 30 sekunnin aikana. Voit poistaa rajan käytöstä asettamalla arvoksi 0." + chat_silence_user_sensitivity: "Todennäköisyys, että chatissa liputettu käyttäjä hiljennetään automaattisesti." + chat_auto_silence_from_flags_duration: "Määrä minuutteina, jonka ajaksi käyttäjät hiljennetään, kun heidät hiljennetään automaattisesti liputettujen chat-viestien takia." + chat_default_channel_id: "Chat-kanava, joka avataan oletuksena, kun käyttäjällä ei ole lukemattomia viestejä tai mainintoja muilla kanavilla." + chat_duplicate_message_sensitivity: "Todennäköisyys, että saman lähettäjän kaksoiskappaleviesti estetään lyhyen ajan sisällä. Desimaaliluku välillä 0–1,0, ja 1,0 on korkein asetus (estää viestit useammin lyhyemmässä ajassa). Voit sallia kaksoiskappaleviestit asettamalla arvoksi 0." + chat_minimum_message_length: "Chat-viestin merkkien vähimmäismäärä." + chat_allow_uploads: "Salli lataukset julkisilla chat-kanavilla ja yksityisviestikanavilla." + chat_archive_destination_topic_status: "Tila, jossa kohdeketjun tulisi olla, kun kanavan arkistointi on valmis. Tätä käytetään vain, kun kohdeketju on uusi ketju, ei olemassa oleva." + default_emoji_reactions: "Chat-viestien oletusarvoiset emoji-reaktiot. Lisää enintään 5 emojia nopeaa reagointia varten." + direct_message_enabled_groups: "Salli näiden ryhmien käyttäjien luoda käyttäjien välisiä henkilökohtaisia chat-keskusteluja. Huomautus: henkilökunta voi aina luoda henkilökohtaisia chat-keskusteluja, ja käyttäjät voivat vastata henkilökohtaisiin chat-keskusteluihin, jotka on aloittanut käyttäjä, jolla on oikeus luoda niitä." + chat_message_flag_allowed_groups: "Näiden ryhmien käyttäjät voivat liputtaa chat-viestejä." + errors: + chat_default_channel: "Oletus-chat-kanavan täytyy olla julkinen kanava." + direct_message_enabled_groups_invalid: "Sinun täytyy määrittää vähintään yksi ryhmä tälle asetukselle. Jos et halua muiden kuin henkilökunnan lähettävän yksityisviestejä, valitse henkilökunnan ryhmä." + chat_upload_not_allowed_secure_uploads: "Chat-lataukset eivät ole sallittuja, kun suojattujen latauksien sivustoasetus on käytössä." + system_messages: + chat_channel_archive_complete: + title: "Chat-kanavan arkistointi on valmis" + subject_template: "Chat-kanavan arkistointi on valmis" + text_body_template: | + Chat-kanavan **\#%{channel_name}** arkistointi on valmis. Viestit kopioitiin ketjuun [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Chat-kanavan arkistointi epäonnistui" + subject_template: "Chat-kanavan arkistointi epäonnistui" + text_body_template: | + Chat-kanavan **\#%{channel_name}** arkistointi epäonnistui. %{messages_archived} viestiä on arkistoitu. Osittain arkistoidut viestit kopioitiin ketjuun [%{topic_title}](%{topic_url}). Yritä uudelleen vierailemalla kanavalla osoitteessa %{channel_url}. + chat: + deleted_chat_username: poistettu + errors: + channel_exists_for_category: "Tällä alueella ja nimellä on jo olemassa kanava" + channel_new_message_disallowed: "Kanava on %{status}, uusia viestejä ei voi lähettää" + channel_modify_message_disallowed: "Kanava on %{status}, viestejä ei voi muokata tai poistaa" + user_cannot_send_message: "Et voi lähettää viestejä tällä hetkellä." + rate_limit_exceeded: "Ylitti 30 sekunnin sisällä lähetettävien chat-viestien rajan" + auto_silence_from_flags: "Chat-viesti liputettiin riittävän korkealla pistemäärällä käyttäjän hiljentämiseksi." + channel_cannot_be_archived: "Kanavaa ei voi arkistoida tällä hetkellä, sen täytyy olla suljettu tai avoinna, jotta sen voi arkistoida." + duplicate_message: "Lähetit identtisen viestin liian äskettäin." + delete_channel_failed: "Kanavan poistaminen epäonnistui, yritä uudelleen." + minimum_length_not_met: "Viesti on liian lyhyt, siinä täytyy olla vähintään %{minimum} merkkiä." + max_reactions_limit_reached: "Uusia reaktioita ei sallita tässä viestissä." + message_move_invalid_channel: "Lähde- ja kohdekanavan täytyy olla julkisia kanavia." + message_move_no_messages_found: "Annetuilla viestitunnuksilla ei löytynyt viestejä." + cant_update_direct_message_channel: "Yksityisviestikanavan ominaisuuksia, kuten nimeä ja kuvausta, ei voi päivittää." + not_accepting_dms: "%{username} ei ota vastaan viestejä tällä hetkellä." + actor_ignoring_target_user: "Ohitat käyttäjän %{username} tällä hetkellä, joten et voi lähettää hänelle viestejä." + actor_muting_target_user: "Mykistät käyttäjän %{username} tällä hetkellä, joten et voi lähettää hänelle viestejä." + actor_disallowed_dms: "Olet päättänyt estää käyttäjiä lähettämästä sinulle yksityisviestejä, joten et voi luoda uusia yksityisviestejä." + actor_preventing_target_user_from_dm: "Olet päättänyt estää käyttäjää %{username} lähettämästä sinulle yksityisviestejä, joten et voi luoda uusia yksityisviestejä hänelle." + user_cannot_send_direct_messages: "Valitettavasti et voi lähettää yksityisviestejä." + reviewables: + message_already_handled: "Kiitos, mutta olemme jo käsitelleet tämän viestin ja todenneet, ettei sitä tarvitse liputtaa uudelleen." + actions: + agree: + title: "Hyväksy..." + agree_and_keep_message: + title: "Säilytä viesti" + description: "Hyväksy liputus ja pidä viesti ennallaan." + agree_and_keep_deleted: + title: "Pidä viesti poistettuna" + description: "Hyväksy liputus ja pidä viesti poistettuna." + agree_and_suspend: + title: "Aseta käyttäjä käyttökieltoon" + description: "Hyväksy liputus ja aseta käyttäjä käyttökieltoon." + agree_and_silence: + title: "Hiljennä käyttäjä" + description: "Hyväksy liputus ja hiljennä käyttäjä." + agree_and_restore: + title: "Palauta viesti" + description: "Palauta viesti, jotta käyttäjät näkevät sen." + agree_and_delete: + title: "Poista viesti" + description: "Poista viesti, jotta käyttäjät eivät näe sitä." + delete_and_agree: + title: "Poista viesti" + disagree_and_restore: + title: "Hylkää ja palauta viesti" + description: "Palauta viesti, jotta kaikki käyttäjät näkevät sen." + disagree: + title: "Hylkää" + ignore: + title: "Ohita" + direct_messages: + transcript_title: "Kanavan %{channel_name} aiempien viestin transkriptio" + transcript_body: "Antaaksemme sinulle enemmän kontekstia lisäsimme tämän keskustelun aiempien viestien transkription (enintään kymmenen):\n\n%{transcript}" + channel: + statuses: + read_only: "Vain luku" + archived: "Arkistoitu" + closed: "Suljettu" + open: "Avoinna" + archive: + first_post_raw: "Tämä ketju on chat-kanavan [%{channel_name}](%{channel_url}) arkisto." + messages_moved: + one: "@%{acting_username} siirsi viestin kanavalle [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} siirsi %{count} viestiä kanavalle [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} ja %{leftover} muuta" + bookmarkable: + notification_title: "viesti kanavalla %{channel_name}" + personal_chat: "henkilökohtainen chat" + onebox: + inline_to_message: "Viesti %{message_id}, lähettäjä: %{username} – #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Ketjun %{topic_title} chat-keskustelu" + x_members: + one: "%{count} jäsen" + other: "%{count} jäsentä" + and_x_others: + one: "ja %{count} muu" + other: "ja %{count} muuta" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mainitsi sinut kanavalla "%{channel}"' + other_type: '%{username} mainitsi kohteen %{identifier} kanavalla "%{channel}"' + direct_message_chat_mention: + direct: "%{username} mainitsi sinut henkilökohtaisessa chatissa" + other_type: "%{username} mainitsi kohteen %{identifier} henkilökohtaisessa chatissa" + new_chat_message: '%{username} lähetti viestin kanavalla "%{channel}"' + new_direct_chat_message: "%{username} lähetti viestin henkilökohtaisessa chatissa" + discourse_automation: + scriptables: + send_chat_message: + title: Lähetä chat-viesti + reviewable_score_types: + needs_review: + title: "Vaatii käsittelyä" + notify_user: + chat_pm_title: 'Chat-viestisi kanavalla "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Chat-viesti kanavalla "%{channel_name}" vaatii henkilökunnan huomiota' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Henkilökunnan jäsenen mielestä tämä chat-viesti täytyy tarkastaa." + user_notifications: + chat_summary: + deleted_user: "Poistettu käyttäjä" + description: + one: "Sinulla on uusi chat-viesti" + other: "Sinulla on uusia chat-viestejä" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Uusi viesti käyttäjältä %{message_title}" + other: "[%{email_prefix}] Uusia viestejä käyttäjältä %{message_title} ja %{others}" + chat_channel: + one: "[%{email_prefix}] Uusi viesti kanavalta %{message_title}" + other: "[%{email_prefix}] Uusia viestejä kanavalta %{message_title} ja %{others}" + other_direct_message: "käyttäjältä %{message_title}" + others: "ja %{count} muulta" + unsubscribe: "Tämä chat-yhteenveto lähetetään sivustolta %{site_link}, kun olet poissa. Määritä %{email_preferences_link} tai %{unsubscribe_link} peruuttaaksesi tilauksen." + unsubscribe_no_link: "Tämä chat-yhteenveto lähetetään sivustolta %{site_link}, kun olet poissa. Määritä %{email_preferences_link}." + view_messages: + one: "Näytä viesti" + other: "Näytä %{count} viestiä" + view_more: + one: "Näytä %{count} viesti lisää" + other: "Näytä %{count} viestiä lisää" + your_chat_settings: "chat-sähköpostien tiheysasetus" + unsubscribe: + chat_summary: + select_title: "Aseta chat-yhteenvetosähköpostien tiheydeksi:" + never: Ei koskaan + when_away: Vain poissa ollessa + category: + cannot_delete: + has_chat_channels: "Tätä aluetta ei voi poistaa, koska siinä on chat-kanavia." diff --git a/plugins/chat/config/locales/server.fr.yml b/plugins/chat/config/locales/server.fr.yml new file mode 100644 index 0000000000..0083fbd311 --- /dev/null +++ b/plugins/chat/config/locales/server.fr.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + site_settings: + chat_allowed_groups: "Les utilisateurs de ces groupes peuvent discuter. Notez que les responsables peuvent toujours accéder à la discussion." + chat_channel_retention_days: "Les messages de discussion dans les canaux ordinaires seront conservés pendant ce nombre de jours. Fixez cette valeur sur « 0 » pour conserver les messages indéfiniment." + chat_dm_retention_days: "Les messages de discussion dans les discussions privées seront conservés pendant ce nombre de jours. Fixez cette valeur sur « 0 » pour conserver les messages indéfiniment." + chat_auto_silence_duration: "Nombre de minutes pendant lesquelles les utilisateurs seront mis en sourdine lorsqu'ils dépasseront la limite de création de messages de discussion. Réglez cette valeur sur « 0 » pour désactiver la mise en sourdine automatique." + chat_allowed_messages_for_trust_level_0: "Nombre de messages que les utilisateurs de niveau 0 sont autorisés à envoyer en 30 secondes. Réglez cette valeur sur « 0 » pour désactiver la limite." + chat_allowed_messages_for_other_trust_levels: "Nombre de messages que les utilisateurs avec des niveaux de confiance de 1 à 4 sont autorisés à envoyer en 30 secondes. Réglez cette valeur sur « 0 » pour désactiver la limite." + chat_silence_user_sensitivity: "La probabilité qu'un utilisateur signalé dans la discussion soit automatiquement mis en sourdine." + chat_auto_silence_from_flags_duration: "Nombre de minutes pendant lesquelles les utilisateurs sont mis en sourdine lorsqu'ils sont automatiquement mis en sourdine en raison de signalements de messages de discussion." + chat_default_channel_id: "Le canal de discussion qui est ouvert par défaut lorsqu'un utilisateur n'a aucun message ou mention non lue dans d'autres canaux." + chat_duplicate_message_sensitivity: "Probabilité qu'un message dupliqué du même expéditeur soit bloqué dans un court laps de temps. Nombre décimal compris entre 0 et 1,0, 1,0 étant le paramètre le plus élevé (bloque les messages plus fréquemment dans un laps de temps plus court). Réglez cette valeur sur « 0 » pour autoriser les messages dupliqués." + chat_minimum_message_length: "Nombre minimal de caractères pour un message de discussion." + chat_allow_uploads: "Autoriser les téléversements dans les canaux de discussion publics et les canaux de messagerie directe." + chat_archive_destination_topic_status: "Le statut que doit avoir le sujet de destination une fois l'archivage du canal terminé. Cela s'applique uniquement lorsque le sujet de destination est un nouveau sujet, et non un sujet existant." + default_emoji_reactions: "Réactions émoji par défaut pour les messages de discussion. Ajoutez jusqu'à 5 émojis pour une réaction rapide." + direct_message_enabled_groups: "Permettre aux utilisateurs de ces groupes de créer des discussions privées entre utilisateurs. Remarque : les responsables peuvent toujours créer des conversations privées et les utilisateurs pourront répondre aux conversations privées initiées par les utilisateurs qui sont autorisés à les créer." + chat_message_flag_allowed_groups: "Les utilisateurs de ces groupes sont autorisés à signaler les messages de discussion." + errors: + chat_default_channel: "Le canal de discussion par défaut doit être un canal public." + direct_message_enabled_groups_invalid: "Vous devez spécifier au moins un groupe pour ce paramètre. Si vous ne souhaitez pas que quiconque, à l'exception des responsables, envoie des messages privés, choisissez le groupe des responsables." + chat_upload_not_allowed_secure_uploads: "Les téléversements de discussion ne sont pas autorisés lorsque le paramètre du site de téléversement sécurisé est activé." + system_messages: + chat_channel_archive_complete: + title: "Archivage du canal de discussion terminé" + subject_template: "L'archivage du canal de discussion est terminé" + text_body_template: | + L'archivage du canal de discussion **\#%{channel_name}** a bien été effectué. Les messages ont été copiés dans le sujet [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Échec de l'archivage du canal de discussion" + subject_template: "Échec de l'archivage du canal de discussion" + text_body_template: | + L'archivage du canal de discussion **\#%{channel_name}** a échoué. Les messages de %{messages_archived} ont été archivés. Les messages partiellement archivés ont été copiés dans le sujet [%{topic_title}](%{topic_url}). Visitez le canal à l'adresse %{channel_url} pour réessayer. + chat: + deleted_chat_username: supprimé + errors: + channel_exists_for_category: "Un canal existe déjà pour cette catégorie et ce nom" + channel_new_message_disallowed: "Le canal a le statut %{status}, aucun nouveau message ne peut être envoyé" + channel_modify_message_disallowed: "Le canal a le statut %{status}, aucun message ne peut être modifié ou supprimé" + user_cannot_send_message: "Vous ne pouvez pas envoyer de messages pour le moment." + rate_limit_exceeded: "Dépassement de la limite de messages de discussion pouvant être envoyés dans les 30 secondes" + auto_silence_from_flags: "Message de discussion marqué avec un score suffisamment élevé pour mettre l'utilisateur en sourdine." + channel_cannot_be_archived: "Le canal ne peut pas être archivé pour le moment, il doit être soit fermé, soit ouvert à l'archivage." + duplicate_message: "Vous avez publié un message identique trop récemment." + delete_channel_failed: "Échec de la suppression du canal, veuillez réessayer." + minimum_length_not_met: "Le message est trop court. Il doit comporter au moins %{minimum} caractères." + max_reactions_limit_reached: "Les nouvelles réactions ne sont pas autorisées sur ce message." + message_move_invalid_channel: "Le canal source et le canal de destination doivent être des canaux publics." + message_move_no_messages_found: "Aucun message n'a été trouvé avec les ID de message fournis." + cant_update_direct_message_channel: "Les propriétés du canal de discussion privée telles que le nom et la description ne peuvent pas être mises à jour." + not_accepting_dms: "Nous sommes désolés, %{username} n'accepte pas les messages pour le moment." + actor_ignoring_target_user: "Vous ignorez %{username}, vous ne pouvez donc pas lui envoyer de messages." + actor_muting_target_user: "Vous mettez %{username} en sourdine, vous ne pouvez donc pas lui envoyer de messages." + actor_disallowed_dms: "Vous avez choisi d'empêcher les utilisateurs de vous envoyer des messages privés et directs, vous ne pouvez donc pas créer de nouveaux messages directs." + actor_preventing_target_user_from_dm: "Vous avez choisi d'empêcher %{username} de vous envoyer des messages privés et directs, vous ne pouvez donc pas lui envoyer de nouveaux messages privés." + user_cannot_send_direct_messages: "Nous sommes désolés, vous ne pouvez pas envoyer de messages privés." + reviewables: + message_already_handled: "Merci, mais nous avons déjà examiné ce message et déterminé qu'il n'a pas besoin d'être signalé à nouveau." + actions: + agree: + title: "D'accord…" + agree_and_keep_message: + title: "Conserver le message" + description: "Accepter le signalement et garder le message inchangé." + agree_and_keep_deleted: + title: "Garder le message supprimé" + description: "Accepter le signalement et laisser le message supprimé." + agree_and_suspend: + title: "Suspendre l'utilisateur" + description: "Accepter le signalement et suspendre l'utilisateur." + agree_and_silence: + title: "Désactiver l'utilisateur" + description: "Accepter le signalement et désactiver l'utilisateur." + agree_and_restore: + title: "Restaurer le message" + description: "Restaurer le message pour que les utilisateurs puissent le voir." + agree_and_delete: + title: "Supprimer le message" + description: "Supprimer le message pour que les utilisateurs ne puissent pas le voir." + delete_and_agree: + title: "Supprimer le message" + disagree_and_restore: + title: "Refuser et restaurer le message" + description: "Restaurer le message pour que tous les utilisateurs puissent le voir." + disagree: + title: "Refuser" + ignore: + title: "Ignorer" + direct_messages: + transcript_title: "Transcription des messages précédents dans le canal %{channel_name}" + transcript_body: "Pour vous donner plus de contexte, nous avons inclus une transcription des messages précédents de cette conversation (jusqu'à dix) :\n\n%{transcript}" + channel: + statuses: + read_only: "Lecture seule" + archived: "Archivé" + closed: "Fermé" + open: "Ouvert" + archive: + first_post_raw: "Ce sujet est une archive du canal de discussion [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} a déplacé un message vers le canal [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} a déplacé %{count} messages vers le canal [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} et %{leftover} autres utilisateurs" + bookmarkable: + notification_title: "message dans %{channel_name}" + personal_chat: "discussion privée" + onebox: + inline_to_message: "Message #%{message_id} par %{username} - #%{chat_channel}" + inline_to_channel: "Discussion #%{chat_channel}" + inline_to_topic_channel: "Discussion pour le sujet %{topic_title}" + x_members: + one: "%{count} membre" + other: "%{count} membres" + and_x_others: + one: "et %{count} autre utilisateur" + other: "et %{count} autres utilisateurs" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} vous a mentionné(e) dans « %{channel} »' + other_type: '%{username} a mentionné %{identifier} dans « %{channel} »' + direct_message_chat_mention: + direct: "%{username} vous a mentionné(e) dans la discussion privée" + other_type: "%{username} a mentionné %{identifier} dans la discussion privée" + new_chat_message: '%{username} a envoyé un message dans « %{channel} »' + new_direct_chat_message: "%{username} a envoyé un message dans la discussion privée" + discourse_automation: + scriptables: + send_chat_message: + title: Envoyer un message de chat + reviewable_score_types: + needs_review: + title: "Nécessite un examen" + notify_user: + chat_pm_title: 'Votre message de discussion dans « %{channel_name} »' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Un message de discussion dans « %{channel_name} » nécessite l''attention des responsables' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Un responsable pense que ce message de discussion doit être examiné." + user_notifications: + chat_summary: + deleted_user: "Utilisateur supprimé" + description: + one: "Vous avez un nouveau message de discussion" + other: "Vous avez de nouveaux messages de discussions" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nouveau message de %{message_title}" + other: "[%{email_prefix}] Nouveaux messages de %{message_title} et de %{others}" + chat_channel: + one: "[%{email_prefix}] Nouveau message dans %{message_title}" + other: "[%{email_prefix}] Nouveaux messages dans %{message_title} et %{others}" + other_direct_message: "de %{message_title}" + others: "%{count} autres" + unsubscribe: "Ce résumé de discussion est envoyé à partir de %{site_link} lorsque vous vous absentez. Changez vos %{email_preferences_link} ou %{unsubscribe_link} pour vous désabonner." + unsubscribe_no_link: "Ce résumé de discussion est envoyé à partir de %{site_link} lorsque vous vous absentez. Changez vos %{email_preferences_link}." + view_messages: + one: "Voir le message" + other: "Voir %{count} messages" + view_more: + one: "Voir %{count} message supplémentaire" + other: "Voir %{count} messages supplémentaires" + your_chat_settings: "préférence de fréquence des e-mails de discussion" + unsubscribe: + chat_summary: + select_title: "Définissez la fréquence des e-mails de résumé de discussion sur :" + never: Jamais + when_away: Seulement en cas d'absence + category: + cannot_delete: + has_chat_channels: "Impossible de supprimer cette catégorie, car elle contient des canaux de discussion." diff --git a/plugins/chat/config/locales/server.gl.yml b/plugins/chat/config/locales/server.gl.yml new file mode 100644 index 0000000000..c1abe6a185 --- /dev/null +++ b/plugins/chat/config/locales/server.gl.yml @@ -0,0 +1,44 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: + chat: + deleted_chat_username: eliminado + errors: + not_accepting_dms: "Sentímolo, %{username} non acepta mensaxes neste momento." + reviewables: + actions: + agree: + title: "De acordo..." + agree_and_suspend: + title: "Suspender un usuario" + description: "De acordo con esta denuncia e suspender o usuario." + agree_and_silence: + title: "Silenciar o usuario" + description: "De acordo con esta denuncia e silenciar o usuario." + disagree: + title: "Discrepar" + ignore: + title: "Ignorar" + channel: + statuses: + closed: "Pechado" + open: "Abrir" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mencionouno en "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Nunca diff --git a/plugins/chat/config/locales/server.he.yml b/plugins/chat/config/locales/server.he.yml new file mode 100644 index 0000000000..81e3104a81 --- /dev/null +++ b/plugins/chat/config/locales/server.he.yml @@ -0,0 +1,201 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + site_settings: + chat_enabled: "הפעלת תוסף הצ׳אט." + chat_allowed_groups: "משתמשים בקבוצות אלה יכולים לשוחח בצ׳אט. נא לשים לב שהסגל תמיד יכול לגשת לצ׳אט." + chat_channel_retention_days: "הודעות הצ׳אט בערוצים הרגילים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." + chat_dm_retention_days: "הודעות הצ׳אט בערוצי הצ׳אט האישיים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." + chat_auto_silence_duration: "מספר הדקות שבמהלכן שמשתמשים יושתקו כאשר הם חורגים ממגבלת קצב יצירת הודעת בצ׳אט. 0 משבית את ההשתקה האוטומטית." + chat_allowed_messages_for_trust_level_0: "מספר ההודעות שמשתמשים בדרגת אמון 0 רשאים לשלוח תוך 30 שניות. ‚0’ כדי להשבית את המגבלה." + chat_allowed_messages_for_other_trust_levels: "מספר ההודעות שמשתמשים בדרגות אמון 1-4 רשאים לשלוח תוך 30 שניות. ‚0’ כדי להשבית את המגבלה." + chat_silence_user_sensitivity: "הסבירות שמשתמש שסומן בצ׳אט יושתק אוטומטית." + chat_auto_silence_from_flags_duration: "מספר דקות השתקת המשתמשים כאשר הם מושתקים אוטומטית עקב הודעות צ׳אט מסומנות." + chat_default_channel_id: "ערוץ הצ׳אט שייפתח כברירת מחדל כאשר למשתמש אין הודעות או אזכורים שלא נקראו בערוצים אחרים." + chat_duplicate_message_sensitivity: "הסבירות שהודעה כפולה מאותו שולח תיחסם תוך זמן קצר. מספר עשרוני בין 0 ל־1.0, כאשר 1.0 הוא ההגדרה הגבוהה ביותר (חוסם הודעות בתדירות גבוהה יותר בפרק זמן קצר יותר). ‚0’ כדי לאפשר הודעות כפולות." + chat_minimum_message_length: "מספר התווים המזערי להודעת צ׳אט." + chat_allow_uploads: "לאפשר העלאות בערוצי צ׳אט ציבוריים ובערוצי הודעות ישירות." + chat_archive_destination_topic_status: "המצב בו נושא היעד צריך להיות לאחר שהעברת ערוץ לארכיון הושלמה. חל רק כאשר נושא היעד הוא נושא חדש ולא קיים." + default_emoji_reactions: "רגשות אמוג׳י כברירת מחדל להודעות צ׳אט. ניתן להוסיף עד 5 אמוג׳ים לתגובה מהירה." + direct_message_enabled_groups: "לאפשר למשתמשים בקבוצות אלה ליצור צ׳אטים אישיים בין המשתמשים לבין עצמם. הערה: הסגל תמיד יכול ליצור צ׳אטים אישיים, ומשתמשים יוכלו להשיב לצ׳אטים אישיים שיזמו משתמשים שיש להם הרשאה ליצור אותם." + chat_message_flag_allowed_groups: "משתמשים בקבוצות אלו רשאים לסמן הודעות צ׳אט בדגל." + errors: + chat_default_channel: "ערוץ הצ׳אט כברירת המחדל חייב להיות ערוץ ציבורי." + direct_message_enabled_groups_invalid: "יש לציין לפחות קבוצה אחת בהגדרה הזאת. כדי למנוע מכולם לשלוח הודעות ישירות למעט הסגל, יש לבחור בקבוצת הסגל." + chat_upload_not_allowed_secure_uploads: "אסור להעלות לצ׳אט כשהגדרת האתר להעלאות מאובטחות מופעלת." + system_messages: + chat_channel_archive_complete: + title: "העברת ערוץ הצ׳אט לארכיון הושלמה" + subject_template: "העברת ערוץ הצ׳אט לארכיון הושלמה בהצלחה" + text_body_template: | + העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון הושלמה בהצלחה. ההודעות הועתקו לנושא [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "העברת הצ׳אט לארכיון נכשלה" + subject_template: "העברת הצ׳אט לארכיון נכשלה" + text_body_template: | + העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון נכשלה. %{messages_archived} הודעות הועברו לארכיון. הודעות שהועברו לארכיון באופן חלקי הועתקו לנושא [%{topic_title}](%{topic_url}). יש לבקר בכתובת הערוץ %{channel_url} כדי לנסות שוב. + chat: + deleted_chat_username: נמחק + errors: + channel_exists_for_category: "כבר קיים ערוץ לקטגוריה ולשם האלו" + channel_new_message_disallowed: "הערוץ %{status}, לא ניתן לשלוח הודעות חדשות" + channel_modify_message_disallowed: "הערוץ %{status}, לא ניתן לערוך או למחוק הודעות" + user_cannot_send_message: "אין לך אפשרות לשלוח הודעות כרגע." + rate_limit_exceeded: "חריגה ממגבלת הודעות הצ׳אט שניתן לשלוח תוך 30 שניות" + auto_silence_from_flags: "הודעת צ׳אט שסומנה בציון גבוה מספיק כדי להשתיק את המשתמש." + channel_cannot_be_archived: "אי אפשר להעביר את הערוץ לארכיון כרגע, הוא חייב להיות סגור או פתוח להעברה לארכיון." + duplicate_message: "פרסמת הודעה זהה לפני זמן קצר מדי." + delete_channel_failed: "מחיקת הערוץ נכשלה, נא לנסות שוב." + minimum_length_not_met: "ההודעה קצרה מדי, היא חייבת להיות ארוכה מ־%{minimum} תווים" + max_reactions_limit_reached: "רגשות חדשים אסורים בהודעה זו." + message_move_invalid_channel: "ערוצי המקור והיעד חייבים להיות ערוצים ציבוריים." + message_move_no_messages_found: "לא נמצאו הודעות עם מזהי ההודעות שסופקו." + cant_update_direct_message_channel: "מאפייני ערוץ הודעות ישירות כמו שם ותיאור נעולים מפני עדכון." + not_accepting_dms: "מצטעים, %{username} לא מקבל הודעות כרגע." + actor_ignoring_target_user: "בחרת להתעלם מ־%{username}, כך שאין לך אפשרות לשלוח אליהם הודעות." + actor_muting_target_user: "בחרת להשתיק את %{username}, כך שאין לך אפשרות לשלוח אליהם הודעות." + actor_disallowed_dms: "בחרת למנוע ממשתמשים לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות." + actor_preventing_target_user_from_dm: "בחרת למנוע מ־%{username} לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות אליהם." + user_cannot_send_direct_messages: "מחילה, אין לך אפשרות לשלוח הודעות ישירות." + reviewables: + message_already_handled: "תודה, אבל כבר סקרנו הודעה זו וקבענו שאין צורך לסמן אותה שוב." + actions: + agree: + title: "הסכמה…" + agree_and_keep_message: + title: "להשאיר את ההודעה" + description: "להסכים עם הסימון ולהשאיר את ההודעה ללא שינוי." + agree_and_keep_deleted: + title: "להשאיר את ההודעה מחוקה" + description: "להסכים עם הסימון ולהשאיר את ההודעה מחוקה." + agree_and_suspend: + title: "השעיית משתמש" + description: "הבעת הסכמה עם הדגל והשעיית המשתמש." + agree_and_silence: + title: "השתקת משתמש" + description: "הבעת הסכמה עם הדגל והשתקת המשתמש." + agree_and_restore: + title: "שחזור הודעה" + description: "לשחזר את ההודעה כדי שמשתמשים יוכלו לראות אותה." + agree_and_delete: + title: "מחיקת ההודעה" + description: "למחוק את ההודעה כדי שמשתמשים לא יוכלו לראות אותה." + delete_and_agree: + title: "מחיקת ההודעה" + disagree_and_restore: + title: "חוסר הסכמה ושחזור ההודעה" + description: "לשחזר את ההודעה כדי שכל המשתמשים יוכלו לראות אותה." + disagree: + title: "אי קבלה" + ignore: + title: "התעלמות" + direct_messages: + transcript_title: "תמלול הודעות קודמות בערוץ %{channel_name}" + transcript_body: "כדי לתת לך יותר הקשר, הוספנו תמליל של (עד עשר) ההודעות הקודמות בשיחה זו:\n\n%{transcript}" + channel: + statuses: + read_only: "לקריאה בלבד" + archived: "בארכיון" + closed: "סגורה" + open: "פתיחה" + archive: + first_post_raw: "הנושא הזה הוא הארכיון של ערוץ הצ׳אט [%{channel_name}](%{channel_url})." + messages_moved: + one: "הודעה הועברה על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + two: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + many: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + other: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} ועוד %{leftover}" + bookmarkable: + notification_title: "הודעה ב־%{channel_name}" + personal_chat: "צ׳אט אישי" + onebox: + inline_to_message: "הודעה מס׳ %{message_id} מאת ‎%{username}‏ – ‎#%{chat_channel}" + inline_to_channel: "צ׳אט מס׳ %{chat_channel}" + inline_to_topic_channel: "צ׳אט לנושא %{topic_title}" + x_members: + one: "חבר %{count}" + two: "%{count} חברים" + many: "%{count} חברים" + other: "%{count} חברים" + and_x_others: + one: "ועוד %{count}" + two: "ו־%{count} נוספים" + many: "ו־%{count} נוספים" + other: "ו־%{count} נוספים" + discourse_push_notifications: + popup: + chat_mention: + direct: 'אוזכרת בערוץ „%{channel}” על ידי %{username}' + other_type: 'נוסף אזכור של %{identifier} בערוץ „%{channel}” על ידי %{username}' + direct_message_chat_mention: + direct: "אוזכרת בצ׳אט אישי על ידי %{username}" + other_type: "נוסף אזכור של ‎%{identifier} בצ׳אט אישי על ידי %{username}" + new_chat_message: 'נשלחה הודעה על ידי %{username} ב־„%{channel}”' + new_direct_chat_message: "נשלחה הודעה על ידי %{username} בצ׳אט אישי" + discourse_automation: + scriptables: + send_chat_message: + title: שליחת הודעת צ׳אט + reviewable_score_types: + needs_review: + title: "נדרשת סקירה" + notify_user: + chat_pm_title: 'הודעת הצ׳אט שלך תחת „%{channel_name}”' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'הודעת צ׳אט בערוץ „%{channel_name}” דורשת את תשומת לב הסגל' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "חבר סגל חושב שהודעת צ׳אט זו דורשת בדיקה." + user_notifications: + chat_summary: + deleted_user: "משתמש שנמחק" + description: + one: "יש לך הודעה חדשה בצ׳אט" + two: "יש לך הודעות חדשות בצ׳אט" + many: "יש לך הודעות חדשות בצ׳אט" + other: "יש לך הודעות חדשות בצ׳אט" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] הודעה חדשה מאת %{message_title}" + two: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + many: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + other: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + chat_channel: + one: "[%{email_prefix}] הודעה חדשה תחת %{message_title}" + two: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + many: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + other: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + other_direct_message: "מאת %{message_title}" + others: "%{count} נוספים" + unsubscribe: "סיכום צ׳אט זה נשלח מהאתר %{site_link} כשנעדרת ממנו. ניתן לשנות את %{email_preferences_link} שלך או %{unsubscribe_link} כדי להפסיק לקבל הודעות." + unsubscribe_no_link: "סיכום צ׳אט זה נשלח מהאתר %{site_link} כשנעדרת ממנו. ניתן לשנות את %{email_preferences_link} שלך." + view_messages: + one: "הצגת הודעה" + two: "הצגת %{count} הודעות" + many: "הצגת %{count} הודעות" + other: "הצגת %{count} הודעות" + view_more: + one: "הצגת הודעה נוספת %{count}" + two: "הצגת %{count} הודעות נוספות" + many: "הצגת %{count} הודעות נוספות" + other: "הצגת %{count} הודעות נוספות" + your_chat_settings: "העדפת תדירות דוא״ל צ׳אט" + unsubscribe: + chat_summary: + select_title: "הגדרת תדירות הודעות סיכום בדוא״ל ל־:" + never: לעולם לא + when_away: רק כשלא במערכת + category: + cannot_delete: + has_chat_channels: "לא ניתן למחוק את הקטגוריה הזו כי יש לה ערוצי צ׳אט." diff --git a/plugins/chat/config/locales/server.hr.yml b/plugins/chat/config/locales/server.hr.yml new file mode 100644 index 0000000000..2f600fb0c0 --- /dev/null +++ b/plugins/chat/config/locales/server.hr.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + chat: + deleted_chat_username: izbrisao + errors: + not_accepting_dms: "Žao nam je, %{username} trenutno ne prihvaća poruke." + reviewables: + actions: + agree_and_suspend: + title: "Suspendiraj korisnika" + agree_and_silence: + title: "Ušuti korisnika" + disagree: + title: "Odbaci" + ignore: + title: "Zanemari" + channel: + statuses: + closed: "Zatvoreno" + open: "Otvori" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} vas je spomenuo u "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Nikad diff --git a/plugins/chat/config/locales/server.hu.yml b/plugins/chat/config/locales/server.hu.yml new file mode 100644 index 0000000000..f8d08804ec --- /dev/null +++ b/plugins/chat/config/locales/server.hu.yml @@ -0,0 +1,151 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + site_settings: + chat_channel_retention_days: "A normál csatornákon lévő csevegőüzenetek ennyi napig maradnak meg. Állítsa „0”-ra, hogy örökre megtartsa az üzeneteket." + chat_dm_retention_days: "A személyes csevegési csatornákon lévő csevegőüzenetek ennyi napig maradnak meg. Állítsa „0”-ra, hogy örökre megtartsa az üzeneteket." + chat_auto_silence_duration: "A felhasználók ennyi percig lesznek némítva, ha túllépik a csevegőüzenet létrehozási korlátját. Állítsa „0”-ra, hogy letiltsa az automatikus némítást." + chat_allowed_messages_for_trust_level_0: "A 0-s megbízhatósági szinttel rendelkező felhasználók legfeljebb ennyi üzenetet küldhetnek 30 másodpercen belül. A korlátozás letiltásához állítsa „0”-ra." + chat_allowed_messages_for_other_trust_levels: "Az 1–4-es megbízhatósági szinttel rendelkező felhasználók legfeljebb ennyi üzenetet küldhetnek 30 másodpercen belül. A korlátozás letiltásához állítsa „0”-ra." + chat_silence_user_sensitivity: "Annak a valószínűsége, hogy a csevegésben megjelölt felhasználó automatikusan némítva lesz." + chat_auto_silence_from_flags_duration: "Azon percek száma, ameddig a felhasználók el lesznek némítva, ha a megjelölt csevegési üzenetek miatt automatikusan némítva lesznek." + chat_default_channel_id: "Az a csevegőcsatorna, amely alapértelmezés szerint megnyílik, ha a felhasználónak nincsenek olvasatlan üzenetei vagy említései más csatornákon." + chat_duplicate_message_sensitivity: "Annak a valószínűsége, hogy az azonos feladó által küldött ismételt üzenet rövid időn belül blokkolásra kerül. Tizedes szám 0 és 1.0 között, ahol az 1.0 a legmagasabb érték (az üzeneteket gyakrabban blokkolja rövidebb idő alatt). Az ismételt üzenetek engedélyezéséhez állítsa „0”-ra az értéket." + chat_minimum_message_length: "A csevegőüzenetek minimális karakterszáma." + chat_archive_destination_topic_status: "Az az állapot, amelyet a céltéma a csatornaarchiválás befejezése után fog kapni. Ez csak akkor érvényes, ha a céltéma egy új téma, nem pedig egy meglévő." + errors: + chat_default_channel: "Az alapértelmezett csevegőcsatornának nyilvános csatornának kell lennie." + system_messages: + chat_channel_archive_complete: + title: "A csevegőcsatorna archiválása kész" + subject_template: "A csevegőcsatorna archiválása sikeresen befejeződött" + text_body_template: | + A(z) **\#%{channel_name}** csevegőcsatorna archiválása sikeresen befejeződött. Az üzenetek átmásolásra kerültek a(z) [ <%{topic_title}](%{topic_url}) témába. + chat_channel_archive_failed: + title: "A csevegőcsatorna archiválása sikertelen" + subject_template: "A csevegőcsatorna archiválása sikertelen" + text_body_template: | + A(z) **\#%{channel_name}** csevegőcsatorna archiválása nem sikerült. %{messages_archived} üzenet archiválásra került. A részben archivált üzeneteket a(z) [%{topic_title}](%{topic_url}) témába lettek másolva. Keresse fel a(z) %{channel_url} csatornát az újbóli próbálkozáshoz. + chat: + deleted_chat_username: törölt + errors: + channel_exists_for_category: "Már létezik csatorna ehhez a kategóriával, és ezzel a névvel" + channel_new_message_disallowed: "A csatorna „%{status}”, új üzenet nem küldhető" + channel_modify_message_disallowed: "A csatorna „%{status}”, az üzenetek nem szerkeszthetők vagy törölhetők" + user_cannot_send_message: "Jelenleg nem küldhet üzeneteket." + rate_limit_exceeded: "Túllépte a 30 másodpercen belül elküldhető csevegőüzenetek korlátját" + auto_silence_from_flags: "A csevegőüzenet elég magas pontszámmal lett megjelölve, hogy a felhasználó némítva legyen." + channel_cannot_be_archived: "A csatorna jelenleg nem archiválható, a csatornát vagy le kell zárni, vagy meg kell nyitni az archiváláshoz." + duplicate_message: "Nemrég küldött egy azonos tartalmú üzenetet." + delete_channel_failed: "A csatorna törlése sikertelen, próbálja meg újra." + minimum_length_not_met: "Az üzenet túl rövid, legalább %{minimum} karaktert kell tartalmaznia." + max_reactions_limit_reached: "Új reakciók nem engedélyezettek ezen az üzeneten." + message_move_invalid_channel: "A forrás- és célcsatornának nyilvános csatornának kell lennie." + message_move_no_messages_found: "A megadott üzenetazonosítókkal nem találhatók üzenetek." + reviewables: + actions: + agree: + title: "Egyetértek…" + agree_and_keep_message: + title: "Üzenet megtartása" + description: "Egyetért a jelentéssel, és változatlanul hagyja az üzenetet." + agree_and_keep_deleted: + title: "Üzenet törölve hagyása" + description: "Egyetért a jelentéssel, és törölve hagyja az üzenetet." + agree_and_suspend: + title: "Felhasználó felfüggesztése" + description: "Egyetértés a megjelöléssel, és a felhasználó felfüggesztése" + agree_and_silence: + title: "Felhasználó némítása" + description: "Egyetértés a megjelöléssel, és a felhasználó némítása" + agree_and_restore: + title: "Üzenet helyreállítása" + description: "Üzenet helyreállítása, hogy láthassák a felhasználók." + agree_and_delete: + title: "Üzenet törlése" + description: "Üzenet törlése, hogy a felhasználók ne láthassák." + delete_and_agree: + title: "Üzenet törlése" + disagree_and_restore: + title: "Nem ért egyet, és az üzenet helyreállítása" + description: "Üzenet helyreállítása, hogy az összes felhasználó láthassa." + disagree: + title: "Elutasítás" + ignore: + title: "Letiltás" + channel: + statuses: + read_only: "Csak olvasható" + archived: "Archivált" + closed: "Zárt" + open: "Megnyitás" + archive: + first_post_raw: "Ez a téma a(z) [%{channel_name}](%{channel_url}) csevegőcsatorna archívuma." + messages_moved: + one: "@%{acting_username} áthelyezett egy üzenetet a(z) [%{channel_name}](%{first_moved_message_url}) csatornába." + other: "@%{acting_username} áthelyezett %{count} üzenetet a(z) [%{channel_name}](%{first_moved_message_url}) csatornába." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} és még %{leftover} fő" + personal_chat: "személyes csevegés" + onebox: + x_members: + one: "%{count} tag" + other: "%{count} tag" + and_x_others: + one: "és még %{count} fő" + other: "és még %{count} fő" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} megemlítette Önt a következő csatornán: „%{channel}”' + other_type: '%{username} megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + direct_message_chat_mention: + direct: "%{username} megemlítette Önt egy személyes csevegésben" + other_type: "%{username} megemlítette %{identifier} felhasználót egy személyes csevegésben" + new_chat_message: '%{username} üzenet küldött a következő csatornán: „%{channel}”' + new_direct_chat_message: "%{username} üzenetet küldött egy személyes csevegésben" + discourse_automation: + scriptables: + send_chat_message: + title: Csevegőüzenet küldése + reviewable_score_types: + needs_review: + title: "Felülvizsgálatra szorul" + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + deleted_user: "Törölt felhasználó" + description: + one: "Új csevegőüzenete érkezett" + other: "Új csevegőüzenetei érkeztek" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Új üzenet a következőtől: %{message_title}" + other: "[%{email_prefix}] Új üzenetek a következőktől: %{message_title} és %{others}" + other_direct_message: "a következőtől: %{message_title}" + others: "és még %{count} fő" + view_messages: + one: "Üzenet megtekintése" + other: "%{count} üzenet megtekintése" + view_more: + one: "%{count} további üzenet megtekintése" + other: "%{count} további üzenet megtekintése" + your_chat_settings: "csevegési e-mail gyakoriságának beállítása" + unsubscribe: + chat_summary: + select_title: "A csevegési összefoglaló e-mailek gyakoriságának beállítása:" + never: Soha + when_away: Csak ha távol van + category: + cannot_delete: + has_chat_channels: "Ezt a kategóriát nem lehet törölni, mert csevegőcsatornái vannak." diff --git a/plugins/chat/config/locales/server.hy.yml b/plugins/chat/config/locales/server.hy.yml new file mode 100644 index 0000000000..6248aff03a --- /dev/null +++ b/plugins/chat/config/locales/server.hy.yml @@ -0,0 +1,39 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: + chat: + deleted_chat_username: ջնջված + errors: + not_accepting_dms: "Ներողություն, %{username}-ը այս պահին հաղորդագրություններ չի ընդունում:" + reviewables: + actions: + agree_and_suspend: + title: "Սառեցնել Օգտատիրոջը" + description: "Ընդունել դրոշակավորումը և սառեցնել օգտատիրոջը:" + agree_and_silence: + title: "Լռեցնել Օգտատիրոջը" + description: "Ընդունել դրոշակը և սառեցնել օգտատիրոջը:" + disagree: + title: "Չընդունել" + ignore: + title: "Անտեսել" + channel: + statuses: + closed: "Փակված" + open: "Բացել" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} -ը նշել է Ձեզ այստեղ՝ "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Երբեք diff --git a/plugins/chat/config/locales/server.id.yml b/plugins/chat/config/locales/server.id.yml new file mode 100644 index 0000000000..7b8b81cc5c --- /dev/null +++ b/plugins/chat/config/locales/server.id.yml @@ -0,0 +1,25 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: + chat: + deleted_chat_username: dihapus + reviewables: + actions: + ignore: + title: "Abaikan" + channel: + statuses: + closed: "Tertutup" + open: "Buka" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Tidak pernah diff --git a/plugins/chat/config/locales/server.it.yml b/plugins/chat/config/locales/server.it.yml new file mode 100644 index 0000000000..6b935f8dd1 --- /dev/null +++ b/plugins/chat/config/locales/server.it.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + site_settings: + chat_allowed_groups: "Gli utenti di questi gruppi possono chattare. Tieni presente che lo staff può sempre accedere alla chat." + chat_channel_retention_days: "I messaggi di chat nei canali normali saranno conservati per i giorni indicati. Impostare il parametro a '0' per conservare i messaggi per sempre." + chat_dm_retention_days: "I messaggi di chat nei canali di chat personali saranno conservati per i giorni indicati. Impostare il parametro a '0' per conservare i messaggi per sempre." + chat_auto_silence_duration: "Numero di minuti per i quali gli utenti verranno silenziati quando superano il limite di velocità di creazione dei messaggi di chat. Imposta il valore su '0' per disabilitare il silenziamento automatico." + chat_allowed_messages_for_trust_level_0: "Numero di messaggi che gli utenti con livello di attendibilità 0 possono inviare in 30 secondi. Imposta su '0' per disabilitare il limite." + chat_allowed_messages_for_other_trust_levels: "Numero di messaggi che gli utenti con livello di attendibilità 1-4 possono inviare in 30 secondi. Imposta su '0' per disabilitare il limite." + chat_silence_user_sensitivity: "La probabilità che un utente segnalato in chat venga automaticamente silenziato." + chat_auto_silence_from_flags_duration: "Numero di minuti per i quali gli utenti verranno silenziati automaticamente a causa di messaggi di chat segnalati." + chat_default_channel_id: "Il canale di chat che verrà aperto per impostazione predefinita quando un utente non ha messaggi non letti o menzioni in altri canali." + chat_duplicate_message_sensitivity: "La probabilità che un messaggio duplicato dello stesso mittente venga bloccato in breve tempo. Numero decimale compreso tra 0 e 1,0, dove 1,0 è l'impostazione più alta (blocca i messaggi più frequentemente in un lasso di tempo più breve). Imposta su `0` per consentire messaggi duplicati." + chat_minimum_message_length: "Numero minimo di caratteri per un messaggio di chat." + chat_allow_uploads: "Consenti caricamenti nei canali di chat pubblici e nei canali di messaggistica diretta." + chat_archive_destination_topic_status: "Lo stato che dovrebbe avere l'argomento di destinazione una volta completata l'archiviazione di un canale. Questa opzione si applica solo quando l'argomento di destinazione è nuovo, non già esistente." + default_emoji_reactions: "Reazioni emoji predefinite per i messaggi di chat. Aggiungi fino a 5 emoji per una reazione rapida." + direct_message_enabled_groups: "Consenti agli utenti all'interno di questi gruppi di creare chat personali da utente a utente. Nota: lo staff può sempre creare chat personali e gli utenti potranno rispondere alle chat personali avviate da utenti che dispongono dell'autorizzazione per crearle." + chat_message_flag_allowed_groups: "Gli utenti di questi gruppi possono contrassegnare i messaggi di chat." + errors: + chat_default_channel: "Il canale di chat predefinito deve essere un canale pubblico." + direct_message_enabled_groups_invalid: "Devi specificare almeno un gruppo per questa impostazione. Se non vuoi che nessuno al di fuori dello staff possa inviare messaggi diretti, scegli il gruppo dello staff." + chat_upload_not_allowed_secure_uploads: "I caricamenti di chat non sono consentiti quando l'impostazione del sito per i caricamenti sicuri è abilitata." + system_messages: + chat_channel_archive_complete: + title: "Archiviazione canale chat completata" + subject_template: "Archiviazione del canale di chat completata correttamente" + text_body_template: | + L'archiviazione del canale di chat **\#%{channel_name}** è stata completata con successo. I messaggi sono stati copiati nell'argomento [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Archiviazione canale chat non riuscita" + subject_template: "Archiviazione canale chat non riuscita" + text_body_template: | + L'archiviazione del canale di chat **\#%{channel_name}** non è riuscita. %{messages_archived} messaggi sono stati archiviati. I messaggi parzialmente archiviati sono stati copiati nell'argomento [%{topic_title}](%{topic_url}). Visita il canale in %{channel_url} per riprovare. + chat: + deleted_chat_username: eliminato + errors: + channel_exists_for_category: "Esiste già un canale per questa categoria e questo nome" + channel_new_message_disallowed: "Il canale è %{status}, non è possibile inviare nuovi messaggi" + channel_modify_message_disallowed: "Il canale è %{status}, nessun messaggio può essere modificato o cancellato" + user_cannot_send_message: "In questo momento non puoi inviare messaggi." + rate_limit_exceeded: "Superato il limite dei messaggi di chat che possono essere inviati in 30 secondi" + auto_silence_from_flags: "Messaggio di chat contrassegnato con un punteggio sufficientemente alto per silenziare l'utente." + channel_cannot_be_archived: "Il canale non può essere archiviato in questo momento, deve essere chiuso o aperto per l'archiviazione." + duplicate_message: "Hai pubblicato un messaggio identico troppo di recente." + delete_channel_failed: "Eliminazione del canale non riuscita, riprova." + minimum_length_not_met: "Il messaggio è troppo breve, deve contenere almeno %{minimum} caratteri." + max_reactions_limit_reached: "Non sono consentite nuove reazioni su questo messaggio." + message_move_invalid_channel: "I canali di origine e di destinazione devono essere canali pubblici." + message_move_no_messages_found: "Nessun messaggio è stato trovato con gli ID messaggio forniti." + cant_update_direct_message_channel: "Le proprietà del canale dei messaggi diretti come il nome e la descrizione non possono essere aggiornate." + not_accepting_dms: "Spiacenti, %{username} non accetta messaggi al momento." + actor_ignoring_target_user: "Stai ignorando %{username}, quindi non puoi inviare messaggi a questo destinatario." + actor_muting_target_user: "Hai silenziato %{username}, quindi non puoi inviare messaggi a questo destinatario." + actor_disallowed_dms: "Hai scelto di impedire agli utenti di inviarti messaggi privati e diretti, quindi non puoi creare nuovi messaggi diretti." + actor_preventing_target_user_from_dm: "Hai scelto di impedire a %{username} di inviarti messaggi privati e diretti, quindi non puoi creare nuovi messaggi diretti per questo destinatario." + user_cannot_send_direct_messages: "Spiacenti, non puoi inviare messaggi diretti." + reviewables: + message_already_handled: "Grazie, ma abbiamo già esaminato questo messaggio e stabilito che non è necessario contrassegnarlo di nuovo." + actions: + agree: + title: "Accetta..." + agree_and_keep_message: + title: "Conserva messaggio" + description: "Accetta segnalazione e mantieni il messaggio invariato." + agree_and_keep_deleted: + title: "Conferma eliminazione del messaggio" + description: "Accetta segnalazione e conferma eliminazione del messaggio." + agree_and_suspend: + title: "Sospendi utente" + description: "Accetta segnalazione e sospendi l'utente." + agree_and_silence: + title: "Silenzia utente" + description: "Accetta segnalazione e silenzia l'utente." + agree_and_restore: + title: "Ripristina messaggio" + description: "Ripristina il messaggio in modo che gli utenti possano vederlo." + agree_and_delete: + title: "Elimina messaggio" + description: "Elimina il messaggio in modo che gli utenti non possano vederlo." + delete_and_agree: + title: "Elimina messaggio" + disagree_and_restore: + title: "Rifiuta e ripristina il messaggio" + description: "Ripristina il messaggio in modo che tutti gli utenti possano vederlo." + disagree: + title: "Rifiuta" + ignore: + title: "Ignora" + direct_messages: + transcript_title: "Trascrizione dei messaggi precedenti in %{channel_name}" + transcript_body: "Per darti più contesto, abbiamo incluso una trascrizione dei messaggi precedenti in questa conversazione (fino a dieci):\n\n%{transcript}" + channel: + statuses: + read_only: "Sola lettura" + archived: "Archiviati" + closed: "Chiusi" + open: "Aperto" + archive: + first_post_raw: "Questo argomento è un archivio del canale di chat [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} ha spostato un messaggio nel canale [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} ha spostato %{count} messaggi sul canale [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} e altri %{leftover}" + bookmarkable: + notification_title: "messaggio in %{channel_name}" + personal_chat: "chat personale" + onebox: + inline_to_message: "Messaggio n.%{message_id} per %{username} – n.%{chat_channel}" + inline_to_channel: "Chat n. %{chat_channel}" + inline_to_topic_channel: "Chat per l'argomento %{topic_title}" + x_members: + one: "%{count} membro" + other: "%{count} membri" + and_x_others: + one: "e %{count} altro" + other: "e %{count} altri" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} ti ha menzionato in "%{channel}"' + other_type: '%{username} ha menzionato %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "%{username} ti ha menzionato nella chat personale" + other_type: "%{username} ha menzionato %{identifier} nella chat personale" + new_chat_message: '%{username} ha inviato un messaggio in "%{channel}"' + new_direct_chat_message: "%{username} ha inviato un messaggio nella chat personale" + discourse_automation: + scriptables: + send_chat_message: + title: Invia messaggio di chat + reviewable_score_types: + needs_review: + title: "Necessita di revisione" + notify_user: + chat_pm_title: 'Tuo messaggio di chat in "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Un messaggio di chat in "%{channel_name}" richiede l''attenzione del personale' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Un membro dello staff ritiene che questo messaggio di chat debba essere rivisto." + user_notifications: + chat_summary: + deleted_user: "Utente eliminato" + description: + one: "Hai un nuovo messaggio di chat" + other: "Hai nuovi messaggi di chat" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nuovo messaggio da %{message_title}" + other: "[%{email_prefix}] Nuovi messaggi da %{message_title} e %{others}" + chat_channel: + one: "[%{email_prefix}] Nuovo messaggio in %{message_title}" + other: "[%{email_prefix}] Nuovi messaggi in %{message_title} e %{others}" + other_direct_message: "da %{message_title}" + others: "e %{count} altri" + unsubscribe: "Questo riepilogo della chat viene inviato da %{site_link} quando non ci sei. Modifica %{email_preferences_link} o %{unsubscribe_link} per annullare l'iscrizione." + unsubscribe_no_link: "Questo riepilogo della chat viene inviato da %{site_link} quando non ci sei. Modifica %{email_preferences_link}." + view_messages: + one: "Visualizza messaggio" + other: "Visualizza %{count} messaggi" + view_more: + one: "Visualizza %{count} altro messaggio" + other: "Visualizza altri %{count} messaggi" + your_chat_settings: "preferenza di frequenza e-mail della chat" + unsubscribe: + chat_summary: + select_title: "Imposta la frequenza delle e-mail di riepilogo della chat su:" + never: Mai + when_away: Solo quando non sono collegato + category: + cannot_delete: + has_chat_channels: "Impossibile eliminare questa categoria perché ha canali di chat." diff --git a/plugins/chat/config/locales/server.ja.yml b/plugins/chat/config/locales/server.ja.yml new file mode 100644 index 0000000000..ed2b822e2c --- /dev/null +++ b/plugins/chat/config/locales/server.ja.yml @@ -0,0 +1,176 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + site_settings: + chat_allowed_groups: "これらのグループのユーザーがチャットできます。スタッフはいつでもチャットにアクセスできることに注意してください。" + chat_channel_retention_days: "通常のチャンネルのチャットメッセージは、この日数の間保持されます。メッセージを永久に保持するには、'0' に設定します。" + chat_dm_retention_days: "パーソナルチャットチャンネルのチャットメッセージは、この日数の間保持されます。メッセージを永久に保持するには、'0' に設定します。" + chat_auto_silence_duration: "チャットメッセージの作成速度制限を超えた場合にユーザーが投稿禁止になる分数。自動投稿禁止を無効にするには '0' に設定します。" + chat_allowed_messages_for_trust_level_0: "信頼レベル 0 のユーザーが 30 秒間に送信できるメッセージの件数。'0' に設定すると、制限が無効になります。" + chat_allowed_messages_for_other_trust_levels: "信頼レベル 1~4 のユーザーが 30 秒間に送信できるメッセージの件数。'0' に設定すると、制限が無効になります。" + chat_silence_user_sensitivity: "チャットで通報されたユーザーが自動的に投稿禁止にされる可能性。" + chat_auto_silence_from_flags_duration: "チャットメッセージの通報によって自動的に投稿禁止にされる場合に、ユーザーが投稿禁止になる分数。" + chat_default_channel_id: "ユーザーに、他のチャンネルの未読のメッセージまたはメンションがない場合に、デフォルトでオープンになるチャットチャンネル。" + chat_duplicate_message_sensitivity: "同じ送信者による重複したメッセージが短期間でブロックされる可能性。0~1.0 の 10 進数で、1.0 が最高の設定です(より短期間でより頻繁にメッセージをブロックします)。'0' に設定すると、重複メッセージが許可されます。" + chat_minimum_message_length: "チャットメッセージの最低文字数。" + chat_allow_uploads: "公開チャットチャンネルとダイレクトメッセージチャンネルでアップロードを許可します。" + chat_archive_destination_topic_status: "チャンネルのアーカイブが完了した後のアーカイブ先トピックのステータス。これは、アーカイブ先のトピックが既存のトピックではなく新しいトピックである場合にのみ適用されます。" + default_emoji_reactions: "チャットメッセージのデフォルトの絵文字リアクション。すぐにリアクションするための絵文字を最大 5 個追加できます。" + direct_message_enabled_groups: "これらのグループのユーザーがユーザー間のパーソナルチャットを作成することを許可します。注意: スタッフはいつでもパーソナルチャットを作成でき、ユーザーは作成権限のあるユーザーが開始したパーソナルチャットに返信できます。" + chat_message_flag_allowed_groups: "これらのグループのユーザーは、チャットメッセージを通報できます。" + errors: + chat_default_channel: "デフォルトのチャットチャンネルは公開チャンネルである必要があります。" + direct_message_enabled_groups_invalid: "この設定には、少なくとも 1 つのグループを指定する必要があります。スタッフ以外がダイレクトメッセージを送れないようにするには、スタッフグループを選択します。" + chat_upload_not_allowed_secure_uploads: "安全なアップロードのサイト設定が有効でない場合、チャットのアップロードは行えません。" + system_messages: + chat_channel_archive_complete: + title: "チャットチャンネルのアーカイブ完了" + subject_template: "チャットチャンネルのアーカイブが正常に完了しました" + text_body_template: | + チャットチャンネル **\#%{channel_name}** のアーカイブが正常に完了しました。メッセージはトピック [%{topic_title}](%{topic_url}) にコピーされました。 + chat_channel_archive_failed: + title: "チャットチャンネルのアーカイブ失敗" + subject_template: "チャットチャンネルのアーカイブに失敗しました" + text_body_template: | + チャットチャンネル **\#%{channel_name}** のアーカイブに失敗しました。%{messages_archived} 件のメッセージがアーカイブされました。部分的にアーカイブされたメッセージは、トピック [%{topic_title}](%{topic_url}) にコピーされました。%{channel_url} よりチャンネルにアクセスして、再試行してください。 + chat: + deleted_chat_username: 削除済み + errors: + channel_exists_for_category: "このカテゴリと名前のチャンネルはすでに存在します" + channel_new_message_disallowed: "チャンネルは %{status} です。新しいメッセージは送信できません" + channel_modify_message_disallowed: "チャンネルは %{status} です。メッセージの編集や削除は行えません" + user_cannot_send_message: "現在、メッセージを送信できません。" + rate_limit_exceeded: "30 秒間で送信できるチャットメッセージの件数制限を超えました" + auto_silence_from_flags: "ユーザーを投稿禁止にするのに十分なスコアで通報されたチャットメッセージ。" + channel_cannot_be_archived: "現在、チャンネルをアーカイブできません。アーカイブするには閉鎖されているかオープンである必要があります。" + duplicate_message: "同一のメッセージを最近投稿しました。" + delete_channel_failed: "チャンネルの削除に失敗しました。もう一度お試しください。" + minimum_length_not_met: "メッセージが短すぎます。最低 %{minimum} 文字が必要です。" + max_reactions_limit_reached: "このメッセージでは、新しいリアクションは許可されていません。" + message_move_invalid_channel: "移動元と移動先のチャンネルは公開チャンネルである必要があります。" + message_move_no_messages_found: "指定されたメッセージ ID を持つメッセージは見つかりませんでした。" + cant_update_direct_message_channel: "名前や説明と言ったダイレクトメッセージのチャンネルプロパティを更新できません。" + not_accepting_dms: "%{username} は現在、メッセージを受け付けていません。" + actor_ignoring_target_user: "%{username} を無視しているため、メッセージを送信できません。" + actor_muting_target_user: "%{username} をミュートしているため、メッセージを送信できません。" + actor_disallowed_dms: "ユーザーがあなたにプライベートメッセージやダイレクトメッセージを送信できないように選択しているため、新しいダイレクトメッセージを作成できません。" + actor_preventing_target_user_from_dm: "%{username} があなたにプライベートメッセージやダイレクトメッセージを送信できないように選択しているため、新しいダイレクトメッセージを作成できません。" + user_cannot_send_direct_messages: "ダイレクトメッセージを送信できません。" + reviewables: + message_already_handled: "ありがとうございます。ただ、このメッセージはすでにレビュー済みで、通報の必要がないと判断されています。" + actions: + agree: + title: "同意…" + agree_and_keep_message: + title: "メッセージを維持" + description: "通報に同意し、メッセージを未変更のままにします。" + agree_and_keep_deleted: + title: "メッセージの削除を維持" + description: "通報に同意し、メッセージを削除したままにします。" + agree_and_suspend: + title: "ユーザーを凍結" + description: "通報に同意し、ユーザーを凍結します。" + agree_and_silence: + title: "ユーザーを投稿禁止" + description: "通報に同意し、ユーザーを投稿禁止にします。" + agree_and_restore: + title: "メッセージを復元" + description: "ユーザーが閲覧できるようにメッセージを復元します。" + agree_and_delete: + title: "メッセージを削除" + description: "ユーザーが閲覧できないようにメッセージを削除します。" + delete_and_agree: + title: "メッセージを削除" + disagree_and_restore: + title: "同意せずにメッセージを復元" + description: "すべてのユーザーが閲覧できるようにメッセージを復元します。" + disagree: + title: "同意しない" + ignore: + title: "無視" + direct_messages: + transcript_title: "%{channel_name} の前のメッセージのトランスクリプト" + transcript_body: "より文脈を掴みやすいように、この会話の前のメッセージのトランスクリプトを含めました(最大 10 件)。\n\n%{transcript}" + channel: + statuses: + read_only: "読み取り専用" + archived: "アーカイブ済み" + closed: "閉鎖" + open: "オープン" + archive: + first_post_raw: "このトピックは、[%{channel_name}](%{channel_url}) チャットチャンネルのアーカイブです。" + messages_moved: + other: "@%{acting_username} が %{count} 件のメッセージを [%{channel_name}](%{first_moved_message_url}) チャンネルに移動しました。" + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} および他 %{leftover} 人" + bookmarkable: + notification_title: "%{channel_name} のメッセージ" + personal_chat: "パーソナルチャット" + onebox: + inline_to_message: "%{username} によるメッセージ #%{message_id} - #%{chat_channel}" + inline_to_channel: "チャット #%{chat_channel}" + inline_to_topic_channel: "トピック %{topic_title} のチャット" + x_members: + other: "%{count} 人のメンバー" + and_x_others: + other: "および他 %{count} 人" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} があなたを "%{channel}" でメンションしました' + other_type: '%{username} が %{identifier} を "%{channel}" でメンションしました' + direct_message_chat_mention: + direct: "%{username} がパーソナルチャットであなたをメンションしました" + other_type: "%{username} が %{identifier} をパーソナルチャットでメンションしました" + new_chat_message: '%{username} が "%{channel}" でメッセージを送信しました' + new_direct_chat_message: "%{username} がパーソナルチャットでメッセージを送信しました" + discourse_automation: + scriptables: + send_chat_message: + title: チャットメッセージを送信する + reviewable_score_types: + needs_review: + title: "要レビュー" + notify_user: + chat_pm_title: '"%{channel_name}" のあなたのチャットメッセージ' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: '"%{channel_name}" のチャットメッセージには、スタッフの注意が必要です' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "スタッフメンバーは、このチャットメッセージにレビューが必要だと考えています。" + user_notifications: + chat_summary: + deleted_user: "削除されたユーザー" + description: + other: "新しいチャットメッセージがあります" + from: "%{site_name}" + subject: + direct_message: + other: "[%{email_prefix}] %{message_title} と他 %{others} 件のチャットの新しいメッセージ" + chat_channel: + other: "[%{email_prefix}] %{message_title} と他 %{others} 件の新しいメッセージ" + other_direct_message: "%{message_title} から" + others: "他 %{count} 件" + unsubscribe: "このチャットの要約は、あなたが退席中のときに %{site_link} から送信されます。%{email_preferences_link} を変更するか、%{unsubscribe_link} から購読を停止します。" + unsubscribe_no_link: "このチャットの要約は、あなたが退席中のときに %{site_link} から送信されます。%{email_preferences_link} を変更します。" + view_messages: + other: "%{count} 件のメッセージを表示" + view_more: + other: "さらに %{count} 件のメッセージを表示" + your_chat_settings: "チャットメールの頻度設定" + unsubscribe: + chat_summary: + select_title: "チャット要約メールの頻度を設定:" + never: なし + when_away: 退席中の時のみ + category: + cannot_delete: + has_chat_channels: "このカテゴリにはチャットチャンネルがあるため削除できません。" diff --git a/plugins/chat/config/locales/server.ko.yml b/plugins/chat/config/locales/server.ko.yml new file mode 100644 index 0000000000..c8a3b106eb --- /dev/null +++ b/plugins/chat/config/locales/server.ko.yml @@ -0,0 +1,40 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: + chat: + deleted_chat_username: 삭제되었습니다 + errors: + not_accepting_dms: "죄송합니다. %{username} 님이 당분간 메시지를 받지 않습니다." + reviewables: + actions: + agree: + title: "동의..." + agree_and_suspend: + title: "사용자 차단" + description: "플래그에 동의하고 사용자를 일시 중지하십시오." + agree_and_silence: + title: "글 작성 중지 사용자" + description: "신고에 동의하고 사용자를 쓰기 금지로 설정합니다." + disagree: + title: "동의하지 않음" + ignore: + title: "무시" + channel: + statuses: + closed: "닫힘" + open: "열기" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: 알림 받지 않기 diff --git a/plugins/chat/config/locales/server.lt.yml b/plugins/chat/config/locales/server.lt.yml new file mode 100644 index 0000000000..d6dc1b7c1e --- /dev/null +++ b/plugins/chat/config/locales/server.lt.yml @@ -0,0 +1,43 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: + chat: + deleted_chat_username: ištrintas + errors: + not_accepting_dms: "Atsiprašome, %{username} šiuo metu nepriima pranešimų." + reviewables: + actions: + agree: + title: "Sutinku..." + agree_and_suspend: + title: "Suspenduoti narį" + agree_and_silence: + title: "Nutildyti narį" + description: "Sutikti su pranešimu ir nutildyti narį." + disagree: + title: "Nesutinka" + ignore: + title: "Ignoruoti" + channel: + statuses: + closed: "Uždaryta" + open: "Atidaryti" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} paminėjo tave "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Niekada diff --git a/plugins/chat/config/locales/server.lv.yml b/plugins/chat/config/locales/server.lv.yml new file mode 100644 index 0000000000..9c059cb15e --- /dev/null +++ b/plugins/chat/config/locales/server.lv.yml @@ -0,0 +1,28 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: + chat: + deleted_chat_username: dzēsts + reviewables: + actions: + agree_and_suspend: + title: "Apturētie lietotāji" + disagree: + title: "Nepiekrist" + ignore: + title: "Ignorēt" + channel: + statuses: + closed: "Slēgts" + open: "Atvērt" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} pieminēja jūs "%{channel}"' + unsubscribe: + chat_summary: + never: Nekad diff --git a/plugins/chat/config/locales/server.nb_NO.yml b/plugins/chat/config/locales/server.nb_NO.yml new file mode 100644 index 0000000000..bc8fd472cc --- /dev/null +++ b/plugins/chat/config/locales/server.nb_NO.yml @@ -0,0 +1,39 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: + chat: + deleted_chat_username: slettet + errors: + not_accepting_dms: "Beklager, %{username} ønsker ikke å motta personlige meldinger for øyeblikket." + reviewables: + actions: + agree_and_suspend: + title: "Steng ute bruker" + description: "Si deg enig med rapportering og steng ute bruker. " + agree_and_silence: + title: "Demp bruker" + description: "Si deg enig med rapportering og demp bruker. " + disagree: + title: "Si deg uenig" + ignore: + title: "Ignorer" + channel: + statuses: + closed: "Lukket" + open: "Åpne" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nevnte deg i "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Aldri diff --git a/plugins/chat/config/locales/server.nl.yml b/plugins/chat/config/locales/server.nl.yml new file mode 100644 index 0000000000..87f4860a5d --- /dev/null +++ b/plugins/chat/config/locales/server.nl.yml @@ -0,0 +1,40 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + chat: + deleted_chat_username: verwijderd + errors: + not_accepting_dms: "Sorry, %{username} accepteert momenteel geen berichten." + reviewables: + actions: + agree: + title: "Akkoord..." + agree_and_suspend: + title: "Gebruiker schorsen" + description: "Akkoord met markering en de gebruiker schorsen." + agree_and_silence: + title: "Gebruiker dempen" + description: "Akkoord met markering en de gebruiker dempen." + disagree: + title: "Niet akkoord" + ignore: + title: "Negeren" + channel: + statuses: + closed: "Gesloten" + open: "Openen" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Nooit diff --git a/plugins/chat/config/locales/server.pl_PL.yml b/plugins/chat/config/locales/server.pl_PL.yml new file mode 100644 index 0000000000..ea26c6afde --- /dev/null +++ b/plugins/chat/config/locales/server.pl_PL.yml @@ -0,0 +1,106 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + site_settings: + chat_enabled: "Włącz wtyczkę czatu." + chat: + deleted_chat_username: usunięte + errors: + channel_exists_for_category: "Istnieje już kanał dla tej kategorii i nazwy" + user_cannot_send_message: "W tej chwili nie możesz wysyłać wiadomości." + rate_limit_exceeded: "Przekroczono limit wiadomości na czacie, które można wysłać w ciągu 30 sekund" + max_reactions_limit_reached: "Nowe reakcje nie są dozwolone w tej wiadomości." + not_accepting_dms: "Przepraszamy, ale użytkownik %{username} nie akceptuje w tej chwili wiadomości " + reviewables: + actions: + agree: + title: "Zgadzam się..." + agree_and_keep_message: + title: "Zachowaj wiadomość" + agree_and_keep_deleted: + title: "Zachowaj wiadomość usuniętą" + agree_and_suspend: + title: "Zawieś użytkownika" + description: "Zgódź się z flagą i zawieś konto użytkownika." + agree_and_silence: + title: "Wycisz użytkownika" + description: "Zgódź się z flagą i wycisz użytkownika." + agree_and_restore: + title: "Przywróć wiadomość" + agree_and_delete: + title: "Usuń wiadomość" + delete_and_agree: + title: "Usuń wiadomość" + disagree: + title: "Wycofaj" + ignore: + title: "Ignoruj" + channel: + statuses: + read_only: "Tylko do odczytu" + archived: "Zarchiwizowany" + closed: "Zamknięta" + open: "Otwórz" + dm_title: + multi_user_truncated: "%{users} i %{leftover} innych" + bookmarkable: + notification_title: "wiadomość w %{channel_name}" + personal_chat: "czat osobisty" + onebox: + x_members: + one: "%{count} członek" + few: "%{count} członków" + many: "%{count} członków" + other: "%{count} członków" + and_x_others: + one: "i %{count} inny" + few: "i %{count} inne" + many: "i %{count} innych" + other: "i %{count} innych" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} wspomniał(a) o Tobie w "%{channel}"' + discourse_automation: + scriptables: + send_chat_message: + title: Wyślij wiadomość na czacie + reviewable_score_types: + needs_review: + title: "Wymaga przeglądu" + notify_user: + chat_pm_title: 'Twoja wiadomość w kanale "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Wiadomość w kanale "%{channel_name}" wymaga uwagi personelu' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Członek personelu uważa, że ta wiadomość wymaga weryfikacji." + user_notifications: + chat_summary: + deleted_user: "Usunięty użytkownik" + description: + one: "Masz nową wiadomość na czacie" + few: "Masz nowe wiadomości na czacie" + many: "Masz nowe wiadomości na czacie" + other: "Masz nowe wiadomości na czacie" + from: "%{site_name}" + subject: + other_direct_message: "od %{message_title}" + others: "%{count} inni" + view_messages: + one: "Zobacz wiadomość" + few: "Zobacz %{count} wiadomości" + many: "Zobacz %{count} wiadomości" + other: "Zobacz %{count} wiadomości" + unsubscribe: + chat_summary: + never: Nigdy + category: + cannot_delete: + has_chat_channels: "Nie można usunąć tej kategorii, ponieważ zawiera ona kanały czatu." diff --git a/plugins/chat/config/locales/server.pt.yml b/plugins/chat/config/locales/server.pt.yml new file mode 100644 index 0000000000..7efeafb1ca --- /dev/null +++ b/plugins/chat/config/locales/server.pt.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: + chat: + deleted_chat_username: eliminado + errors: + not_accepting_dms: "Desculpe, de momento, %{username} não está a aceitar mensagens." + reviewables: + actions: + agree_and_suspend: + title: "Utilizador Suspenso" + agree_and_silence: + title: "Silenciar Usuário" + disagree: + title: "Discordar" + ignore: + title: "Ignorar" + channel: + statuses: + closed: "Fechado" + open: "Abrir" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mencionou-o em "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Nunca diff --git a/plugins/chat/config/locales/server.pt_BR.yml b/plugins/chat/config/locales/server.pt_BR.yml new file mode 100644 index 0000000000..ef5e4b5086 --- /dev/null +++ b/plugins/chat/config/locales/server.pt_BR.yml @@ -0,0 +1,184 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + site_settings: + chat_allowed_groups: "Os usuários desses grupos podem conversar. Observe que a equipe sempre pode acessar o chat." + chat_channel_retention_days: "Mensagens de chat em canais regulares serão mantidas por essa quantidade de dias. Defina para '0' para manter mensagens para sempre." + chat_dm_retention_days: "Mensagens de chat em canais pessoais serão mantidas por essa quantidade de dias. Defina para '0' para manter mensagens para sempre." + chat_auto_silence_duration: "Número de minutos pelos quais os usuários serão silenciados quando excederem o limite de criação de mensagens de chat. Defina como '0' para desativar o silenciamento automático." + chat_allowed_messages_for_trust_level_0: "Número de mensagens que os usuários de nível 0 de confiança podem enviar em 30 segundos. Defina como '0' para desativar o limite." + chat_allowed_messages_for_other_trust_levels: "Número de mensagens que os usuários de nível 1-4 de confiança podem enviar em 30 segundos. Defina como '0' para desativar o limite." + chat_silence_user_sensitivity: "A probabilidade de um usuário sinalizado no chat ser silenciado automaticamente." + chat_auto_silence_from_flags_duration: "Número de minutos pelos quais os usuários serão silenciados quando forem silenciados automaticamente devido a mensagens de chat sinalizadas." + chat_default_channel_id: "O canal de chat que será aberto por padrão quando um usuário não tiver mensagens não lidas ou menções em outros canais." + chat_duplicate_message_sensitivity: "A probabilidade de que uma mensagem duplicada do mesmo remetente seja bloqueada em um curto período. Número decimal entre 0 e 1.0, sendo 1.0 a configuração mais alta (bloqueia as mensagens com mais frequência em menos tempo). Defina como '0' para permitir mensagens duplicadas." + chat_minimum_message_length: "Número mínimo de caracteres para uma mensagem de chat." + chat_allow_uploads: "Permitir envios em canais de chat públicos e canais de mensagens diretas." + chat_archive_destination_topic_status: "O status que o tópico de destino deve ter quando um arquivo de canal for concluído. Isso só se aplica quando o tópico de destino for um tópico novo, não um preexistente." + default_emoji_reactions: "Reações de emoji padrão para mensagens do chat. Adicione até 5 emojis para uma reação rápida." + direct_message_enabled_groups: "Permitir que os usuários desses grupos criem chats pessoais de usuário para usuário. Observação: a equipe sempre pode criar chats pessoais e os usuários poderão responder aos chats pessoais iniciados por usuários que tenham permissão para criá-los." + chat_message_flag_allowed_groups: "Os usuários desses grupos podem sinalizar mensagens do chat." + errors: + chat_default_channel: "O canal de chat padrão deve ser um canal público." + direct_message_enabled_groups_invalid: "Você deve especificar pelo menos um grupo para esta configuração. Se você não quiser que ninguém, exceto a equipe, envie mensagens diretas, escolha o grupo da equipe." + chat_upload_not_allowed_secure_uploads: "Enviar arquivos no chat não é permitido quando a configuração do site de envios seguros estiver habilitada." + system_messages: + chat_channel_archive_complete: + title: "Arquivamento do Canal de Chat Concluído" + subject_template: "Arquivo do canal de chat concluído com sucesso" + text_body_template: | + O arquivamento do canal de chat **\#%{channel_name}** foi concluído com êxito. As mensagens foram copiadas para o tópico [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Falha no Arquivamento do Canal de Chat" + subject_template: "Falha no arquivamento do canal de chat" + text_body_template: | + Falha ao realizar o arquivamento do canal de chat **\#%{channel_name}**. %{messages_archived} mensagens foram arquivadas. As mensagens parcialmente arquivadas foram copiadas para o tópico [%{topic_title}](%{topic_url}). Visite o canal em %{channel_url} para tentar novamente. + chat: + deleted_chat_username: excluído + errors: + channel_exists_for_category: "Já existe um canal para este nome e categoria" + channel_new_message_disallowed: "O canal está %{status}, nenhuma mensagem nova pode ser enviada" + channel_modify_message_disallowed: "O canal está %{status}, nenhuma mensagem pode ser editada ou excluída" + user_cannot_send_message: "Você não pode enviar mensagens neste momento." + rate_limit_exceeded: "Excedeu o limite de mensagens de chat que podem ser enviadas em 30 segundos" + auto_silence_from_flags: "Mensagem de chat sinalizada com pontuação alta o suficiente para silenciar o usuário." + channel_cannot_be_archived: "O canal não pode ser arquivado no momento, ele deve estar fechado ou aberto para arquivar." + duplicate_message: "Você postou uma mensagem idêntica muito recentemente." + delete_channel_failed: "Falha ao excluir canal. Tente novamente." + minimum_length_not_met: "A mensagem é muito curta, deve ter no mínimo %{minimum} caracteres." + max_reactions_limit_reached: "Novas reações não são permitidas nesta mensagem." + message_move_invalid_channel: "O canal de origem e de destino deve ser canais públicos." + message_move_no_messages_found: "Nenhuma mensagem foi encontrada com os IDs de mensagem fornecidos." + cant_update_direct_message_channel: "As propriedades do canal de mensagem direta, como nome e descrição, não podem ser atualizadas." + not_accepting_dms: "Desculpe, %{username} não está aceitando mensagens no momento." + actor_ignoring_target_user: "Você está ignorando %{username}, então você não pode enviar mensagens para ele(a)." + actor_muting_target_user: "Você está silenciando %{username}, então você não pode enviar mensagens para ele(a)." + actor_disallowed_dms: "Você optou por impedir que os usuários lhe enviem mensagens privadas e diretas, portanto, você não pode criar novas mensagens diretas." + actor_preventing_target_user_from_dm: "Você optou por impedir que %{username} lhe envie mensagens privadas e diretas, portanto, você não pode criar novas mensagens diretas para ele(a)." + user_cannot_send_direct_messages: "Desculpe, você não pode enviar mensagens diretas." + reviewables: + message_already_handled: "Obrigado, mas já analisamos esta mensagem e determinamos que ela não precisa ser sinalizada novamente." + actions: + agree: + title: "Concordo..." + agree_and_keep_message: + title: "Manter mensagem" + description: "Concorde com a sinalização e mantenha a mensagem inalterada." + agree_and_keep_deleted: + title: "Manter mensagem excluída" + description: "Concorde com a sinalização e mantenha a mensagem excluída." + agree_and_suspend: + title: "Suspender usuário(s)" + description: "Concorde com a sinalização e suspenda o usuário(a)." + agree_and_silence: + title: "Silenciar usuário(a)" + description: "Concorde com a sinalização e silencie o usuário(a)." + agree_and_restore: + title: "Restaurar mensagem" + description: "Restaure a mensagem para que os(as) usuários(as) possam vê-las." + agree_and_delete: + title: "Excluir mensagem" + description: "Exclua a mensagem para que os(as) usuários(as) não possam vê-las." + delete_and_agree: + title: "Excluir mensagem" + disagree_and_restore: + title: "Não concordar e restaurar mensagem" + description: "Restaure a mensagem para que todos(as) os(as) usuários(as) possam vê-las." + disagree: + title: "Discordar" + ignore: + title: "Ignorar" + direct_messages: + transcript_title: "Transcrição de mensagens anteriores em %{channel_name}" + transcript_body: "Para dar mais contexto, incluímos uma transcrição das mensagens anteriores nesta conversa (até dez):\n\n%{transcript}" + channel: + statuses: + read_only: "Somente leitura" + archived: "Arquivados" + closed: "Fechados" + open: "Aberto" + archive: + first_post_raw: "Este tópico é um arquivo do canal do chat [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} moveu uma mensagem para o canal [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} moveu %{count} mensagens para o canal [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} e %{leftover} outros" + bookmarkable: + notification_title: "mensagem em %{channel_name}" + personal_chat: "chat pessoal" + onebox: + inline_to_message: "Mensagem #%{message_id} por %{username} – #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Chat do Tópico %{topic_title}" + x_members: + one: "%{count} membro" + other: "%{count} membros" + and_x_others: + one: "e %{count} outro" + other: "e %{count} outros" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mencionou você em "%{channel}"' + other_type: '%{username} mencionou %{identifier} em "%{channel}"' + direct_message_chat_mention: + direct: "%{username} mencionou você no chat pessoal" + other_type: "%{username} mencionou %{identifier} no chat pessoal" + new_chat_message: '%{username} enviou uma mensagem em "%{channel}"' + new_direct_chat_message: "%{username} enviou uma mensagem no chat" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensagem de chat + reviewable_score_types: + needs_review: + title: "Precisa de revisão" + notify_user: + chat_pm_title: 'Nova mensagem de chat em "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Uma mensagem de chat em "%{channel_name}" requer atenção da equipe' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Um membro da equipe acha que esta mensagem do chat precisa ser revisada." + user_notifications: + chat_summary: + deleted_user: "Usuário excluído" + description: + one: "Você tem uma nova mensagem de chat" + other: "Você tem novas mensagens de chat" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nova mensagem de %{message_title}" + other: "[%{email_prefix}] Novas mensagens de %{message_title} e %{others}" + chat_channel: + one: "[%{email_prefix}] Nova mensagem de %{message_title}" + other: "[%{email_prefix}] Novas mensagens em %{message_title} e %{others}" + other_direct_message: "de %{message_title}" + others: "outros %{count}" + unsubscribe: "Este resumo do chat será enviado de %{site_link} quando você estiver ausente. Altere seu %{email_preferences_link}, ou %{unsubscribe_link} para cancelar a inscrição." + unsubscribe_no_link: "Este resumo do chat será enviado de %{site_link} quando você estiver ausente. Altere seu %{email_preferences_link}." + view_messages: + one: "Ver mensagem" + other: "Ver %{count} mensagens" + view_more: + one: "Ver mais %{count} mensagens" + other: "Ver mais %{count} mensagens" + your_chat_settings: "preferência de frequência de e-mail do chat" + unsubscribe: + chat_summary: + select_title: "Defina a frequência de e-mails de resumo do chat para:" + never: Nunca + when_away: Só quando estiver ausente + category: + cannot_delete: + has_chat_channels: "Não é possível excluir esta categoria, porque ela tem canais de chat." diff --git a/plugins/chat/config/locales/server.ro.yml b/plugins/chat/config/locales/server.ro.yml new file mode 100644 index 0000000000..f6d61b6885 --- /dev/null +++ b/plugins/chat/config/locales/server.ro.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: + chat: + deleted_chat_username: șters + errors: + not_accepting_dms: "%{username} nu acceptă mesaje în acest moment." + reviewables: + actions: + agree_and_suspend: + title: "Suspendă Utilizator" + agree_and_silence: + title: "Suspendă utilizatorul" + disagree: + title: "Nu sunt de acord" + ignore: + title: "Ignoră" + channel: + statuses: + closed: "Închis" + open: "Deschide sondajul" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} te-a menționat în discuția "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Niciodată diff --git a/plugins/chat/config/locales/server.ru.yml b/plugins/chat/config/locales/server.ru.yml new file mode 100644 index 0000000000..bb8a3d12c1 --- /dev/null +++ b/plugins/chat/config/locales/server.ru.yml @@ -0,0 +1,200 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + site_settings: + chat_allowed_groups: "Пользователи в этих группах могут общаться в чате. Обратите внимание, что сотрудники всегда могут получить доступ к чату." + chat_channel_retention_days: "Сообщения чата в обычных каналах будут храниться указанное здесь количество дней. Установите значение в '0', чтобы сохранять сообщения навсегда." + chat_dm_retention_days: "Сообщения чата в личных каналах чата будут храниться указанное здесь количество дней. Установите значение в '0', чтобы сохранять сообщения навсегда." + chat_auto_silence_duration: "Количество минут, в течение которых пользователи будут заблокированы, если они превысят лимит скорости создания сообщений в чате. Установите значение в '0', чтобы отключить автоблокировку." + chat_allowed_messages_for_trust_level_0: "Количество сообщений, которые пользователи с уровнем доверия '0' могут отправлять в течение 30 секунд. Установите значение в '0', чтобы отключить ограничение." + chat_allowed_messages_for_other_trust_levels: "Количество сообщений, которые пользователи с уровнем доверия от '1' до '4' могут отправлять в течение 30 секунд. Установите значение в '0', чтобы отключить ограничение." + chat_silence_user_sensitivity: "Вероятность того, что пользователь, на которого поступила жалоба, будет автоматически заблокирован." + chat_auto_silence_from_flags_duration: "Количество минут, в течение которых пользователи будут заблокированы, если на их сообщения поступают жалобы." + chat_default_channel_id: "Канал чата, который будет открываться по умолчанию, когда у пользователя нет непрочитанных сообщений или упоминаний в других каналах." + chat_duplicate_message_sensitivity: "Вероятность того, что дубликат сообщения от одного и того же отправителя будет заблокирован через короткий промежуток времени. Десятичное число от '0' до '1.0', где '1.0' — блокирует сообщения наиболее часто за короткий промежуток времени, а '0' - разрешает дублирование сообщений." + chat_minimum_message_length: "Минимальное количество символов при создании сообщения чата." + chat_allow_uploads: "Разрешить загрузку в общедоступных каналах чата и каналах прямых сообщений." + chat_archive_destination_topic_status: "Статус, который должен быть присвоен теме назначения после завершения архивирования канала. Присваивается только в том случае, если целевой темой является новая тема, а не существующая." + default_emoji_reactions: "Стандартные эмодзи чата. Можно добавить до 5 смайликов." + direct_message_enabled_groups: "Разрешить пользователям в этих группах создавать личные чаты между пользователями. Примечание. Сотрудники всегда могут создавать личные чаты, а пользователи смогут отвечать на личные чаты, инициированные пользователями, имеющими разрешение на их создание." + chat_message_flag_allowed_groups: "Пользователям этих групп разрешено жаловаться на сообщения в чате." + errors: + chat_default_channel: "Канал чата по умолчанию должен быть общедоступным." + direct_message_enabled_groups_invalid: "Для этого параметра необходимо указать хотя бы одну группу. Если вы не хотите, чтобы кто-либо, кроме сотрудников, отправлял прямые сообщения, выберите группу сотрудников." + chat_upload_not_allowed_secure_uploads: "Загрузка в чат запрещена, если включено ограничение доступа к загружаемому контенту." + system_messages: + chat_channel_archive_complete: + title: "Архивация канала завершена" + subject_template: "Архивация канала успешно завершена" + text_body_template: | + Архивация канала **\#%{channel_name}** успешно завершена. Сообщения были скопированы в тему [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Не удалось заархивировать канал" + subject_template: "Не удалось заархивировать канал" + text_body_template: | + Не удалось заархивировать канал **\#%{channel_name}**. Сообщения %{messages_archived} были заархивированы. Частично заархивированные сообщения были скопированы в тему [%{topic_title}](%{topic_url}). Посетите канал %{channel_url} и повторите попытку. + chat: + deleted_chat_username: удалён + errors: + channel_exists_for_category: "Канал для этого раздела уже существует" + channel_new_message_disallowed: "Канал %{status}, в него не могут быть отправлены новые сообщения" + channel_modify_message_disallowed: "Канал %{status}, существующие сообщения не могут быть отредактированы или удалены" + user_cannot_send_message: "В настоящее время вы не можете отправлять сообщения." + rate_limit_exceeded: "Превышен лимит сообщений, которые могут быть отправлены в течение 30 секунд" + auto_silence_from_flags: "На сообщение поступило большое количество жалоб, и пользователь был заблокирован." + channel_cannot_be_archived: "Канал в данный момент не может быть заархивирован, он должен быть либо закрыт, либо открыт для архивации." + duplicate_message: "Вы отправляете одно и то же сообщение слишком часто." + delete_channel_failed: "Не удалось удалить канал, попробуйте ещё раз." + minimum_length_not_met: "Сообщение слишком короткое, оно должно содержать не менее %{minimum} символов." + max_reactions_limit_reached: "Новые реакции на это сообщение запрещены." + message_move_invalid_channel: "Исходный и целевой каналы должны быть общедоступными." + message_move_no_messages_found: "Не найдено сообщений с указанными идентификаторами сообщений." + cant_update_direct_message_channel: "Такие свойства канала как название и описание, не могут быть обновлены." + not_accepting_dms: "Извините, пользователь %{username} в данный момент не принимает личные \nсообщения." + actor_ignoring_target_user: "Вы игнорируете %{username}, поэтому не можете отправлять им личные сообщения." + actor_muting_target_user: "Вы отключили все уведомления от %{username}, поэтому вы не можете отправлять им личные сообщения." + actor_disallowed_dms: "Вы решили запретить пользователям отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать новые прямые сообщения." + actor_preventing_target_user_from_dm: "Вы решили запретить %{username} отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать для них новые прямые сообщения." + user_cannot_send_direct_messages: "К сожалению, вы не можете отправлять прямые сообщения." + reviewables: + message_already_handled: "Спасибо, но мы уже рассмотрели жалобу на это сообщение, поэтому жаловаться на него снова нет необходимости." + actions: + agree: + title: "Согласиться..." + agree_and_keep_message: + title: "Оставить сообщение" + description: "Согласиться с жалобой и оставить сообщение без изменений." + agree_and_keep_deleted: + title: "Оставить сообщение уделённым" + description: "Согласиться с жалобой и оставить сообщение удалённым." + agree_and_suspend: + title: "Заморозить пользователя" + description: "Согласиться с жалобой и заморозить пользователя." + agree_and_silence: + title: "Pf,kjrbhjdfnm gjkmpjdfntkz" + description: "Согласиться с жалобой и блокировать пользователя." + agree_and_restore: + title: "Восстановить сообщение" + description: "Восстановить сообщение, чтобы пользователи могли его видеть." + agree_and_delete: + title: "Удалить сообщение" + description: "Удалить сообщение, чтобы пользователи не могли его видеть." + delete_and_agree: + title: "Удалить сообщение" + disagree_and_restore: + title: "Отклонить жалобу и восстановить сообщение" + description: "Восстановить сообщение, чтобы все пользователи могли его видеть." + disagree: + title: "Отклонить" + ignore: + title: "Игнорировать" + direct_messages: + transcript_title: "Содержимое предыдущих сообщений в канале %{channel_name}" + transcript_body: "Чтобы дать больше контекста, мы отображаем содержимое предыдущих сообщений этой беседы (до десяти):\n\n%{transcript}" + channel: + statuses: + read_only: "Только для чтения" + archived: "Архивные" + closed: "Закрытые" + open: "Открыт" + archive: + first_post_raw: "Эта тема является архивом канала [%{channel_name}](%{channel_url})." + messages_moved: + one: "Пользователь @%{acting_username} переместил сообщение в канал [%{channel_name}](%{first_moved_message_url})." + few: "Пользователь @%{acting_username} переместил %{count} сообщения в канал [%{channel_name}](%{first_moved_message_url})." + many: "Пользователь @%{acting_username} переместил %{count} сообщений в канал [%{channel_name}](%{first_moved_message_url})." + other: "Пользователь @%{acting_username} переместил %{count} сообщений в канал [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} и ещё %{leftover}" + bookmarkable: + notification_title: "Сообщение в канале %{channel_name}" + personal_chat: "личный чат" + onebox: + inline_to_message: "Сообщение №%{message_id} от пользователя %{username} — №%{chat_channel}" + inline_to_channel: "Чат №%{chat_channel}" + inline_to_topic_channel: "Чат по теме %{topic_title}" + x_members: + one: "%{count} участник" + few: "%{count} участника" + many: "%{count} участников" + other: "%{count} участников" + and_x_others: + one: "и ещё %{count}" + few: "и ещё %{count}" + many: "и ещё %{count}" + other: "и ещё %{count}" + discourse_push_notifications: + popup: + chat_mention: + direct: 'Пользователь %{username} упомянул вас на канале "%{channel}"' + other_type: 'Пользователь %{username} упомянул %{identifier} в канале "%{channel}"' + direct_message_chat_mention: + direct: "Пользователь %{username} упомянул вас в личном чате" + other_type: "Пользователь %{username} упомянул %{identifier} в личном чате" + new_chat_message: 'Пользователь %{username} отправил сообщение на канале "%{channel}"' + new_direct_chat_message: "Пользователь %{username} отправил сообщение в личный чат" + discourse_automation: + scriptables: + send_chat_message: + title: Отправить сообщение в чат + reviewable_score_types: + needs_review: + title: "Требуется проверка" + notify_user: + chat_pm_title: 'Ваше сообщение в канале ''%{channel_name}''' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Сообщение в канале ''%{channel_name}'' требует внимания модератора' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Сотрудник считает, что это сообщение должно быть отправлено на премодерацию." + user_notifications: + chat_summary: + deleted_user: "Удалённый пользователь" + description: + one: "У вас в чате одно новое сообщение" + few: "У вас в чате есть новые сообщения" + many: "У вас в чате есть новые сообщения" + other: "У вас в чате есть новые сообщения" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Новое сообщение от %{message_title}" + few: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + many: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + other: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + chat_channel: + one: "[%{email_prefix}] Новое сообщение в %{message_title}" + few: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + many: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + other: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + other_direct_message: "от %{message_title}" + others: "%{count} других" + unsubscribe: "Этот дайджест чата отправляется с сайта %{site_link} в период вашего отсутствия на форуме. Для отмены подписки измените %{email_preferences_link} или %{unsubscribe_link}." + unsubscribe_no_link: "Этот дайджест чата рассылается с сайта %{site_link} в период вашего отсутствия на форуме. Настройка рассылки: %{email_preferences_link}." + view_messages: + one: "Посмотреть %{count} сообщение" + few: "Посмотреть %{count} сообщения" + many: "Посмотреть %{count} сообщений" + other: "Посмотреть %{count} сообщений" + view_more: + one: "Посмотреть ещё %{count} сообщение" + few: "Посмотреть ещё %{count} сообщения" + many: "Посмотреть ещё %{count} сообщений" + other: "Посмотреть ещё %{count} сообщений" + your_chat_settings: "Настройка частоты рассылки дайджеста чата" + unsubscribe: + chat_summary: + select_title: "Настройте частоту получения электронных писем с дайджестами чата:" + never: Никогда + when_away: Если вы находитесь офлайн + category: + cannot_delete: + has_chat_channels: "Невозможно удалить этот раздел, поскольку в нём есть каналы чата." diff --git a/plugins/chat/config/locales/server.sk.yml b/plugins/chat/config/locales/server.sk.yml new file mode 100644 index 0000000000..7ae7c9dd95 --- /dev/null +++ b/plugins/chat/config/locales/server.sk.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: + chat: + deleted_chat_username: vymazané + reviewables: + actions: + agree_and_suspend: + title: "Suspendovaný užívateľ" + agree_and_silence: + title: "Tichý užívateľ" + disagree: + title: "Nesúhlasiť" + channel: + statuses: + closed: "Zatvorené" + open: "Zahájiť" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} Vás zmienil v "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Nikdy diff --git a/plugins/chat/config/locales/server.sl.yml b/plugins/chat/config/locales/server.sl.yml new file mode 100644 index 0000000000..02225d5911 --- /dev/null +++ b/plugins/chat/config/locales/server.sl.yml @@ -0,0 +1,39 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: + chat: + deleted_chat_username: izbrisano + reviewables: + actions: + agree: + title: "Strinjam se..." + agree_and_suspend: + title: "Suspendiraj uporabnika" + description: "Strinjam se z prijavo in suspendiraj uporabnika." + agree_and_silence: + title: "Utišaj uporabnika" + description: "Potrdi prijavo in utišaj uporabnika" + disagree: + title: "Se ne strinjam" + ignore: + title: "Prezri" + channel: + statuses: + closed: "Zaprto" + open: "Odpri" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} vas je omenil v "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Nikoli diff --git a/plugins/chat/config/locales/server.sq.yml b/plugins/chat/config/locales/server.sq.yml new file mode 100644 index 0000000000..3b2bd872fb --- /dev/null +++ b/plugins/chat/config/locales/server.sq.yml @@ -0,0 +1,30 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: + chat: + deleted_chat_username: deleted + reviewables: + actions: + agree_and_suspend: + title: "Pezullo anëtarin" + disagree: + title: "Jo dakord" + channel: + statuses: + open: "Fillo" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} ju përmendi në "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Asnjëherë diff --git a/plugins/chat/config/locales/server.sr.yml b/plugins/chat/config/locales/server.sr.yml new file mode 100644 index 0000000000..7da3cd0571 --- /dev/null +++ b/plugins/chat/config/locales/server.sr.yml @@ -0,0 +1,22 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: + chat: + errors: + not_accepting_dms: "Žao nam je, %{username} trenutno ne prihvata privatne poruke." + reviewables: + actions: + agree_and_suspend: + title: "Suspenduj Korisnika" + disagree: + title: "Odbaci" + channel: + statuses: + open: "Otvori" + unsubscribe: + chat_summary: + never: Nikad diff --git a/plugins/chat/config/locales/server.sv.yml b/plugins/chat/config/locales/server.sv.yml new file mode 100644 index 0000000000..0e5926e1cb --- /dev/null +++ b/plugins/chat/config/locales/server.sv.yml @@ -0,0 +1,185 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + site_settings: + chat_enabled: "Aktivera chattillägget." + chat_allowed_groups: "Användare i dessa grupper kan chatta. Observera att personalen alltid har tillgång till chatten." + chat_channel_retention_days: "Chattmeddelanden i ordinarie kanaler kommer att behållas i så här många dagar. Sätt till '0' för att behålla meddelanden för alltid." + chat_dm_retention_days: "Chattmeddelanden i personliga chattkanaler kommer att behållas i så här många dagar. Sätt till '0' för att behålla meddelanden för alltid." + chat_auto_silence_duration: "Antal minuter som användare kommer att tystas när de överskrider antalsgränsen för skapande av chattmeddelanden. Ställ in på '0' för att inaktivera automatisk tystning." + chat_allowed_messages_for_trust_level_0: "Antal meddelanden som användare på förtroendenivå 0 får skicka inom 30 sekunder. Ange '0' för att inaktivera gränsen." + chat_allowed_messages_for_other_trust_levels: "Antal meddelanden som användare med förtroendenivå 1-4 får skicka inom 30 sekunder. Ange '0' för att inaktivera gränsen." + chat_silence_user_sensitivity: "Sannolikheten för att en användare som flaggas i chatten automatiskt tystas." + chat_auto_silence_from_flags_duration: "Antal minuter som användarna tystas i när de automatiskt tystas på grund av markerade chattmeddelanden." + chat_default_channel_id: "Chattkanalen som öppnas som standard när en användare inte har olästa meddelanden eller omnämnanden i andra kanaler." + chat_duplicate_message_sensitivity: "Sannolikheten att ett duplicerat meddelande från samma avsändare blockeras inom en kort tidsperiod. Decimaltal mellan 0 och 1,0, där 1,0 är den högsta inställningen (blockerar meddelanden oftare på kortare tid). Ställ in `0` för att tillåta dubbletter av meddelanden." + chat_minimum_message_length: "Minsta antal tecken för ett chattmeddelande." + chat_allow_uploads: "Tillåt uppladdningar i offentliga chattkanaler och direktmeddelandekanaler." + chat_archive_destination_topic_status: "Den status som målämnet ska ha när ett kanalarkiv är slutfört. Detta gäller endast när målämnet är ett nytt ämne, inte ett befintligt." + default_emoji_reactions: "Standardval av emoji-reaktioner för chattmeddelanden. Lägg till upp till 5 emojis som snabbval." + direct_message_enabled_groups: "Tillåt användare inom dessa grupper att skapa personliga chattar från användare till användare. Obs: personal kan alltid skapa personliga chattar och användare kommer att kunna svara på personliga chattar som initieras av användare som har tillstånd att skapa dem." + chat_message_flag_allowed_groups: "Användare i dessa grupper får flagga chattmeddelanden." + errors: + chat_default_channel: "Standardchattkanalen måste vara en offentlig kanal." + direct_message_enabled_groups_invalid: "Du måste ange minst en grupp för den här inställningen. Om du inte vill att någon förutom personal ska skicka direktmeddelanden, välj personalgrupp." + chat_upload_not_allowed_secure_uploads: "Uppladdningar via chatt tillåts inte när inställningen för säkra uppladdningar är aktiverad." + system_messages: + chat_channel_archive_complete: + title: "Arkivering av chattkanalen är färdigt" + subject_template: "Arkivering av chattkanalen slutfördes framgångsrikt" + text_body_template: | + Arkivering av chattkanalen **\#%{channel_name}** har slutförts. Meddelandena har kopierats till ämnet [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Arkivering av chattkanalen misslyckades" + subject_template: "Arkivering av chattkanalen misslyckades" + text_body_template: | + Arkivering av chatt kanalen **\#%{channel_name}** misslyckades. %{messages_archived} meddelanden har arkiverats. Delvis arkiverade meddelanden kopierades till ämnet [%{topic_title}](%{topic_url}). Besök kanalen på %{channel_url} för att försöka igen. + chat: + deleted_chat_username: raderad + errors: + channel_exists_for_category: "En kanal finns redan för denna kategori och namn" + channel_new_message_disallowed: "Kanalen är %{status}, inga nya meddelanden kan skickas" + channel_modify_message_disallowed: "Kanalen är %{status}, inga meddelanden kan redigeras eller tas bort" + user_cannot_send_message: "Du kan inte skicka meddelanden just nu." + rate_limit_exceeded: "Överskred gränsen för chattmeddelanden som kan skickas inom 30 sekunder" + auto_silence_from_flags: "Chattmeddelande flaggat med tillräckligt hög poäng för att tysta användaren." + channel_cannot_be_archived: "Kanalen kan inte arkiveras just nu, den måste vara antingen stängd eller öppen för arkivering." + duplicate_message: "Du skrev också ett identiskt meddelande nyligen." + delete_channel_failed: "Det gick inte att ta bort kanalen, försök igen." + minimum_length_not_met: "Meddelandet är för kort, måste ha minst %{minimum} tecken." + max_reactions_limit_reached: "Nya reaktioner är inte tillåtna för detta meddelande." + message_move_invalid_channel: "Käll- och destinationskanalen måste vara offentliga kanaler." + message_move_no_messages_found: "Inga meddelanden hittades med de angivna meddelande-ID:n." + cant_update_direct_message_channel: "Egenskaper för direktmeddelandekanal såsom namn och beskrivning kan inte uppdateras." + not_accepting_dms: "Tyvärr tar %{username} inte emot meddelanden för tillfället." + actor_ignoring_target_user: "Du ignorerar %{username}, så du kan inte skicka meddelanden till dem." + actor_muting_target_user: "Du har tystat %{username}, så du kan inte skicka meddelanden till dem." + actor_disallowed_dms: "Du har valt att hindra användare från att skicka dig privata och direkta meddelanden, så du kan inte skapa nya direkta meddelanden." + actor_preventing_target_user_from_dm: "Du har valt att hindra %{username} från att skicka privata och direkta meddelanden, så du kan inte skapa nya direktmeddelanden till dem." + user_cannot_send_direct_messages: "Tyvärr kan du inte skicka direktmeddelanden." + reviewables: + message_already_handled: "Tack, men vi har redan granskat det här meddelandet och beslutat att det inte behöver flaggas igen." + actions: + agree: + title: "Godkänn..." + agree_and_keep_message: + title: "Behåll meddelande" + description: "Håll med flaggning men behåll meddelandet oförändrat." + agree_and_keep_deleted: + title: "Behåll meddelandet raderat" + description: "Håll med flaggning och behåll meddelandet raderat." + agree_and_suspend: + title: "Stäng av användaren" + description: "Godkänn flaggning och stäng av användaren." + agree_and_silence: + title: "Tysta användaren" + description: "Godkänn flaggning och tysta användaren." + agree_and_restore: + title: "Återställ meddelande" + description: "Återställ meddelandet så att användarna kan se det." + agree_and_delete: + title: "Radera meddelande" + description: "Ta bort meddelandet så att användarna inte kan se det." + delete_and_agree: + title: "Radera meddelande" + disagree_and_restore: + title: "Håll inte med och återställ meddelande" + description: "Återställ meddelandet så att alla användare kan se det." + disagree: + title: "Håll inte med" + ignore: + title: "Ignorera" + direct_messages: + transcript_title: "Avskrift av tidigare meddelanden i %{channel_name}" + transcript_body: "För att ge dig mer sammanhang inkluderade vi en avskrift av de tidigare meddelandena i det här samtalet (upp till tio):\n\n%{transcript}" + channel: + statuses: + read_only: "Endast läsning" + archived: "Arkiverad" + closed: "Stängd" + open: "Öppna" + archive: + first_post_raw: "Detta ämne är ett arkiv av chatt kanalen [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} flyttade ett meddelande till kanalen [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} flyttade %{count} meddelanden till kanalen [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} och %{leftover} andra" + bookmarkable: + notification_title: "meddelande i %{channel_name}" + personal_chat: "personlig chatt" + onebox: + inline_to_message: "Meddelande #%{message_id} av %{username} – #%{chat_channel}" + inline_to_channel: "Chatt #%{chat_channel}" + inline_to_topic_channel: "Chatt för ämne %{topic_title}" + x_members: + one: "%{count} medlem" + other: "%{count} medlemmar" + and_x_others: + one: "och %{count} annan" + other: "och %{count} andra" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nämnde dig i "%{channel}"' + other_type: '%{username} nämnde %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "%{username} nämnde dig i en personlig chatt" + other_type: "%{username} nämnde %{identifier} i personlig chatt" + new_chat_message: '%{username} skickade ett meddelande i "%{channel}"' + new_direct_chat_message: "%{username} skickade ett meddelande i personlig chatt" + discourse_automation: + scriptables: + send_chat_message: + title: Skicka chattmeddelande + reviewable_score_types: + needs_review: + title: "Behöver granskning" + notify_user: + chat_pm_title: 'Ditt chattmeddelande i "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Ett chattmeddelande i "%{channel_name}" kräver personalens uppmärksamhet' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "En anställd anser att detta chattmeddelande behöver granskas." + user_notifications: + chat_summary: + deleted_user: "Raderad användare" + description: + one: "Du har ett nytt chattmeddelande" + other: "Du har nya chattmeddelanden" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nytt meddelande från %{message_title}" + other: "[%{email_prefix}] Nya meddelanden från %{message_title} och %{others}" + chat_channel: + one: "[%{email_prefix}] Nytt meddelande i %{message_title}" + other: "[%{email_prefix}] Nya meddelanden i %{message_title} och %{others}" + other_direct_message: "från %{message_title}" + others: "%{count} andra" + unsubscribe: "Den här chattsammanfattningen skickas från %{site_link} när du är borta. Ändra din %{email_preferences_link} eller %{unsubscribe_link} för att avsluta prenumerationen." + unsubscribe_no_link: "Den här chattsammanfattningen skickas från %{site_link} när du är borta. Ändra din %{email_preferences_link}." + view_messages: + one: "Visa meddelande" + other: "Visa %{count} meddelanden" + view_more: + one: "Visa %{count} mer meddelande" + other: "Visa %{count} fler meddelanden" + your_chat_settings: "preferens för frekvens av chattmeddelanden" + unsubscribe: + chat_summary: + select_title: "Ställ in e-postfrekvensen för chattsammanfattningar till:" + never: Aldrig + when_away: Endast när du är borta + category: + cannot_delete: + has_chat_channels: "Du kan inte ta bort den här kategorin eftersom den har chattkanaler." diff --git a/plugins/chat/config/locales/server.sw.yml b/plugins/chat/config/locales/server.sw.yml new file mode 100644 index 0000000000..24aa973047 --- /dev/null +++ b/plugins/chat/config/locales/server.sw.yml @@ -0,0 +1,35 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: + chat: + deleted_chat_username: imefutwa + errors: + not_accepting_dms: "Samahani, %{username}haikubali jumbe kwa sasa" + reviewables: + actions: + agree_and_suspend: + title: "Simamisha Mtumiaji" + description: "Kubaliana na bendera na simamisha mtumiaji." + agree_and_silence: + title: "Nyamazisha Mtumiaji" + description: "Kubaliana na bendera na nyamazisha mtumiaji." + disagree: + title: "Kataa" + ignore: + title: "Puuzia" + channel: + statuses: + closed: "Imefungwa" + open: "Fungua" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Kamwe diff --git a/plugins/chat/config/locales/server.te.yml b/plugins/chat/config/locales/server.te.yml new file mode 100644 index 0000000000..7fd9140d31 --- /dev/null +++ b/plugins/chat/config/locales/server.te.yml @@ -0,0 +1,20 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: + chat: + deleted_chat_username: తొలగించారు + reviewables: + actions: + agree_and_suspend: + title: "సభ్యుడిని సస్పెండు చేయి" + disagree: + title: "ఒప్పుకోకు" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" diff --git a/plugins/chat/config/locales/server.th.yml b/plugins/chat/config/locales/server.th.yml new file mode 100644 index 0000000000..9fd7f0cdb1 --- /dev/null +++ b/plugins/chat/config/locales/server.th.yml @@ -0,0 +1,26 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: + chat: + deleted_chat_username: ลบ + reviewables: + actions: + disagree: + title: "ไม่เห็นด้วย" + ignore: + title: "ไม่สนใจ" + channel: + statuses: + closed: "ปิด" + open: "เปิด" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} พูดถึงคุณใน "%{channel}"' + unsubscribe: + chat_summary: + never: ไม่เคย diff --git a/plugins/chat/config/locales/server.tr_TR.yml b/plugins/chat/config/locales/server.tr_TR.yml new file mode 100644 index 0000000000..7acbd5d578 --- /dev/null +++ b/plugins/chat/config/locales/server.tr_TR.yml @@ -0,0 +1,46 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + chat: + deleted_chat_username: silindi + errors: + not_accepting_dms: "Üzgünüz, %{username} şu anda ileti kabul etmiyor." + reviewables: + actions: + agree: + title: "Kabul..." + agree_and_suspend: + title: "Kullanıcıyı Askıya Al" + description: "Bayrakla işaretle ve kullanıcıyı askıya al." + agree_and_silence: + title: "Kullanıcıyı Sessize Al" + description: "Bayrakla işaretle ve kullanıcıyı sessize al." + disagree: + title: "Onaylama" + ignore: + title: "Yok say" + channel: + statuses: + closed: "Kapanmış" + open: "Başlat" + dm_title: + multi_user_truncated: "%{users} ve diğer %{leftover}" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} sizden bahsetti "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + from: "%{site_name}" + unsubscribe: + chat_summary: + never: Asla diff --git a/plugins/chat/config/locales/server.uk.yml b/plugins/chat/config/locales/server.uk.yml new file mode 100644 index 0000000000..2fbe76c9e3 --- /dev/null +++ b/plugins/chat/config/locales/server.uk.yml @@ -0,0 +1,41 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: + chat: + deleted_chat_username: видалено + errors: + not_accepting_dms: "На жаль, %{username} в даний момент не приймає повідомлення." + reviewables: + actions: + agree: + title: "Погодитися…" + agree_and_suspend: + title: "Призупинити користувача" + description: "Погодитися з прапором та заморозити користувача." + agree_and_silence: + title: "Заблокувати користувача" + description: "Погодитися з прапором та відключити користувача." + disagree: + title: "Відмовити" + ignore: + title: "Ігнорувати" + channel: + statuses: + closed: "Закриті" + open: "Відкрити" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} згадав вас у "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}\n" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}\n" + unsubscribe: + chat_summary: + never: Ніколи diff --git a/plugins/chat/config/locales/server.ur.yml b/plugins/chat/config/locales/server.ur.yml new file mode 100644 index 0000000000..5af33ad34b --- /dev/null +++ b/plugins/chat/config/locales/server.ur.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: + chat: + deleted_chat_username: حذف کردہ + errors: + not_accepting_dms: "معذرت، %{username} اِس وقت پیغامات قبول نہیں کر رہا ہے۔" + reviewables: + actions: + agree: + title: "اتفاق کریں..." + agree_and_suspend: + title: "صارف معطل کریں" + description: "فلَیگ کے ساتھ اتفاق کریں اور صارف معطل کریں۔" + agree_and_silence: + title: "صارف خاموش کریں" + description: "فلَیگ کے ساتھ اتفاق کریں اور صارف خاموش کریں۔" + disagree: + title: "اختلاف کریں" + ignore: + title: "نظر انداز کریں" + channel: + statuses: + closed: "بند" + open: "کھولیں" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: کبھی نہیں diff --git a/plugins/chat/config/locales/server.vi.yml b/plugins/chat/config/locales/server.vi.yml new file mode 100644 index 0000000000..00d887158f --- /dev/null +++ b/plugins/chat/config/locales/server.vi.yml @@ -0,0 +1,39 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: + chat: + deleted_chat_username: đã bị xóa + errors: + not_accepting_dms: "Xin lỗi, %{username} hiện không chấp nhận tin nhắn." + reviewables: + actions: + agree_and_suspend: + title: "Tạm ngưng người dùng" + agree_and_silence: + title: "Người dùng im lặng" + disagree: + title: "Không đồng ý" + ignore: + title: "Bỏ qua" + channel: + statuses: + closed: "Đã " + open: "Mở" + dm_title: + multi_user_truncated: "%{users} và %{leftover} khác" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nhắc đến bạn trong "%{channel}"' + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: Không bao giờ diff --git a/plugins/chat/config/locales/server.zh_CN.yml b/plugins/chat/config/locales/server.zh_CN.yml new file mode 100644 index 0000000000..c4a508baed --- /dev/null +++ b/plugins/chat/config/locales/server.zh_CN.yml @@ -0,0 +1,176 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + site_settings: + chat_allowed_groups: "这些群组中的用户可以聊天。请注意,管理人员始终可以访问聊天。" + chat_channel_retention_days: "常规频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" + chat_dm_retention_days: "个人聊天频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" + chat_auto_silence_duration: "用户超过聊天消息创建速率限制时将被禁言的分钟数。设置为 0 将禁用自动禁言。" + chat_allowed_messages_for_trust_level_0: "信任级别 0 用户在 30 秒内可以发送的消息数。设置为 0 将禁用限制。" + chat_allowed_messages_for_other_trust_levels: "信任级别 1-4 的用户在 30 秒内可以发送的消息数。设置为 0 将禁用限制。" + chat_silence_user_sensitivity: "聊天中被举报的用户被自动禁言的可能性。" + chat_auto_silence_from_flags_duration: "用户由于被举报的聊天消息而被自动禁言时将被禁言的分钟数。" + chat_default_channel_id: "当用户在其他频道中没有未读消息或提及时,将默认打开的聊天频道。" + chat_duplicate_message_sensitivity: "同一发件人的重复邮件在短时间内被屏蔽的可能性。0 到 1.0 之间的十进制数,1.0 是最高设置(在更短的时间内更频繁地屏蔽消息)。设置为 0 将允许重复消息。" + chat_minimum_message_length: "聊天消息的最少字符数。" + chat_allow_uploads: "允许在公共聊天频道和直接消息频道中上传文件。" + chat_archive_destination_topic_status: "频道归档完成后目标话题应处于的状态。这仅适用于目标话题是新话题而不是现有话题的情况。" + default_emoji_reactions: "聊天消息的默认表情符号反应。最多可添加 5 个表情符号进行快速反应。" + direct_message_enabled_groups: "允许这些群组中的用户创建用户间的个人聊天。请注意:管理人员始终可以创建个人聊天,用户将能够回复有权创建个人聊天的用户发起的个人聊天。" + chat_message_flag_allowed_groups: "这些群组中的用户可以举报聊天消息。" + errors: + chat_default_channel: "默认聊天频道必须是公共频道。" + direct_message_enabled_groups_invalid: "您必须为此设置至少指定一个群组。如果您不希望管理人员以外的任何人发送直接消息,请选择管理人员群组。" + chat_upload_not_allowed_secure_uploads: "当启用安全上传站点设置时,不允许在聊天中上传文件。" + system_messages: + chat_channel_archive_complete: + title: "聊天频道归档完成" + subject_template: "聊天频道归档成功完成" + text_body_template: | + 聊天频道**\#%{channel_name}**归档已成功完成。消息已被复制到话题[%{topic_title}](%{topic_url})中。 + chat_channel_archive_failed: + title: "聊天频道归档失败" + subject_template: "聊天频道归档失败" + text_body_template: | + 聊天频道**#%{channel_name}**归档失败。%{messages_archived} 条消息已被归档。部分归档的消息已被复制到话题[%{topic_title}](%{topic_url})。请访问 %{channel_url} 下的频道以重试。 + chat: + deleted_chat_username: 已删除 + errors: + channel_exists_for_category: "此类别和名称的频道已经存在" + channel_new_message_disallowed: "频道的状态为%{status},无法发送新消息" + channel_modify_message_disallowed: "频道的状态为%{status},无法编辑或删除消息" + user_cannot_send_message: "您目前无法发送消息。" + rate_limit_exceeded: "超过了 30 秒内可发送的聊天消息的上限" + auto_silence_from_flags: "聊天消息被举报的分数高到足以将用户禁言。" + channel_cannot_be_archived: "目前无法归档该频道,必须将其关闭或打开才能归档。" + duplicate_message: "您在短时间内发布了一条相同的消息。" + delete_channel_failed: "删除频道失败,请重试。" + minimum_length_not_met: "消息太短,必须至少有 %{minimum} 个字符。" + max_reactions_limit_reached: "此消息不允许有新的回应。" + message_move_invalid_channel: "源频道和目标频道必须是公共频道。" + message_move_no_messages_found: "找不到带有提供的消息 ID 的消息。" + cant_update_direct_message_channel: "无法更新名称和描述等直接消息频道属性。" + not_accepting_dms: "抱歉,%{username} 目前不接受消息。" + actor_ignoring_target_user: "您正在忽略 %{username},因此您无法向他们发送消息。" + actor_muting_target_user: "您正在将 %{username} 设为免打扰,因此您无法向他们发送消息。" + actor_disallowed_dms: "您已选择阻止用户向您发送私人和直接消息,因此您无法创建新的直接消息。" + actor_preventing_target_user_from_dm: "您已选择阻止 %{username} 向您发送私人和直接消息,因此您无法创建给他们的新直接消息。" + user_cannot_send_direct_messages: "抱歉,您无法发送直接消息。" + reviewables: + message_already_handled: "谢谢,但我们已经审核此消息,并确定它不需要被再次举报。" + actions: + agree: + title: "同意…" + agree_and_keep_message: + title: "保留消息" + description: "同意举报并保持消息不变。" + agree_and_keep_deleted: + title: "保持消息删除状态" + description: "同意举报并保持消息删除状态。" + agree_and_suspend: + title: "封禁用户" + description: "同意举报并封禁用户。" + agree_and_silence: + title: "将用户禁言" + description: "同意举报并将用户禁言。" + agree_and_restore: + title: "恢复消息" + description: "恢复消息,以便用户可以看到。" + agree_and_delete: + title: "删除消息" + description: "删除消息,使用户看不到。" + delete_and_agree: + title: "删除消息" + disagree_and_restore: + title: "不同意并恢复消息" + description: "恢复消息,以便所有用户都可以看到。" + disagree: + title: "不同意" + ignore: + title: "忽略" + direct_messages: + transcript_title: "%{channel_name}中以前消息的副本" + transcript_body: "为了向您提供更多上下文,我们在此对话中包含了以前消息的副本(最多十条):\n\n%{transcript}" + channel: + statuses: + read_only: "只读" + archived: "已归档" + closed: "已关闭" + open: "开放" + archive: + first_post_raw: "此话题是[%{channel_name}](%{channel_url})聊天频道的归档。" + messages_moved: + other: "@%{acting_username}将 %{count} 条消息移至[%{channel_name}](%{first_moved_message_url})频道。" + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} 和其他 %{leftover} 人" + bookmarkable: + notification_title: "%{channel_name}中的消息" + personal_chat: "个人聊天" + onebox: + inline_to_message: "消息 #%{message_id},来自%{username} – #%{chat_channel}" + inline_to_channel: "聊天 #%{chat_channel}" + inline_to_topic_channel: "话题%{topic_title}的聊天" + x_members: + other: "%{count} 个成员" + and_x_others: + other: "和其他 %{count} 人" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} 在“%{channel}”中提及您' + other_type: '%{username} 在“%{channel}”中提及“%{identifier}”' + direct_message_chat_mention: + direct: "%{username} 在个人聊天中提及您" + other_type: "%{username} 在个人聊天中提及“%{identifier}”" + new_chat_message: '%{username} 在“%{channel}”中发送了一条消息' + new_direct_chat_message: "%{username} 在个人聊天中发送了一条消息" + discourse_automation: + scriptables: + send_chat_message: + title: 发送聊天消息 + reviewable_score_types: + needs_review: + title: "需要审核" + notify_user: + chat_pm_title: '您在“%{channel_name}”中的聊天消息' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: '“%{channel_name}”中的一条聊天消息需要管理人员注意' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "一位管理人员认为此聊天消息需要审核。" + user_notifications: + chat_summary: + deleted_user: "已被删除的用户" + description: + other: "您有新的聊天消息" + from: "%{site_name}" + subject: + direct_message: + other: "[%{email_prefix}] 来自%{message_title}和%{others}的新消息" + chat_channel: + other: "[%{email_prefix}] %{message_title}和%{others}中的新消息" + other_direct_message: "来自%{message_title}" + others: "其他 %{count} 人" + unsubscribe: "此聊天摘要在您离开时从%{site_link}发送。更改您的%{email_preferences_link},或者%{unsubscribe_link}以退订。" + unsubscribe_no_link: "此聊天摘要在您离开时从%{site_link}发送。更改您的%{email_preferences_link}。" + view_messages: + other: "查看 %{count} 条消息" + view_more: + other: "查看其他 %{count} 条消息" + your_chat_settings: "聊天电子邮件频率偏好设置" + unsubscribe: + chat_summary: + select_title: "将聊天摘要电子邮件频率设置为:" + never: 永不 + when_away: 仅在离开时 + category: + cannot_delete: + has_chat_channels: "无法删除此类别,因为它有关联的聊天频道。" diff --git a/plugins/chat/config/locales/server.zh_TW.yml b/plugins/chat/config/locales/server.zh_TW.yml new file mode 100644 index 0000000000..08d87ec741 --- /dev/null +++ b/plugins/chat/config/locales/server.zh_TW.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: + chat: + deleted_chat_username: 已刪除 + errors: + not_accepting_dms: "對不起,%{username} 目前不接收訊息。" + reviewables: + actions: + agree: + title: "同意..." + agree_and_suspend: + title: "將使用者停權" + description: "同意檢舉並停權使用者" + agree_and_silence: + title: "將使用者禁言" + description: "同意檢舉並禁止使用者發文" + disagree: + title: "不同意" + ignore: + title: "忽略" + channel: + statuses: + closed: "不公開" + open: "開啟" + reviewable_score_types: + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + unsubscribe: + chat_summary: + never: 永不 diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml new file mode 100644 index 0000000000..0ded290e0e --- /dev/null +++ b/plugins/chat/config/settings.yml @@ -0,0 +1,95 @@ +chat: + chat_enabled: + default: false + client: true + chat_allowed_groups: + client: true + type: group_list + list_type: compact + default: "3" # 3 is staff group id + allow_any: false + refresh: true + needs_chat_seeded: + default: true + hidden: true + chat_debug_webhook_payloads: + default: false + hidden: true + chat_channel_retention_days: + default: 90 + client: true + max: 3652 # 10 years + min: 0 + chat_dm_retention_days: + default: 0 + client: true + max: 3652 # 10 years + min: 0 + chat_auto_silence_duration: + default: 30 + min: 0 + chat_allowed_messages_for_trust_level_0: + default: 20 + min: 0 + chat_allowed_messages_for_other_trust_levels: + default: 40 + min: 0 + chat_silence_user_sensitivity: + type: enum + enum: "ReviewableSensitivitySetting" + default: 6 + chat_auto_silence_from_flags_duration: + default: 60 + min: 0 + chat_allow_archiving_channels: + default: false + hidden: true + client: true + chat_archive_destination_topic_status: + type: enum + default: archived + choices: + - archived + - open + - closed + chat_default_channel_id: + default: "" + client: true + validator: "ChatDefaultChannelValidator" + chat_duplicate_message_sensitivity: + type: float + default: 0.5 + min: 0 + max: 1 + default_emoji_reactions: + type: emoji_list + default: +1|heart|tada + client: true + chat_minimum_message_length: + type: integer + default: 1 + min: 1 + max: 50 + client: true + chat_allow_uploads: + default: true + client: true + validator: "ChatAllowUploadsValidator" + max_chat_auto_joined_users: + min: 0 + default: 10000 + hidden: true + client: true + direct_message_enabled_groups: + default: "11" # auto group trust_level_1 + type: group_list + client: true + allow_any: false + refresh: true + validator: "DirectMessageEnabledGroupsValidator" + chat_message_flag_allowed_groups: + default: "11" # auto group trust_level_1 + type: group_list + client: true + allow_any: false + refresh: true diff --git a/plugins/chat/db/fixtures/600_chat_channels.rb b/plugins/chat/db/fixtures/600_chat_channels.rb new file mode 100644 index 0000000000..972398ba7f --- /dev/null +++ b/plugins/chat/db/fixtures/600_chat_channels.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ChatSeeder.new.execute if !Rails.env.test? diff --git a/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb b/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb new file mode 100644 index 0000000000..21844f7ea6 --- /dev/null +++ b/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateChatTables < ActiveRecord::Migration[6.0] + def change + create_table :topic_chats do |t| + t.integer :topic_id, null: false, index: true, unique: true + t.datetime :deleted_at + t.integer :deleted_by_id + + t.integer :featured_in_category_id + t.integer :delete_after_seconds, default: nil + end + + create_table :topic_chat_messages do |t| + t.integer :topic_id, null: false + t.integer :post_id, null: false, index: true + t.integer :user_id, null: true + t.timestamps + t.datetime :deleted_at + t.integer :deleted_by_id + t.integer :in_reply_to_id, null: true + t.text :message + end + + add_index :topic_chat_messages, %i[topic_id created_at] + end +end diff --git a/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb b/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb new file mode 100644 index 0000000000..522ad28f16 --- /dev/null +++ b/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddActionCodeToTopicChatMessage < ActiveRecord::Migration[6.0] + def change + add_column :topic_chat_messages, :action_code, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb b/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb new file mode 100644 index 0000000000..134417d385 --- /dev/null +++ b/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class RenameTopicChatsToChatChannels < ActiveRecord::Migration[6.1] + def up + begin + Migration::SafeMigrate.disable! + + # Trash all existing chat info + DB.exec("DELETE FROM topic_chats") + DB.exec("DELETE FROM topic_chat_messages") + + # topic_chat table changes + rename_table :topic_chats, :chat_channels + rename_column :chat_channels, :topic_id, :chatable_id + change_column :chat_channels, :chatable_id, :integer, unique: false + add_column :chat_channels, :chatable_type, :string + change_column_null :chat_channels, :chatable_type, false + add_index :chat_channels, %i[chatable_id chatable_type] + + # topic_chat_messages table changes + rename_table :topic_chat_messages, :chat_messages + rename_column :chat_messages, :topic_id, :chat_channel_id + change_column_null :chat_messages, :post_id, true # Don't require post_id + ensure + Migration::SafeMigrate.enable! + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb b/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb new file mode 100644 index 0000000000..423ccffd41 --- /dev/null +++ b/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateChatMessageRevisions < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_revisions do |t| + t.integer :chat_message_id + t.text :old_message, null: false + t.text :new_message, null: false + t.timestamps + end + + add_index :chat_message_revisions, [:chat_message_id] + end +end diff --git a/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb b/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb new file mode 100644 index 0000000000..a0b7304687 --- /dev/null +++ b/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateUserChatChannelLastRead < ActiveRecord::Migration[6.1] + def change + create_table :user_chat_channel_last_reads do |t| + t.integer :chat_channel_id, null: false + t.integer :chat_message_id, null: true # Can be null if user hasn't opened the channel + t.integer :user_id, null: false + end + + add_index :user_chat_channel_last_reads, + %i[chat_channel_id user_id], + unique: true, + name: "user_chat_channel_reads_index" + end +end diff --git a/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb b/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb new file mode 100644 index 0000000000..3b231e0782 --- /dev/null +++ b/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateDirectMessageTables < ActiveRecord::Migration[6.1] + def change + create_table :direct_message_channels do |t| + t.timestamps + end + + create_table :direct_message_users do |t| + t.integer :direct_message_channel_id, null: false + t.integer :user_id, null: false + t.timestamps + end + + add_index :direct_message_users, + %i[direct_message_channel_id user_id], + unique: true, + name: "direct_message_users_index" + end +end diff --git a/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb b/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb new file mode 100644 index 0000000000..773a778c9f --- /dev/null +++ b/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class AddTimestampsToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :created_at, :timestamp + add_column :chat_channels, :updated_at, :timestamp + + DB.exec("UPDATE chat_channels SET created_at = NOW() WHERE created_at IS NULL") + DB.exec("UPDATE chat_channels SET updated_at = NOW() WHERE updated_at IS NULL") + + change_column_null :chat_channels, :created_at, false + change_column_null :chat_channels, :updated_at, false + end +end diff --git a/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb b/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb new file mode 100644 index 0000000000..23cc115b74 --- /dev/null +++ b/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class CreateIncomingChatWebhooks < ActiveRecord::Migration[6.1] + def change + create_table :incoming_chat_webhooks do |t| + t.string :name, null: false + t.string :key, null: false + t.integer :chat_channel_id, null: false + t.string :username + t.string :description + t.string :emoji + + t.timestamps + end + + add_index :incoming_chat_webhooks, %i[key chat_channel_id] + end +end diff --git a/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb b/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb new file mode 100644 index 0000000000..4e95767754 --- /dev/null +++ b/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateChatWebhookEvents < ActiveRecord::Migration[6.1] + def change + create_table :chat_webhook_events do |t| + t.integer :chat_message_id, null: false + t.integer :incoming_chat_webhook_id, null: false + t.timestamps + end + + add_index :chat_webhook_events, + %i[chat_message_id incoming_chat_webhook_id], + unique: true, + name: "chat_webhook_events_index" + end +end diff --git a/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb b/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb new file mode 100644 index 0000000000..81eea0a67c --- /dev/null +++ b/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +class CreateUserChatChannelMembership < ActiveRecord::Migration[6.1] + def change + create_table :user_chat_channel_memberships do |t| + t.integer :user_id, null: false + t.integer :chat_channel_id, null: false + t.integer :last_read_message_id + t.boolean :following, default: false, null: false # membership on/off switch + t.boolean :muted, default: false, null: false + t.integer :desktop_notification_level, default: 1, null: false + t.integer :mobile_notification_level, default: 1, null: false + t.timestamps + end + + add_index :user_chat_channel_memberships, + %i[ + user_id + chat_channel_id + desktop_notification_level + mobile_notification_level + following + ], + name: "user_chat_channel_memberships_index" + + add_index :user_chat_channel_memberships, + %i[user_id chat_channel_id], + unique: true, + name: "user_chat_channel_unique_memberships" + end +end diff --git a/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb b/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb new file mode 100644 index 0000000000..d30e690362 --- /dev/null +++ b/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddChatEnabledToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_enabled, :boolean, default: true, null: false + end +end diff --git a/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb b/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb new file mode 100644 index 0000000000..dab116d3c0 --- /dev/null +++ b/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateChatMessagePostConnections < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_post_connections do |t| + t.integer :post_id, null: false + t.integer :chat_message_id, null: false + t.timestamps + end + + add_index :chat_message_post_connections, + %i[post_id chat_message_id], + unique: true, + name: "chat_message_post_connections_index" + end +end diff --git a/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb b/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb new file mode 100644 index 0000000000..8844686c9a --- /dev/null +++ b/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddChatIsolatedToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_isolated, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb b/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb new file mode 100644 index 0000000000..5de025e4ed --- /dev/null +++ b/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddOnlyChatPushNotificationsToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :only_chat_push_notifications, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb b/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb new file mode 100644 index 0000000000..876de7cf2d --- /dev/null +++ b/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddCookedToChatMessages < ActiveRecord::Migration[6.1] + def change + add_column :chat_messages, :cooked, :text + add_column :chat_messages, :cooked_version, :integer + end +end diff --git a/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb b/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb new file mode 100644 index 0000000000..7eb9d56683 --- /dev/null +++ b/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateChatUploads < ActiveRecord::Migration[6.1] + def change + create_table :chat_uploads do |t| + t.integer :chat_message_id, null: false + t.integer :upload_id, null: false + t.timestamps + end + + add_index :chat_uploads, %i[chat_message_id upload_id], unique: true + end +end diff --git a/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb b/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb new file mode 100644 index 0000000000..377849e509 --- /dev/null +++ b/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class CreateChatReactions < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_reactions do |t| + t.integer :chat_message_id + t.integer :user_id + t.string :emoji + t.timestamps + end + + add_index :chat_message_reactions, + %i[chat_message_id user_id emoji], + unique: true, + name: :chat_message_reactions_index + end +end diff --git a/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb b/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb new file mode 100644 index 0000000000..26f42042d3 --- /dev/null +++ b/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class CreateChatMentions < ActiveRecord::Migration[6.1] + def change + create_table :chat_mentions do |t| + t.integer :chat_message_id, null: false + t.integer :user_id, null: false + t.integer :notification_id, null: false + t.timestamps + end + + add_index :chat_mentions, + %i[chat_message_id user_id notification_id], + unique: true, + name: "chat_mentions_index" + end +end diff --git a/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb b/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb new file mode 100644 index 0000000000..3eae79c91f --- /dev/null +++ b/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddChatSoundToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_sound, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb b/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb new file mode 100644 index 0000000000..7a651cbca5 --- /dev/null +++ b/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddNameToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :name, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb b/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb new file mode 100644 index 0000000000..6169fd8f8b --- /dev/null +++ b/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddDescriptionToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :description, :text, null: true + end +end diff --git a/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb b/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb new file mode 100644 index 0000000000..1b3c40d8cd --- /dev/null +++ b/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddChatRetentionFieldsToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :dismissed_channel_retention_reminder, :boolean, null: true + add_column :user_options, :dismissed_dm_retention_reminder, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb b/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb new file mode 100644 index 0000000000..aa33c9d1fe --- /dev/null +++ b/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateChatDraftsTable < ActiveRecord::Migration[6.1] + def change + create_table :chat_drafts do |t| + t.integer :user_id, null: false + t.integer :chat_channel_id, null: false + t.text :data, null: false + t.timestamps + end + end +end diff --git a/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb b/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb new file mode 100644 index 0000000000..8b624115ab --- /dev/null +++ b/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class MigrateDraftsToChatDrafts < ActiveRecord::Migration[6.1] + def up + execute <<~SQL + INSERT INTO chat_drafts(user_id, chat_channel_id, data, created_at, updated_at) + SELECT user_id, SUBSTRING(draft_key, LENGTH('chat_') + 1)::integer chat_channel_id, data, created_at, updated_at + FROM drafts + WHERE draft_key LIKE 'chat_%' + SQL + + execute <<~SQL + DELETE FROM drafts + WHERE draft_key LIKE 'chat_%' + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb b/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb new file mode 100644 index 0000000000..2ba06853db --- /dev/null +++ b/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +# +class AddStatusToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :status, :integer, default: 0, null: false + add_index :chat_channels, :status + end +end diff --git a/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb b/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb new file mode 100644 index 0000000000..077f467f0c --- /dev/null +++ b/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# +class CreateChatChannelArchiveTable < ActiveRecord::Migration[6.1] + def change + create_table :chat_channel_archives do |t| + t.integer :chat_channel_id, null: false + t.integer :archived_by_id, null: false + t.integer :destination_topic_id + t.string :destination_topic_title + t.integer :destination_category_id + t.column :destination_tags, :string, array: true + t.integer :total_messages, null: false + t.integer :archived_messages, default: 0, null: false + t.string :archive_error + + t.timestamps + end + + add_index :chat_channel_archives, :chat_channel_id + end +end diff --git a/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb b/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb new file mode 100644 index 0000000000..efcc057cb2 --- /dev/null +++ b/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddUserCountToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :user_count, :integer, null: true, default: 0 + change_column_null :chat_channels, :user_count, false + end +end diff --git a/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb b/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb new file mode 100644 index 0000000000..5f07d4cf8c --- /dev/null +++ b/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddLastMessageCreatedAtToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :last_message_sent_at, :datetime, default: -> { "CURRENT_TIMESTAMP" } + change_column_null :chat_channels, :last_message_sent_at, false + end +end diff --git a/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb b/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb new file mode 100644 index 0000000000..d902c46281 --- /dev/null +++ b/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class IgnoreChannelWideMentionToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :ignore_channel_wide_mention, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb b/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb new file mode 100644 index 0000000000..a1012b44a8 --- /dev/null +++ b/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateUserChatMessageStatuses < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_email_statuses do |t| + t.integer :chat_message_id, null: false + t.integer :user_id, null: false + t.integer :status, null: false, default: 0 + t.integer :type, null: false + t.timestamps + end + + add_index :chat_message_email_statuses, + %i[user_id chat_message_id], + name: "chat_message_email_status_user_message_index" + add_index :chat_message_email_statuses, :status + + add_column :user_options, :chat_email_frequency, :integer, default: 1, null: false + add_column :user_options, :last_emailed_for_chat, :datetime, null: true + end +end diff --git a/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb b/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb new file mode 100644 index 0000000000..bf914e8da7 --- /dev/null +++ b/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TrackLastUnreadMentionWhenEmailed < ActiveRecord::Migration[7.0] + def change + add_column :user_chat_channel_memberships, :last_unread_mention_when_emailed_id, :integer + end +end diff --git a/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb b/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb new file mode 100644 index 0000000000..b1117a9c05 --- /dev/null +++ b/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AutoJoinUsersToChannels < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :auto_join_users, :boolean, null: false, default: false + end +end diff --git a/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb b/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb new file mode 100644 index 0000000000..2ab6ea1644 --- /dev/null +++ b/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddJoinModeToChannelMemberships < ActiveRecord::Migration[7.0] + def change + add_column :user_chat_channel_memberships, :join_mode, :integer, null: false, default: 0 + end +end diff --git a/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb b/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb new file mode 100644 index 0000000000..16a2197be4 --- /dev/null +++ b/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddIndexToChatMessageCreatedAt < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + execute <<~SQL + CREATE INDEX CONCURRENTLY IF NOT EXISTS + idx_chat_messages_by_created_at_not_deleted + ON chat_messages (created_at) + WHERE deleted_at IS NULL + SQL + end + + def down + execute <<~SQL + DROP INDEX IF EXISTS idx_chat_messages_by_created_at_not_deleted + SQL + end +end diff --git a/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb b/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb new file mode 100644 index 0000000000..49a8833cd8 --- /dev/null +++ b/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class DisableChatUploadsIfSecureMediaEnabled < ActiveRecord::Migration[7.0] + ## + # At this point in time, secure media is not compatible with chat, + # so if it is enabled then chat uploads must be disabled to avoid undesirable + # behaviour. + # + # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep + # it enabled, but this is strongly advised against. + def up + chat_allow_uploads_value = + DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_allow_uploads'").first + + # nil means it is true, since the default value is true + chat_uploads_enabled = chat_allow_uploads_value == "t" || chat_allow_uploads_value.nil? + + secure_media_enabled = + DB.query_single("SELECT value FROM site_settings WHERE name = 'secure_media'").first == "t" + secure_uploads_enabled = + DB.query_single("SELECT value FROM site_settings WHERE name = 'secure_uploads'").first == "t" + + if (secure_media_enabled || secure_uploads_enabled) && chat_uploads_enabled && + !GlobalSetting.allow_unsecure_chat_uploads + if chat_allow_uploads_value.nil? + DB.exec( + " + INSERT INTO site_settings(name, data_type, value, created_at, updated_at) + VALUES('chat_allow_uploads', 5, 'f', NOW(), NOW()) + ", + ) + else + DB.exec("UPDATE site_settings SET value = 'f' WHERE name = 'chat_allow_uploads'") + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb b/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb new file mode 100644 index 0000000000..b62fee3254 --- /dev/null +++ b/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUserCountStaleToChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :user_count_stale, :boolean, default: false, null: false + end +end diff --git a/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb b/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb new file mode 100644 index 0000000000..236c1044f0 --- /dev/null +++ b/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTypeToChatChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :type, :string + end +end diff --git a/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb b/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb new file mode 100644 index 0000000000..ac9aa99814 --- /dev/null +++ b/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSlugColumnToChatChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :slug, :string + + add_index :chat_channels, :slug + end +end diff --git a/plugins/chat/db/migrate/20221101061319_add_last_editor_id_to_chat_messages.rb b/plugins/chat/db/migrate/20221101061319_add_last_editor_id_to_chat_messages.rb new file mode 100644 index 0000000000..feb782b127 --- /dev/null +++ b/plugins/chat/db/migrate/20221101061319_add_last_editor_id_to_chat_messages.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddLastEditorIdToChatMessages < ActiveRecord::Migration[7.0] + def change + add_column :chat_messages, :last_editor_id, :integer + add_column :chat_message_revisions, :user_id, :integer + + add_index :chat_messages, :last_editor_id + add_index :chat_message_revisions, :user_id + end +end diff --git a/plugins/chat/db/migrate/20221107034541_make_chat_editor_ids_not_null.rb b/plugins/chat/db/migrate/20221107034541_make_chat_editor_ids_not_null.rb new file mode 100644 index 0000000000..04fd6c79bd --- /dev/null +++ b/plugins/chat/db/migrate/20221107034541_make_chat_editor_ids_not_null.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MakeChatEditorIdsNotNull < ActiveRecord::Migration[7.0] + def change + DB.exec("UPDATE chat_messages SET last_editor_id = user_id") + DB.exec(<<~SQL) + UPDATE chat_message_revisions cmr + SET user_id = cm.user_id + FROM chat_messages AS cm + WHERE cmr.chat_message_id = cm.id + SQL + + change_column_null :chat_messages, :last_editor_id, false + change_column_null :chat_message_revisions, :user_id, false + end +end diff --git a/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb b/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb new file mode 100644 index 0000000000..ed4a829d4a --- /dev/null +++ b/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ChangeChatChannelsTimestampColumnsToTimestampType < ActiveRecord::Migration[6.1] + def change + change_column_default :chat_channels, :created_at, nil + change_column_default :chat_channels, :updated_at, nil + + # the earlier AddTimestampsToChatChannels migration has been modified, + # originally it added the columns as :datetime types, now it has been + # changed to use the correct :timestamp type, this exists check is here so + # we only try and make this change on old tables created before + if !column_exists?(:chat_channels, :created_at, :timestamp) + change_column :chat_channels, :created_at, :timestamp + end + if !column_exists?(:chat_channels, :updated_at, :timestamp) + change_column :chat_channels, :updated_at, :timestamp + end + end +end diff --git a/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb b/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb new file mode 100644 index 0000000000..ca6d3b7957 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "migration/table_dropper" + +class DropChatMessagePostConnectionsTable < ActiveRecord::Migration[6.1] + def up + Migration::TableDropper.execute_drop("chat_message_post_connections") + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb b/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb new file mode 100644 index 0000000000..6ca1c406b8 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DropOldChatMessagePostIdActionCodeColumns < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { chat_messages: %i[post_id action_code] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb b/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb new file mode 100644 index 0000000000..4daf38ae0b --- /dev/null +++ b/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RemoveEmailStatusesTable < ActiveRecord::Migration[7.0] + def up + remove_index :chat_message_email_statuses, :status + remove_index :chat_message_email_statuses, %i[user_id chat_message_id] + + Migration::TableDropper.execute_drop("chat_message_email_statuses") + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb b/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb new file mode 100644 index 0000000000..d55b9eec23 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveUserOptionLastEmailedAt < ActiveRecord::Migration[7.0] + def change + remove_column :user_options, :last_emailed_for_chat, :datetime + end +end diff --git a/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb b/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb new file mode 100644 index 0000000000..afcee809a4 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class RemoveCorruptedLastReadMessageId < ActiveRecord::Migration[7.0] + def down + raise ActiveRecord::IrreversibleMigration + end + + def up + # Delete memberships for deleted channels + execute <<~SQL + DELETE FROM user_chat_channel_memberships uccm + WHERE NOT EXISTS ( + SELECT FROM chat_channels cc + WHERE cc.id = uccm.chat_channel_id + ); + SQL + + # Delete messages for deleted channels + execute <<~SQL + DELETE FROM chat_messages cm + WHERE NOT EXISTS ( + SELECT FROM chat_channels cc + WHERE cc.id = cm.chat_channel_id + ); + SQL + + # Reset highest_channel_message_id if the message cannot be found in the channel + execute <<~SQL + WITH highest_channel_message_id AS ( + SELECT chat_channel_id, max(chat_messages.id) as highest_id + FROM chat_messages + GROUP BY chat_channel_id + ) + UPDATE user_chat_channel_memberships uccm + SET last_read_message_id = highest_channel_message_id.highest_id + FROM highest_channel_message_id + WHERE highest_channel_message_id.chat_channel_id = uccm.chat_channel_id + AND uccm.last_read_message_id IS NOT NULL + AND uccm.last_read_message_id NOT IN ( + SELECT id FROM chat_messages WHERE chat_messages.chat_channel_id = uccm.chat_channel_id + ) + SQL + + # Nullify in_reply_to where message is deleted + execute <<~SQL + UPDATE chat_messages cm + SET in_reply_to_id = NULL + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm2 + WHERE cm.in_reply_to_id = cm2.id + ); + SQL + + # Delete chat_message_revisions with no message linked + execute <<~SQL + DELETE FROM chat_message_revisions cmr + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cmr.chat_message_id + ); + SQL + + # Delete chat_message_reactions with no message linked + execute <<~SQL + DELETE FROM chat_message_reactions cmr + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cmr.chat_message_id + ); + SQL + + # Delete bookmarks with no message linked + execute <<~SQL + DELETE FROM bookmarks b + WHERE b.bookmarkable_type = 'ChatMessage' + AND NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = b.bookmarkable_id + ); + SQL + + # Delete chat_mention with no message linked + execute <<~SQL + DELETE FROM chat_mentions + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = chat_mentions.chat_message_id + ); + SQL + + # Delete chat_webhook_event with no message linked + execute <<~SQL + DELETE FROM chat_webhook_events cwe + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cwe.chat_message_id + ); + SQL + + # Delete chat_uploads with no message linked + execute <<~SQL + DELETE FROM chat_uploads + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = chat_uploads.chat_message_id + ); + SQL + end +end diff --git a/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb b/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb new file mode 100644 index 0000000000..b746266d85 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "migration/table_dropper" + +# usage has been dropped in https://github.com/discourse/discourse-chat/commit/1c110b71b28411dc7ac3ab9e3950e0bbf38d7970 +# but table never got dropped +class DropUserChatChannelLastReads < ActiveRecord::Migration[7.0] + DROPPED_TABLES ||= %i[user_chat_channel_last_reads] + + def up + DROPPED_TABLES.each { |table| Migration::TableDropper.execute_drop(table) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb b/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb new file mode 100644 index 0000000000..0a41149418 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DropChatIsolatedFromUserOptions < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { user_options: %i[chat_isolated] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb b/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb new file mode 100644 index 0000000000..05e61f5f82 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ConvertChatableTopicsToCategories < ActiveRecord::Migration[7.0] + def up + # convert chatable topics to categories using topic's category_id or default category + DB.exec(<<~SQL, uncategorized_category_id: SiteSetting.uncategorized_category_id) + UPDATE chat_channels cc + SET (chatable_type, chatable_id, name) = ( + SELECT 'Category', coalesce(t.category_id, :uncategorized_category_id), coalesce(cc.name, t.title) + FROM topics t + WHERE cc.chatable_id = t.id + ) + WHERE cc.chatable_type = 'Topic' + SQL + + # soft delete all posts small actions + DB.exec( + "UPDATE posts SET deleted_at = :deleted_at, deleted_by_id = :deleted_by_id WHERE action_code IN (:action_codes)", + action_codes: %w[chat.enabled chat.disabled], + deleted_at: Time.zone.now, + deleted_by_id: Discourse::SYSTEM_USER_ID, + ) + + # removes all chat custom fields + DB.exec(<<~SQL) + DELETE FROM topic_custom_fields + WHERE name = 'has_chat_enabled' + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb b/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb new file mode 100644 index 0000000000..bab35b96f7 --- /dev/null +++ b/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class DeleteReviewablesTargettingDeletedChatMessages < ActiveRecord::Migration[7.0] + def down + raise ActiveRecord::IrreversibleMigration + end + + def up + deleted_ids = DB.query_single <<~SQL + DELETE FROM reviewables r + WHERE r.type = 'ReviewableChatMessage' + AND r.id IN ( + SELECT raux.id + FROM reviewables raux + LEFT OUTER JOIN chat_messages cm ON cm.id = raux.target_id + WHERE raux.type = 'ReviewableChatMessage' AND cm.id IS NULL + ) + RETURNING r.id + SQL + + if deleted_ids + DB.exec(<<~SQL, deleted_ids: deleted_ids) + DELETE FROM reviewable_scores rs + WHERE rs.reviewable_id IN (:deleted_ids) + SQL + + DB.exec(<<~SQL, deleted_ids: deleted_ids) + DELETE FROM reviewable_histories rh + WHERE rh.reviewable_id IN (:deleted_ids) + SQL + end + end +end diff --git a/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb b/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb new file mode 100644 index 0000000000..8a879b3a55 --- /dev/null +++ b/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MigrateChatChannels < ActiveRecord::Migration[7.0] + def up + DB.exec("UPDATE chat_channels SET type='CategoryChannel' WHERE chatable_type = 'Category'") + DB.exec( + "UPDATE chat_channels SET type='DMChannel' WHERE chatable_type = 'DirectMessageChannel'", + ) + end +end diff --git a/plugins/chat/db/post_migrate/20221027090832_migrate_dm_channels.rb b/plugins/chat/db/post_migrate/20221027090832_migrate_dm_channels.rb new file mode 100644 index 0000000000..f2510486d6 --- /dev/null +++ b/plugins/chat/db/post_migrate/20221027090832_migrate_dm_channels.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MigrateDmChannels < ActiveRecord::Migration[7.0] + def up + DB.exec( + "UPDATE chat_channels SET type='DirectMessageChannel', chatable_type='DirectMessage' WHERE chatable_type = 'DirectMessageChannel'", + ) + end +end diff --git a/plugins/chat/db/post_migrate/20221104054957_backfill_channel_slugs.rb b/plugins/chat/db/post_migrate/20221104054957_backfill_channel_slugs.rb new file mode 100644 index 0000000000..881ff9f04d --- /dev/null +++ b/plugins/chat/db/post_migrate/20221104054957_backfill_channel_slugs.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class BackfillChannelSlugs < ActiveRecord::Migration[7.0] + def up + channels = DB.query(<<~SQL) + SELECT chat_channels.id, COALESCE(chat_channels.name, categories.name) AS title, NULL as slug + FROM chat_channels + INNER JOIN categories ON categories.id = chat_channels.chatable_id + WHERE chat_channels.chatable_type = 'Category' AND chat_channels.slug IS NULL + SQL + return if channels.count.zero? + + DB.exec("CREATE TEMPORARY TABLE tmp_chat_channel_slugs(id int, slug text)") + + taken_slugs = {} + channels.each do |channel| + # Simplified version of Slug.for generation that doesn't take into + # account different encodings to make things a little easier. + title = channel.title + if title.blank? + channel.slug = "channel-#{channel.id}" + else + channel.slug = + title + .downcase + .chomp + .tr("'", "") + .parameterize + .tr("_", "-") + .truncate(255, omission: "") + .squeeze("-") + .gsub(/\A-+|-+\z/, "") + end + + # Deduplicate slugs with the channel IDs, we can always improve + # slugs later on. + channel.slug = "#{channel.slug}-#{channel.id}" if taken_slugs.key?(channel.slug) + taken_slugs[channel.slug] = true + end + + values_to_insert = + channels.map { |channel| "(#{channel.id}, '#{PG::Connection.escape_string(channel.slug)}')" } + + DB.exec( + "INSERT INTO tmp_chat_channel_slugs + VALUES #{values_to_insert.join(",\n")}", + ) + + DB.exec(<<~SQL) + UPDATE chat_channels cc + SET slug = tmp.slug + FROM tmp_chat_channel_slugs tmp + WHERE cc.id = tmp.id AND cc.slug IS NULL + SQL + + DB.exec("DROP TABLE tmp_chat_channel_slugs") + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb new file mode 100644 index 0000000000..8d1e0e65e0 --- /dev/null +++ b/plugins/chat/lib/chat_channel_archive_service.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +## +# From time to time, site admins may choose to sunset a chat channel and archive +# the messages within. The main use case for this is a topic-based channel, but +# it can be used for category channels just fine. It cannot be used for DM channels +# in its current iteration. +# +# To archive a channel, we mark it read_only first to prevent any further message +# additions or changes, and create a record to track whether the archive topic +# will be new or existing. When we archive the channel, messages are copied into +# posts in batches using the [chat] BBCode to quote the messages. The messages are +# deleted once the batch has its post made. The execute action of this class is +# idempotent, so if we fail halfway through the archive process it can be run again. +# +# Once all of the messages have been copied then we mark the channel as archived. +class Chat::ChatChannelArchiveService + ARCHIVED_MESSAGES_PER_POST = 100 + + def self.begin_archive_process(chat_channel:, acting_user:, topic_params:) + return if ChatChannelArchive.exists?(chat_channel: chat_channel) + + ChatChannelArchive.transaction do + chat_channel.read_only!(acting_user) + + archive = + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: acting_user, + total_messages: chat_channel.chat_messages.count, + destination_topic_id: topic_params[:topic_id], + destination_topic_title: topic_params[:topic_title], + destination_category_id: topic_params[:category_id], + destination_tags: topic_params[:tags], + ) + Jobs.enqueue(:chat_channel_archive, chat_channel_archive_id: archive.id) + + archive + end + end + + def self.retry_archive_process(chat_channel:) + return if !chat_channel.chat_channel_archive&.failed? + Jobs.enqueue( + :chat_channel_archive, + chat_channel_archive_id: chat_channel.chat_channel_archive.id, + ) + end + + attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title + + def initialize(chat_channel_archive) + @chat_channel_archive = chat_channel_archive + @chat_channel = chat_channel_archive.chat_channel + @chat_channel_title = chat_channel.title(chat_channel_archive.archived_by) + end + + def execute + chat_channel_archive.update(archive_error: nil) + + begin + ensure_destination_topic_exists! + + Rails.logger.info( + "Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).", + ) + + # a batch should be idempotent, either the post is created and the + # messages are deleted or we roll back the whole thing. + # + # at some point we may want to reconsider disabling post validations, + # and add in things like dynamic resizing of the number of messages per + # post based on post length, but that can be done later + # + # another future improvement is to send a MessageBus message for each + # completed batch, so the UI can receive updates and show a progress + # bar or something similar + chat_channel + .chat_messages + .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| + create_post( + ChatTranscriptService.new( + chat_channel, + chat_channel_archive.archived_by, + messages_or_ids: chat_messages, + opts: { + no_link: true, + include_reactions: true, + }, + ).generate_markdown, + ) { delete_message_batch(chat_messages.map(&:id)) } + end + + kick_all_users + complete_archive + rescue => err + notify_archiver(:failed, error: err) + raise err + end + end + + private + + def create_post(raw) + pc = nil + Post.transaction do + pc = + PostCreator.new( + Discourse.system_user, + raw: raw, + # we must skip these because the posts are created in a big transaction, + # we do them all at the end instead + skip_jobs: true, + # we do not want to be sending out notifications etc. from this + # automatic background process + import_mode: true, + # don't want to be stopped by watched word or post length validations + skip_validations: true, + topic_id: chat_channel_archive.destination_topic_id, + ) + + pc.create + + # so we can also delete chat messages in the same transaction + yield if block_given? + end + pc.enqueue_jobs + end + + def ensure_destination_topic_exists! + if !chat_channel_archive.destination_topic.present? + Rails.logger.info("Creating topic for #{chat_channel_title} archive.") + Topic.transaction do + topic_creator = + TopicCreator.new( + Discourse.system_user, + Guardian.new(chat_channel_archive.archived_by), + { + title: chat_channel_archive.destination_topic_title, + category: chat_channel_archive.destination_category_id, + tags: chat_channel_archive.destination_tags, + import_mode: true, + }, + ) + + chat_channel_archive.update!(destination_topic: topic_creator.create) + end + + Rails.logger.info("Creating first post for #{chat_channel_title} archive.") + create_post( + I18n.t( + "chat.channel.archive.first_post_raw", + channel_name: chat_channel_title, + channel_url: chat_channel.url, + ), + ) + else + Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") + end + + update_destination_topic_status + end + + def update_destination_topic_status + # we only want to do this when the destination topic is new, not an + # existing topic, because we don't want to update the status unexpectedly + # on an existing topic + if chat_channel_archive.destination_topic_title.present? + if SiteSetting.chat_archive_destination_topic_status == "archived" + chat_channel_archive.destination_topic.update!(archived: true) + elsif SiteSetting.chat_archive_destination_topic_status == "closed" + chat_channel_archive.destination_topic.update!(closed: true) + end + end + end + + def delete_message_batch(message_ids) + ChatMessage.transaction do + ChatMessage.where(id: message_ids).update_all( + deleted_at: DateTime.now, + deleted_by_id: chat_channel_archive.archived_by.id, + ) + + chat_channel_archive.update!( + archived_messages: chat_channel_archive.archived_messages + message_ids.length, + ) + end + + Rails.logger.info( + "Archived #{chat_channel_archive.archived_messages} messages for #{chat_channel_title} archive.", + ) + end + + def complete_archive + Rails.logger.info("Creating posts completed for #{chat_channel_title} archive.") + chat_channel.archived!(chat_channel_archive.archived_by) + notify_archiver(:success) + end + + def notify_archiver(result, error: nil) + base_translation_params = { + channel_name: chat_channel_title, + topic_title: chat_channel_archive.destination_topic.title, + topic_url: chat_channel_archive.destination_topic.url, + } + + if result == :failed + Discourse.warn_exception( + error, + message: "Error when archiving chat channel #{chat_channel_title}.", + env: { + chat_channel_id: chat_channel.id, + chat_channel_name: chat_channel_title, + }, + ) + error_translation_params = + base_translation_params.merge( + channel_url: chat_channel.url, + messages_archived: chat_channel_archive.archived_messages, + ) + chat_channel_archive.update(archive_error: error.message) + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_failed, + error_translation_params, + ) + else + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_complete, + base_translation_params, + ) + end + + ChatPublisher.publish_archive_status( + chat_channel, + archive_status: result, + archived_messages: chat_channel_archive.archived_messages, + archive_topic_id: chat_channel_archive.destination_topic_id, + total_messages: chat_channel_archive.total_messages, + ) + end + + def kick_all_users + Chat::ChatChannelMembershipManager.new(chat_channel).unfollow_all_users + end +end diff --git a/plugins/chat/lib/chat_channel_fetcher.rb b/plugins/chat/lib/chat_channel_fetcher.rb new file mode 100644 index 0000000000..714737043f --- /dev/null +++ b/plugins/chat/lib/chat_channel_fetcher.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +module Chat::ChatChannelFetcher + MAX_PUBLIC_CHANNEL_RESULTS = 50 + + def self.structured(guardian) + memberships = Chat::ChatChannelMembershipManager.all_for_user(guardian.user) + { + public_channels: + secured_public_channels(guardian, memberships, status: :open, following: true), + direct_message_channels: + secured_direct_message_channels(guardian.user.id, memberships, guardian), + memberships: memberships, + } + end + + def self.all_secured_channel_ids(guardian, following: true) + allowed_channel_ids_sql = generate_allowed_channel_ids_sql(guardian) + + return DB.query_single(allowed_channel_ids_sql) if !following + + DB.query_single(<<~SQL, user_id: guardian.user.id) + SELECT chat_channel_id + FROM user_chat_channel_memberships + WHERE user_chat_channel_memberships.user_id = :user_id + AND user_chat_channel_memberships.chat_channel_id IN ( + #{allowed_channel_ids_sql} + ) + SQL + end + + def self.generate_allowed_channel_ids_sql(guardian) + <<~SQL + -- secured category chat channels + #{ + ChatChannel + .select(:id) + .joins( + "INNER JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where( + "categories.id IN (:allowed_category_ids)", + allowed_category_ids: guardian.allowed_category_ids, + ) + .to_sql + } + + UNION + + -- secured direct message chat channels + #{ + ChatChannel + .select(:id) + .joins( + "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id + AND chat_channels.chatable_type = 'DirectMessage' + INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id", + ) + .where("direct_message_users.user_id = :user_id", user_id: guardian.user.id) + .to_sql + } + SQL + end + + def self.secured_public_channel_search(guardian, options = {}) + channels = + ChatChannel + .includes(:chat_channel_archive) + .includes(chatable: [:topic_only_relative_url]) + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: ChatChannel.public_channel_chatable_types) + .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") + + channels = channels.where(status: options[:status]) if options[:status].present? + + if options[:filter].present? + sql = "chat_channels.name ILIKE :filter OR categories.name ILIKE :filter" + channels = + channels.where(sql, filter: "%#{options[:filter].downcase}%").order( + "chat_channels.name ASC, categories.name ASC", + ) + end + + if options.key?(:following) + if options[:following] + channels = + channels.joins(:user_chat_channel_memberships).where( + user_chat_channel_memberships: { + user_id: guardian.user.id, + following: true, + }, + ) + else + channels = + channels.where( + "chat_channels.id NOT IN (SELECT chat_channel_id FROM user_chat_channel_memberships uccm WHERE uccm.chat_channel_id = chat_channels.id AND following IS TRUE AND user_id = ?)", + guardian.user.id, + ) + end + end + + options[:limit] = (options[:limit] || MAX_PUBLIC_CHANNEL_RESULTS).to_i.clamp( + 1, + MAX_PUBLIC_CHANNEL_RESULTS, + ) + options[:offset] = [options[:offset].to_i, 0].max + + channels.limit(options[:limit]).offset(options[:offset]) + end + + def self.secured_public_channels(guardian, memberships, options = { following: true }) + channels = secured_public_channel_search(guardian, options) + decorate_memberships_with_tracking_data(guardian, channels, memberships) + channels = channels.to_a + preload_custom_fields_for(channels) + channels + end + + def self.preload_custom_fields_for(channels) + preload_fields = Category.instance_variable_get(:@custom_field_types).keys + Category.preload_custom_fields( + channels.select { |c| c.chatable_type == "Category" }.map(&:chatable), + preload_fields, + ) + end + + def self.secured_direct_message_channels(user_id, memberships, guardian) + query = ChatChannel.includes(chatable: [{ direct_message_users: :user }, :users]) + query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status + + channels = + query + .joins(:user_chat_channel_memberships) + .where(user_chat_channel_memberships: { user_id: user_id, following: true }) + .where(chatable_type: "DirectMessage") + .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") + .order(last_message_sent_at: :desc) + .to_a + + preload_fields = + User.allowed_user_custom_fields(guardian) + + UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" } + User.preload_custom_fields(channels.map { |c| c.chatable.users }.flatten, preload_fields) + + decorate_memberships_with_tracking_data(guardian, channels, memberships) + end + + def self.decorate_memberships_with_tracking_data(guardian, channels, memberships) + unread_counts_per_channel = unread_counts(channels, guardian.user.id) + + mention_notifications = + Notification.unread.where( + user_id: guardian.user.id, + notification_type: Notification.types[:chat_mention], + ) + mention_notification_data = mention_notifications.map { |m| JSON.parse(m.data) } + + channels.each do |channel| + membership = memberships.find { |m| m.chat_channel_id == channel.id } + + if membership + membership.unread_mentions = + mention_notification_data.count do |data| + data["chat_channel_id"] == channel.id && + data["chat_message_id"] > (membership.last_read_message_id || 0) + end + + membership.unread_count = unread_counts_per_channel[channel.id] if !membership.muted + end + end + end + + def self.unread_counts(channels, user_id) + unread_counts = DB.query_array(<<~SQL, channel_ids: channels.map(&:id), user_id: user_id).to_h + SELECT cc.id, COUNT(*) as count + FROM chat_messages cm + JOIN chat_channels cc ON cc.id = cm.chat_channel_id + JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = cc.id + WHERE cc.id IN (:channel_ids) + AND cm.user_id != :user_id + AND uccm.user_id = :user_id + AND cm.id > COALESCE(uccm.last_read_message_id, 0) + AND cm.deleted_at IS NULL + GROUP BY cc.id + SQL + unread_counts.default = 0 + unread_counts + end + + def self.find_with_access_check(channel_id_or_name, guardian) + begin + channel_id_or_name = Integer(channel_id_or_name) + rescue ArgumentError + end + + base_channel_relation = + ChatChannel.includes(:chatable).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + + if guardian.user.staff? + base_channel_relation = base_channel_relation.includes(:chat_channel_archive) + end + + if channel_id_or_name.is_a? Integer + chat_channel = base_channel_relation.find_by(id: channel_id_or_name) + else + chat_channel = + base_channel_relation.find_by( + "LOWER(categories.name) = :name OR LOWER(chat_channels.name) = :name", + name: channel_id_or_name.downcase, + ) + end + + raise Discourse::NotFound if chat_channel.blank? + raise Discourse::InvalidAccess if !guardian.can_see_chat_channel?(chat_channel) + chat_channel + end +end diff --git a/plugins/chat/lib/chat_channel_membership_manager.rb b/plugins/chat/lib/chat_channel_membership_manager.rb new file mode 100644 index 0000000000..5947f23d7a --- /dev/null +++ b/plugins/chat/lib/chat_channel_membership_manager.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class Chat::ChatChannelMembershipManager + def self.all_for_user(user) + UserChatChannelMembership.where(user: user) + end + + attr_reader :channel + + def initialize(channel) + @channel = channel + end + + def find_for_user(user, following: nil) + params = { user_id: user.id, chat_channel_id: channel.id } + params[:following] = following if following.present? + + UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) + end + + def follow(user) + membership = + find_for_user(user) || + UserChatChannelMembership.new(user: user, chat_channel: channel, following: true) + + ActiveRecord::Base.transaction do + if membership.new_record? + membership.save! + recalculate_user_count + elsif !membership.following + membership.update!(following: true) + recalculate_user_count + end + end + + membership + end + + def unfollow(user) + membership = find_for_user(user) + + return if membership.blank? + + ActiveRecord::Base.transaction do + if membership.following + membership.update!(following: false) + recalculate_user_count + end + end + + membership + end + + def recalculate_user_count + return if ChatChannel.exists?(id: channel.id, user_count_stale: true) + channel.update!(user_count_stale: true) + Jobs.enqueue_in(3.seconds, :update_channel_user_count, chat_channel_id: channel.id) + end + + def unfollow_all_users + UserChatChannelMembership.where(chat_channel: channel).update_all( + following: false, + last_read_message_id: channel.chat_messages.last&.id, + ) + end + + def enforce_automatic_channel_memberships + Jobs.enqueue(:auto_manage_channel_memberships, chat_channel_id: channel.id) + end + + def enforce_automatic_user_membership(user) + Jobs.enqueue( + :auto_join_channel_batch, + chat_channel_id: channel.id, + starts_at: user.id, + ends_at: user.id, + ) + end +end diff --git a/plugins/chat/lib/chat_mailer.rb b/plugins/chat/lib/chat_mailer.rb new file mode 100644 index 0000000000..8c914b497d --- /dev/null +++ b/plugins/chat/lib/chat_mailer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Chat::ChatMailer + def self.send_unread_mentions_summary + return unless SiteSetting.chat_enabled + + users_with_unprocessed_unread_mentions.find_each do |user| + # user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]] + # Find the max unread id per membership. + membership_and_max_unread_mention_ids = + user + .memberships_with_unread_messages + .group_by { |memberships| memberships[0] } + .transform_values do |membership_and_msg_ids| + membership_and_msg_ids.max_by { |membership, msg| msg } + end + .values + + Jobs.enqueue( + :user_email, + type: "chat_summary", + user_id: user.id, + force_respect_seen_recently: true, + memberships_to_update_data: membership_and_max_unread_mention_ids, + ) + end + end + + private + + def self.users_with_unprocessed_unread_mentions + when_away_frequency = UserOption.chat_email_frequencies[:when_away] + allowed_group_ids = Chat.allowed_group_ids + + users = User + .joins(:user_option) + .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) + .where("users.last_seen_at < ?", 15.minutes.ago) + + if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) + users = users.joins(:groups).where(groups: { id: allowed_group_ids }) + end + + users + .select("users.id", "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages") + .joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id") + .joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id") + .joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id") + .where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id") + .where("c_msg.created_at > ?", 1.week.ago) + .where(<<~SQL) + (uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR + (cc.chatable_type = 'DirectMessage') + ) + SQL + .group("users.id, uccm.user_id") + end +end diff --git a/plugins/chat/lib/chat_message_bookmarkable.rb b/plugins/chat/lib/chat_message_bookmarkable.rb new file mode 100644 index 0000000000..8a72d0f9f2 --- /dev/null +++ b/plugins/chat/lib/chat_message_bookmarkable.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ChatMessageBookmarkable < BaseBookmarkable + def self.model + ChatMessage + end + + def self.serializer + UserChatMessageBookmarkSerializer + end + + def self.preload_associations + [:chat_channel] + end + + def self.list_query(user, guardian) + accessible_channel_ids = Chat::ChatChannelFetcher.all_secured_channel_ids(guardian) + return if accessible_channel_ids.empty? + user + .bookmarks_of_type("ChatMessage") + .joins( + "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id + AND chat_messages.deleted_at IS NULL + AND bookmarks.bookmarkable_type = 'ChatMessage'", + ) + .where("chat_messages.chat_channel_id IN (?)", accessible_channel_ids) + end + + def self.search_query(bookmarks, query, ts_query, &bookmarkable_search) + bookmarkable_search.call(bookmarks, "chat_messages.message ILIKE :q") + end + + def self.validate_before_create(guardian, bookmarkable) + if bookmarkable.blank? || !guardian.can_see_chat_channel?(bookmarkable.chat_channel) + raise Discourse::InvalidAccess + end + end + + def self.reminder_handler(bookmark) + send_reminder_notification( + bookmark, + data: { + title: + I18n.t( + "chat.bookmarkable.notification_title", + channel_name: bookmark.bookmarkable.chat_channel.title(bookmark.user), + ), + bookmarkable_url: bookmark.bookmarkable.url, + }, + ) + end + + def self.reminder_conditions(bookmark) + bookmark.bookmarkable.present? && bookmark.bookmarkable.chat_channel.present? + end + + def self.can_see?(guardian, bookmark) + guardian.can_see_chat_channel?(bookmark.bookmarkable.chat_channel) + end + + def self.cleanup_deleted + DB.query(<<~SQL, grace_time: 3.days.ago) + DELETE FROM bookmarks b + USING chat_messages cm + WHERE b.bookmarkable_id = cm.id AND b.bookmarkable_type = 'ChatMessage' + AND (cm.deleted_at < :grace_time) + SQL + end +end diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb new file mode 100644 index 0000000000..49b9f58b9f --- /dev/null +++ b/plugins/chat/lib/chat_message_creator.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true +class Chat::ChatMessageCreator + attr_reader :error, :chat_message + + def self.create(opts) + instance = new(**opts) + instance.create + instance + end + + def initialize( + chat_channel:, + in_reply_to_id: nil, + user:, + content:, + staged_id: nil, + incoming_chat_webhook: nil, + upload_ids: nil + ) + @chat_channel = chat_channel + @user = user + @guardian = Guardian.new(user) + @in_reply_to_id = in_reply_to_id + @content = content + @staged_id = staged_id + @incoming_chat_webhook = incoming_chat_webhook + @upload_ids = upload_ids || [] + @error = nil + + @chat_message = + ChatMessage.new( + chat_channel: @chat_channel, + user_id: @user.id, + in_reply_to_id: @in_reply_to_id, + message: @content, + ) + end + + def create + begin + validate_channel_status! + uploads = get_uploads + validate_message!(has_uploads: uploads.any?) + @chat_message.cook + @chat_message.save! + create_chat_webhook_event + @chat_message.attach_uploads(uploads) + ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all + ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id) + Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) + Chat::ChatNotifier.notify_new( + chat_message: @chat_message, + timestamp: @chat_message.created_at, + ) + DiscourseEvent.trigger(:chat_message_created, @chat_message, @chat_channel, @user) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + + if @chat_channel.direct_message_channel? && !@guardian.can_create_direct_message? + raise StandardError.new(I18n.t("chat.errors.user_cannot_send_direct_messages")) + else + raise StandardError.new( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: @chat_channel.status_name, + ), + ) + end + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def create_chat_webhook_event + return if @incoming_chat_webhook.blank? + ChatWebhookEvent.create( + chat_message: @chat_message, + incoming_chat_webhook: @incoming_chat_webhook, + ) + end + + def get_uploads + return [] if @upload_ids.blank? || !SiteSetting.chat_allow_uploads + + Upload.where(id: @upload_ids, user_id: @user.id) + end +end diff --git a/plugins/chat/lib/chat_message_processor.rb b/plugins/chat/lib/chat_message_processor.rb new file mode 100644 index 0000000000..078e73cf0a --- /dev/null +++ b/plugins/chat/lib/chat_message_processor.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Chat::ChatMessageProcessor + include ::CookedProcessorMixin + + def initialize(chat_message) + @model = chat_message + @previous_cooked = (chat_message.cooked || "").dup + @with_secure_uploads = false + @size_cache = {} + @opts = {} + + cooked = ChatMessage.cook(chat_message.message) + @doc = Loofah.fragment(cooked) + end + + def run! + post_process_oneboxes + DiscourseEvent.trigger(:chat_message_processed, @doc, @model) + end + + def large_images + [] + end + + def broken_images + [] + end + + def downloaded_images + {} + end +end diff --git a/plugins/chat/lib/chat_message_rate_limiter.rb b/plugins/chat/lib/chat_message_rate_limiter.rb new file mode 100644 index 0000000000..9f205098e7 --- /dev/null +++ b/plugins/chat/lib/chat_message_rate_limiter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Chat::ChatMessageRateLimiter + def self.run!(user) + instance = self.new(user) + instance.run! + end + + def initialize(user) + @user = user + end + + def run! + return if @user.staff? + + allowed_message_count = + ( + if @user.trust_level == TrustLevel[0] + SiteSetting.chat_allowed_messages_for_trust_level_0 + else + SiteSetting.chat_allowed_messages_for_other_trust_levels + end + ) + return if allowed_message_count.zero? + + @rate_limiter = RateLimiter.new(@user, "create_chat_message", allowed_message_count, 30.seconds) + silence_user if @rate_limiter.remaining.zero? + @rate_limiter.performed! + end + + def clear! + # Used only for testing. Need to clear the rate limiter between tests. + @rate_limiter.clear! if defined?(@rate_limiter) + end + + private + + def silence_user + silenced_for_minutes = SiteSetting.chat_auto_silence_duration + return unless silenced_for_minutes > 0 + + UserSilencer.silence( + @user, + Discourse.system_user, + silenced_till: silenced_for_minutes.minutes.from_now, + reason: I18n.t("chat.errors.rate_limit_exceeded"), + ) + end +end diff --git a/plugins/chat/lib/chat_message_reactor.rb b/plugins/chat/lib/chat_message_reactor.rb new file mode 100644 index 0000000000..9304809cad --- /dev/null +++ b/plugins/chat/lib/chat_message_reactor.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class Chat::ChatMessageReactor + ADD_REACTION = :add + REMOVE_REACTION = :remove + MAX_REACTIONS_LIMIT = 30 + + def initialize(user, chat_channel) + @user = user + @chat_channel = chat_channel + @guardian = Guardian.new(user) + end + + def react!(message_id:, react_action:, emoji:) + @guardian.ensure_can_see_chat_channel!(@chat_channel) + @guardian.ensure_can_react! + validate_channel_status! + validate_reaction!(react_action, emoji) + message = ensure_chat_message!(message_id) + validate_max_reactions!(message, react_action, emoji) + + ActiveRecord::Base.transaction do + enforce_channel_membership! + create_reaction(message, react_action, emoji) + end + + publish_reaction(message, react_action, emoji) + + message + end + + private + + def ensure_chat_message!(message_id) + message = ChatMessage.find_by(id: message_id, chat_channel: @chat_channel) + raise Discourse::NotFound unless message + message + end + + def validate_reaction!(react_action, emoji) + if ![ADD_REACTION, REMOVE_REACTION].include?(react_action) || !Emoji.exists?(emoji) + raise Discourse::InvalidParameters + end + end + + def enforce_channel_membership! + Chat::ChatChannelMembershipManager.new(@chat_channel).follow(@user) + end + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "chat.errors.channel_modify_message_disallowed", + custom_message_params: { + status: @chat_channel.status_name, + }, + ) + end + + def validate_max_reactions!(message, react_action, emoji) + if react_action == ADD_REACTION && + message.reactions.count("DISTINCT emoji") >= MAX_REACTIONS_LIMIT && + !message.reactions.exists?(emoji: emoji) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "chat.errors.max_reactions_limit_reached", + ) + end + end + + def create_reaction(message, react_action, emoji) + if react_action == ADD_REACTION + message.reactions.find_or_create_by!(user: @user, emoji: emoji) + else + message.reactions.where(user: @user, emoji: emoji).destroy_all + end + end + + def publish_reaction(message, react_action, emoji) + ChatPublisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) + end +end diff --git a/plugins/chat/lib/chat_message_updater.rb b/plugins/chat/lib/chat_message_updater.rb new file mode 100644 index 0000000000..e72bdb3d93 --- /dev/null +++ b/plugins/chat/lib/chat_message_updater.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Chat::ChatMessageUpdater + attr_reader :error + + def self.update(opts) + instance = new(**opts) + instance.update + instance + end + + def initialize(guardian:, chat_message:, new_content:, upload_ids: nil) + @guardian = guardian + @user = guardian.user + @chat_message = chat_message + @old_message_content = chat_message.message + @chat_channel = @chat_message.chat_channel + @new_content = new_content + @upload_ids = upload_ids + @error = nil + end + + def update + begin + validate_channel_status! + @guardian.ensure_can_edit_chat!(@chat_message) + @chat_message.message = @new_content + @chat_message.last_editor_id = @user.id + upload_info = get_upload_info + validate_message!(has_uploads: upload_info[:uploads].any?) + @chat_message.cook + @chat_message.save! + update_uploads(upload_info) + revision = save_revision! + ChatPublisher.publish_edit!(@chat_channel, @chat_message) + Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) + Chat::ChatNotifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) + DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_modify_channel_message?(@chat_channel) + raise StandardError.new( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: @chat_channel.status_name, + ), + ) + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def get_upload_info + return { uploads: [] } if @upload_ids.nil? || !SiteSetting.chat_allow_uploads + + uploads = Upload.where(id: @upload_ids, user_id: @user.id) + if uploads.count != @upload_ids.count + # User is passing upload_ids for uploads that they don't own. Don't change anything. + return { uploads: @chat_message.uploads, changed: false } + end + + new_upload_ids = uploads.map(&:id) + existing_upload_ids = @chat_message.upload_ids + difference = (existing_upload_ids + new_upload_ids) - (existing_upload_ids & new_upload_ids) + { uploads: uploads, changed: difference.any? } + end + + def update_uploads(upload_info) + return unless upload_info[:changed] + + ChatUpload.where(chat_message: @chat_message).destroy_all + @chat_message.attach_uploads(upload_info[:uploads]) + end + + def save_revision! + @chat_message.revisions.create!( + old_message: @old_message_content, + new_message: @chat_message.message, + user_id: @user.id, + ) + end +end diff --git a/plugins/chat/lib/chat_notifier.rb b/plugins/chat/lib/chat_notifier.rb new file mode 100644 index 0000000000..d2fcc4496a --- /dev/null +++ b/plugins/chat/lib/chat_notifier.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +## +# When we are attempting to notify users based on a message we have to take +# into account the following: +# +# * Individual user mentions like @alfred +# * Group mentions that include N users such as @support +# * Global @here and @all mentions +# * Users watching the channel via UserChatChannelMembership +# +# For various reasons a mention may not notify a user: +# +# * The target user of the mention is ignoring or muting the user who created the message +# * The target user either cannot chat or cannot see the chat channel, in which case +# they are defined as `unreachable` +# * The target user is not a member of the channel, in which case they are defined +# as `welcome_to_join` +# * In the case of global @here and @all mentions users with the preference +# `ignore_channel_wide_mention` set to true will not be notified +# +# For any users that fall under the `unreachable` or `welcome_to_join` umbrellas +# we send a MessageBus message to the UI and to inform the creating user. The +# creating user can invite any `welcome_to_join` users to the channel. Target +# users who are ignoring or muting the creating user _do not_ fall into this bucket. +# +# The ignore/mute filtering is also applied via the ChatNotifyWatching job, +# which prevents desktop / push notifications being sent. +class Chat::ChatNotifier + class << self + def user_has_seen_message?(membership, chat_message_id) + (membership.last_read_message_id || 0) >= chat_message_id + end + + def push_notification_tag(type, chat_channel_id) + "#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}" + end + + def notify_edit(chat_message:, timestamp:) + new(chat_message, timestamp).notify_edit + end + + def notify_new(chat_message:, timestamp:) + new(chat_message, timestamp).notify_new + end + end + + def initialize(chat_message, timestamp) + @chat_message = chat_message + @timestamp = timestamp + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + end + + ### Public API + + def notify_new + to_notify = list_users_to_notify + inaccessible = to_notify.extract!(:unreachable, :welcome_to_join) + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + mentioned_user_ids.each do |member_id| + ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) + end + + notify_creator_of_inaccessible_mentions( + inaccessible[:unreachable], + inaccessible[:welcome_to_join], + ) + + notify_mentioned_users(to_notify) + notify_watching_users(except: mentioned_user_ids << @user.id) + + to_notify + end + + def notify_edit + existing_notifications = + ChatMention.includes(:user, :notification).where(chat_message: @chat_message) + already_notified_user_ids = existing_notifications.map(&:user_id) + + to_notify = list_users_to_notify + inaccessible = to_notify.extract!(:unreachable, :welcome_to_join) + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + needs_deletion = already_notified_user_ids - mentioned_user_ids + needs_deletion.each do |user_id| + chat_mention = existing_notifications.detect { |n| n.user_id == user_id } + chat_mention.notification.destroy! + chat_mention.destroy! + end + + needs_notification_ids = mentioned_user_ids - already_notified_user_ids + return if needs_notification_ids.blank? + + notify_creator_of_inaccessible_mentions( + inaccessible[:unreachable], + inaccessible[:welcome_to_join], + ) + + notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) + + to_notify + end + + private + + def list_users_to_notify + {}.tap do |to_notify| + # The order of these methods is the precedence + # between different mention types. + + already_covered_ids = [] + + expand_direct_mentions(to_notify, already_covered_ids) + expand_group_mentions(to_notify, already_covered_ids) + expand_here_mention(to_notify, already_covered_ids) + expand_global_mention(to_notify, already_covered_ids) + + filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + + to_notify[:all_mentioned_user_ids] = already_covered_ids + end + end + + def chat_users + users = + User.includes(:do_not_disturb_timings, :push_subscriptions, :user_chat_channel_memberships) + + users + .distinct + .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins(:user_option) + .real + .not_suspended + .where(user_options: { chat_enabled: true }) + .where.not(username_lower: @user.username.downcase) + end + + def rest_of_the_channel + chat_users.where( + user_chat_channel_memberships: { + following: true, + chat_channel_id: @chat_channel.id, + }, + ) + end + + def members_accepting_channel_wide_notifications + rest_of_the_channel.where(user_options: { ignore_channel_wide_mention: [false, nil] }) + end + + def direct_mentions_from_cooked + @direct_mentions_from_cooked ||= + Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention").map(&:text) + end + + def normalized_mentions(mentions) + mentions.reduce([]) do |memo, mention| + %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) + end + end + + def expand_global_mention(to_notify, already_covered_ids) + typed_global_mention = direct_mentions_from_cooked.include?("@all") + + if typed_global_mention + to_notify[:global_mentions] = members_accepting_channel_wide_notifications + .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:global_mentions]) + else + to_notify[:global_mentions] = [] + end + end + + def expand_here_mention(to_notify, already_covered_ids) + typed_here_mention = direct_mentions_from_cooked.include?("@here") + + if typed_here_mention + to_notify[:here_mentions] = members_accepting_channel_wide_notifications + .where("last_seen_at > ?", 5.minutes.ago) + .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:here_mentions]) + else + to_notify[:here_mentions] = [] + end + end + + def group_users_to_notify(users) + potential_participants, unreachable = + users.partition do |user| + guardian = Guardian.new(user) + guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel) + end + + participants, welcome_to_join = + potential_participants.partition do |participant| + participant.user_chat_channel_memberships.any? do |m| + predicate = m.chat_channel_id == @chat_channel.id + predicate = predicate && m.following == true if @chat_channel.public_channel? + predicate + end + end + + { + already_participating: participants || [], + welcome_to_join: welcome_to_join || [], + unreachable: unreachable || [], + } + end + + def expand_direct_mentions(to_notify, already_covered_ids) + direct_mentions = + chat_users + .where(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + + grouped = group_users_to_notify(direct_mentions) + + to_notify[:direct_mentions] = grouped[:already_participating].map(&:id) + to_notify[:welcome_to_join] = grouped[:welcome_to_join] + to_notify[:unreachable] = grouped[:unreachable] + already_covered_ids.concat(to_notify[:direct_mentions]) + end + + def group_name_mentions + @group_mentions_from_cooked ||= + normalized_mentions( + Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention-group").map(&:text), + ) + end + + def mentionable_groups + @mentionable_groups ||= + Group.mentionable(@user, include_public: false).where( + "LOWER(name) IN (?)", + group_name_mentions, + ) + end + + def expand_group_mentions(to_notify, already_covered_ids) + return [] if mentionable_groups.empty? + + mentionable_groups.each { |g| to_notify[g.name.downcase] = [] } + + reached_by_group = + chat_users.joins(:groups).where(groups: mentionable_groups).where.not(id: already_covered_ids) + + grouped = group_users_to_notify(reached_by_group) + + grouped[:already_participating].each do |user| + # When a user is a member of multiple mentioned groups, + # the most far to the left should take precedence. + ordered_group_names = group_name_mentions & mentionable_groups.map { |mg| mg.name.downcase } + user_group_names = user.groups.map { |ug| ug.name.downcase } + group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) } + + to_notify[group_name] << user.id + end + already_covered_ids.concat(grouped[:already_participating]) + + to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join]) + to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable]) + end + + def notify_creator_of_inaccessible_mentions(unreachable, welcome_to_join) + return if unreachable.empty? && welcome_to_join.empty? + + ChatPublisher.publish_inaccessible_mentions( + @user.id, + @chat_message, + unreachable, + welcome_to_join, + ) + end + + # Filters out users from global, here, group, and direct mentions that are + # ignoring or muting the creator of the message, so they will not receive + # a notification via the ChatNotifyMentioned job and are not prompted for + # invitation by the creator. + # + # already_covered_ids and to_notify sometimes contain IDs and sometimes contain + # Users, hence the gymnastics to resolve the user_id + def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + user_ids_to_screen = + already_covered_ids + .map { |ac| user_id_resolver(ac) } + .concat(to_notify.values.flatten.map { |tn| user_id_resolver(tn) }) + .uniq + screener = UserCommScreener.new(acting_user: @user, target_user_ids: user_ids_to_screen) + to_notify + .except(:unreachable) + .each do |key, users_or_ids| + to_notify[key] = users_or_ids.reject do |user_or_id| + screener.ignoring_or_muting_actor?(user_id_resolver(user_or_id)) + end + end + already_covered_ids.reject! do |already_covered| + screener.ignoring_or_muting_actor?(user_id_resolver(already_covered)) + end + end + + def user_id_resolver(obj) + obj.is_a?(User) ? obj.id : obj + end + + def notify_mentioned_users(to_notify, already_notified_user_ids: []) + Jobs.enqueue( + :chat_notify_mentioned, + { + chat_message_id: @chat_message.id, + to_notify_ids_map: to_notify.as_json, + already_notified_user_ids: already_notified_user_ids, + timestamp: @timestamp.iso8601(6), + }, + ) + end + + def notify_watching_users(except: []) + Jobs.enqueue( + :chat_notify_watching, + { + chat_message_id: @chat_message.id, + except_user_ids: except, + timestamp: @timestamp.iso8601(6), + }, + ) + end +end diff --git a/plugins/chat/lib/chat_review_queue.rb b/plugins/chat/lib/chat_review_queue.rb new file mode 100644 index 0000000000..4b0392e151 --- /dev/null +++ b/plugins/chat/lib/chat_review_queue.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +# Acceptable options: +# - message: Used when the flag type is notify_user or notify_moderators and we have to create +# a separate PM. +# - is_warning: Staff can send warnings when using the notify_user flag. +# - take_action: Automatically approves the created reviewable and deletes the chat message. +# - queue_for_review: Adds a special reason to the reviwable score and creates the reviewable using +# the force_review option. + +class Chat::ChatReviewQueue + def flag_message(chat_message, guardian, flag_type_id, opts = {}) + result = { success: false, errors: [] } + + is_notify_type = + ReviewableScore.types.slice(:notify_user, :notify_moderators).values.include?(flag_type_id) + is_dm = chat_message.chat_channel.direct_message_channel? + + raise Discourse::InvalidParameters.new(:flag_type) if is_dm && is_notify_type + + guardian.ensure_can_flag_chat_message!(chat_message) + guardian.ensure_can_flag_message_as!(chat_message, flag_type_id, opts) + + existing_reviewable = Reviewable.includes(:reviewable_scores).find_by(target: chat_message) + + if !can_flag_again?(existing_reviewable, chat_message, guardian.user, flag_type_id) + result[:errors] << I18n.t("chat.reviewables.message_already_handled") + return result + end + + payload = { message_cooked: chat_message.cooked } + + if opts[:message].present? && !is_dm && is_notify_type + creator = companion_pm_creator(chat_message, guardian.user, flag_type_id, opts) + post = creator.create + + if creator.errors.present? + creator.errors.full_messages.each { |msg| result[:errors] << msg } + return result + end + elsif is_dm + transcript = find_or_create_transcript(chat_message, guardian.user, existing_reviewable) + payload[:transcript_topic_id] = transcript.topic_id if transcript + end + + queued_for_review = !!ActiveRecord::Type::Boolean.new.deserialize(opts[:queue_for_review]) + + reviewable = + ReviewableChatMessage.needs_review!( + created_by: guardian.user, + target: chat_message, + reviewable_by_moderator: true, + potential_spam: flag_type_id == ReviewableScore.types[:spam], + payload: payload, + ) + reviewable.update(target_created_by: chat_message.user) + score = + reviewable.add_score( + guardian.user, + flag_type_id, + meta_topic_id: post&.topic_id, + take_action: opts[:take_action], + reason: queued_for_review ? "chat_message_queued_by_staff" : nil, + force_review: queued_for_review, + ) + + if opts[:take_action] + reviewable.perform(guardian.user, :agree_and_delete) + ChatPublisher.publish_delete!(chat_message.chat_channel, chat_message) + else + enforce_auto_silence_threshold(reviewable) + ChatPublisher.publish_flag!(chat_message, guardian.user, reviewable, score) + end + + result.tap do |r| + r[:success] = true + r[:reviewable] = reviewable + end + end + + private + + def enforce_auto_silence_threshold(reviewable) + auto_silence_duration = SiteSetting.chat_auto_silence_from_flags_duration + return if auto_silence_duration.zero? + return if reviewable.score <= ReviewableChatMessage.score_to_silence_user + + user = reviewable.target_created_by + return unless user + return if user.silenced? + + UserSilencer.silence( + user, + Discourse.system_user, + silenced_till: auto_silence_duration.minutes.from_now, + reason: I18n.t("chat.errors.auto_silence_from_flags"), + ) + end + + def companion_pm_creator(chat_message, flagger, flag_type_id, opts) + notifying_user = flag_type_id == ReviewableScore.types[:notify_user] + + i18n_key = notifying_user ? "notify_user" : "notify_moderators" + + title = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_body", + message: opts[:message], + link: chat_message.full_url, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + } + + if notifying_user + create_args[:subtype] = TopicSubtype.notify_user + create_args[:target_usernames] = chat_message.user.username + + create_args[:is_warning] = opts[:is_warning] if flagger.staff? + else + create_args[:subtype] = TopicSubtype.notify_moderators + create_args[:target_group_names] = [Group[:moderators].name] + end + + PostCreator.new(flagger, create_args) + end + + def find_or_create_transcript(chat_message, flagger, existing_reviewable) + previous_message_ids = + ChatMessage + .where(chat_channel: chat_message.chat_channel) + .where("id < ?", chat_message.id) + .order("created_at DESC") + .limit(10) + .pluck(:id) + .reverse + + return if previous_message_ids.empty? + + service = + ChatTranscriptService.new( + chat_message.chat_channel, + Discourse.system_user, + messages_or_ids: previous_message_ids, + ) + + title = + I18n.t( + "chat.reviewables.direct_messages.transcript_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "chat.reviewables.direct_messages.transcript_body", + transcript: service.generate_markdown, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + subtype: TopicSubtype.notify_moderators, + target_group_names: [Group[:moderators].name], + } + + PostCreator.new(Discourse.system_user, create_args).create + end + + def can_flag_again?(reviewable, message, flagger, flag_type_id) + return true if reviewable.blank? + + flagger_has_pending_flags = + reviewable.reviewable_scores.any? { |rs| rs.user == flagger && rs.pending? } + + if !flagger_has_pending_flags && flag_type_id == ReviewableScore.types[:notify_moderators] + return true + end + + flag_used = + reviewable.reviewable_scores.any? do |rs| + rs.reviewable_score_type == flag_type_id && rs.pending? + end + handled_recently = + !( + reviewable.pending? || + reviewable.updated_at < SiteSetting.cooldown_hours_until_reflag.to_i.hours.ago + ) + + latest_revision = message.revisions.last + edited_since_last_review = latest_revision && latest_revision.updated_at > reviewable.updated_at + + !flag_used && !flagger_has_pending_flags && (!handled_recently || edited_since_last_review) + end +end diff --git a/plugins/chat/lib/chat_seeder.rb b/plugins/chat/lib/chat_seeder.rb new file mode 100644 index 0000000000..79d8dc23bd --- /dev/null +++ b/plugins/chat/lib/chat_seeder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ChatSeeder + def execute(args = {}) + return if !SiteSetting.needs_chat_seeded + + begin + create_category_channel_from(SiteSetting.staff_category_id) + create_category_channel_from(SiteSetting.general_category_id) + rescue => error + Rails.logger.warn("Error seeding chat category - #{error.inspect}") + ensure + SiteSetting.needs_chat_seeded = false + end + end + + def create_category_channel_from(category_id) + category = Category.find_by(id: category_id) + return if category.nil? + + chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name) + category.custom_fields[Chat::HAS_CHAT_ENABLED] = true + category.save! + + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + chat_channel + end +end diff --git a/plugins/chat/lib/chat_statistics.rb b/plugins/chat/lib/chat_statistics.rb new file mode 100644 index 0000000000..ab79fcf111 --- /dev/null +++ b/plugins/chat/lib/chat_statistics.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Chat::Statistics + def self.about_messages + { + :last_day => ChatMessage.where("created_at > ?", 1.days.ago).count, + "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).count, + "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).count, + :previous_30_days => + ChatMessage.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, + :count => ChatMessage.count, + } + end + + def self.about_channels + { + :last_day => ChatChannel.where(status: :open).where("created_at > ?", 1.days.ago).count, + "7_days" => ChatChannel.where(status: :open).where("created_at > ?", 7.days.ago).count, + "30_days" => ChatChannel.where(status: :open).where("created_at > ?", 30.days.ago).count, + :previous_30_days => + ChatChannel + .where(status: :open) + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .count, + :count => ChatChannel.where(status: :open).count, + } + end + + def self.about_users + { + :last_day => ChatMessage.where("created_at > ?", 1.days.ago).distinct.count(:user_id), + "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).distinct.count(:user_id), + "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).distinct.count(:user_id), + :previous_30_days => + ChatMessage + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .distinct + .count(:user_id), + :count => ChatMessage.distinct.count(:user_id), + } + end + + def self.monthly + start_of_month = Time.zone.now.beginning_of_month + { + messages: ChatMessage.where("created_at > ?", start_of_month).count, + channels: ChatChannel.where(status: :open).where("created_at > ?", start_of_month).count, + users: ChatMessage.where("created_at > ?", start_of_month).distinct.count(:user_id), + } + end +end diff --git a/plugins/chat/lib/chat_transcript_service.rb b/plugins/chat/lib/chat_transcript_service.rb new file mode 100644 index 0000000000..6326494cdd --- /dev/null +++ b/plugins/chat/lib/chat_transcript_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +## +# Used to generate BBCode [chat] tags for the message IDs provided. +# +# If there is > 1 message then the channel name will be shown at +# the top of the first message, and subsequent messages will have +# the chained attribute, which will affect how they are displayed +# in the UI. +# +# Subsequent messages from the same user will be put into the same +# tag. Each new user in the chain of messages will have a new [chat] +# tag created. +# +# A single message will have the channel name displayed to the right +# of the username and datetime of the message. +class ChatTranscriptService + CHAINED_ATTR = "chained=\"true\"" + MULTIQUOTE_ATTR = "multiQuote=\"true\"" + NO_LINK_ATTR = "noLink=\"true\"" + + class ChatTranscriptBBCode + attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions + + def initialize( + channel: nil, + acting_user: nil, + multiquote: false, + chained: false, + no_link: false, + include_reactions: false + ) + @channel = channel + @acting_user = acting_user + @multiquote = multiquote + @chained = chained + @no_link = no_link + @include_reactions = include_reactions + @message_data = [] + end + + def add(message:, reactions: nil) + @message_data << { message: message, reactions: reactions } + end + + def render + attrs = [quote_attr(@message_data.first[:message])] + + if channel + attrs << channel_attr + attrs << channel_id_attr + end + + attrs << MULTIQUOTE_ATTR if multiquote + attrs << CHAINED_ATTR if chained + attrs << NO_LINK_ATTR if no_link + attrs << reactions_attr if include_reactions + + <<~MARKDOWN + [chat #{attrs.compact.join(" ")}] + #{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")} + [/chat] + MARKDOWN + end + + private + + def reactions_attr + reaction_data = + @message_data.reduce([]) do |array, msg_data| + if msg_data[:reactions].any? + array << msg_data[:reactions].map { |react| "#{react.emoji}:#{react.usernames}" } + end + array + end + return if reaction_data.empty? + "reactions=\"#{reaction_data.join(";")}\"" + end + + def quote_attr(message) + "quote=\"#{message.user.username};#{message.id};#{message.created_at.iso8601}\"" + end + + def channel_attr + "channel=\"#{channel.title(@acting_user)}\"" + end + + def channel_id_attr + "channelId=\"#{channel.id}\"" + end + end + + def initialize(channel, acting_user, messages_or_ids: [], opts: {}) + @channel = channel + @acting_user = acting_user + + if messages_or_ids.all? { |m| m.is_a?(Numeric) } + @message_ids = messages_or_ids + else + @messages = messages_or_ids + end + @opts = opts + end + + def generate_markdown + previous_message = nil + rendered_markdown = [] + all_messages_same_user = messages.count(:user_id) == 1 + open_bbcode_tag = + ChatTranscriptBBCode.new( + channel: @channel, + acting_user: @acting_user, + multiquote: messages.length > 1, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + + messages.each.with_index do |message, idx| + if previous_message.present? && previous_message.user_id != message.user_id + rendered_markdown << open_bbcode_tag.render + + open_bbcode_tag = + ChatTranscriptBBCode.new( + acting_user: @acting_user, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + end + + if @opts[:include_reactions] + open_bbcode_tag.add(message: message, reactions: reactions_for_message(message)) + else + open_bbcode_tag.add(message: message) + end + previous_message = message + end + + # tie off the last open bbcode + render + rendered_markdown << open_bbcode_tag.render + rendered_markdown.join("\n") + end + + private + + def messages + @messages ||= + ChatMessage + .includes(:user, chat_uploads: :upload) + .where(id: @message_ids, chat_channel_id: @channel.id) + .order(:created_at) + end + + ## + # Queries reactions and returns them in this format + # + # emoji | usernames | chat_message_id + # ---------------------------------------- + # +1 | foo,bar,baz | 102 + # heart | foo | 102 + # sob | bar,baz | 103 + def reactions + @reactions ||= DB.query(<<~SQL, @messages.map(&:id)) + SELECT emoji, STRING_AGG(DISTINCT users.username, ',') AS usernames, chat_message_id + FROM chat_message_reactions + INNER JOIN users on users.id = chat_message_reactions.user_id + WHERE chat_message_id IN (?) + GROUP BY emoji, chat_message_id + ORDER BY chat_message_id, emoji + SQL + end + + def reactions_for_message(message) + reactions.select { |react| react.chat_message_id == message.id } + end +end diff --git a/plugins/chat/lib/direct_message_channel_creator.rb b/plugins/chat/lib/direct_message_channel_creator.rb new file mode 100644 index 0000000000..06315b8a47 --- /dev/null +++ b/plugins/chat/lib/direct_message_channel_creator.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Chat::DirectMessageChannelCreator + class NotAllowed < StandardError + end + + def self.create!(acting_user:, target_users:) + Guardian.new(acting_user).ensure_can_create_direct_message! + target_users.uniq! + direct_message = DirectMessage.for_user_ids(target_users.map(&:id)) + if direct_message + chat_channel = ChatChannel.find_by!(chatable: direct_message) + else + ensure_actor_can_communicate!(acting_user, target_users) + direct_message = DirectMessage.create!(user_ids: target_users.map(&:id)) + chat_channel = direct_message.create_chat_channel! + end + + update_memberships(acting_user, target_users, chat_channel.id) + ChatPublisher.publish_new_channel(chat_channel, target_users) + + chat_channel + end + + private + + def self.update_memberships(acting_user, target_users, chat_channel_id) + sql_params = { + acting_user_id: acting_user.id, + user_ids: target_users.map(&:id), + chat_channel_id: chat_channel_id, + always_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + } + + DB.exec(<<~SQL, sql_params) + INSERT INTO user_chat_channel_memberships( + user_id, + chat_channel_id, + muted, + following, + desktop_notification_level, + mobile_notification_level, + created_at, + updated_at + ) + VALUES( + unnest(array[:user_ids]), + :chat_channel_id, + false, + false, + :always_notification_level, + :always_notification_level, + NOW(), + NOW() + ) + ON CONFLICT (user_id, chat_channel_id) DO NOTHING; + + UPDATE user_chat_channel_memberships + SET following = true + WHERE user_id = :acting_user_id AND chat_channel_id = :chat_channel_id; + SQL + end + + def self.ensure_actor_can_communicate!(acting_user, target_users) + # We never want to prevent the actor from communicating with themself. + target_users = target_users.reject { |user| user.id == acting_user.id } + + screener = + UserCommScreener.new(acting_user: acting_user, target_user_ids: target_users.map(&:id)) + + # People blocking the actor. + screener.preventing_actor_communication.each do |user_id| + raise NotAllowed.new( + I18n.t( + "chat.errors.not_accepting_dms", + username: target_users.find { |user| user.id == user_id }.username, + ), + ) + end + + # The actor cannot start DMs with people if they are not allowing anyone + # to start DMs with them, that's no fair! + if screener.actor_disallowing_all_pms? + raise NotAllowed.new(I18n.t("chat.errors.actor_disallowed_dms")) + end + + # People the actor is blocking. + target_users.each do |target_user| + if screener.actor_disallowing_pms?(target_user.id) + raise NotAllowed.new( + I18n.t( + "chat.errors.actor_preventing_target_user_from_dm", + username: target_user.username, + ), + ) + end + + if screener.actor_ignoring?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_ignoring_target_user", username: target_user.username), + ) + end + + if screener.actor_muting?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_muting_target_user", username: target_user.username), + ) + end + end + end +end diff --git a/plugins/chat/lib/discourse_dev/direct_channel.rb b/plugins/chat/lib/discourse_dev/direct_channel.rb new file mode 100644 index 0000000000..4ee6e835fe --- /dev/null +++ b/plugins/chat/lib/discourse_dev/direct_channel.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class DirectChannel < Record + def initialize + super(::DirectMessage, 5) + end + + def data + if Faker::Boolean.boolean(true_ratio: 0.5) + admin_username = + begin + DiscourseDev::Config.new.config[:admin][:username] + rescue StandardError + nil + end + admin_user = ::User.find_by(username: admin_username) if admin_username + end + + [User.new.create!, admin_user || User.new.create!] + end + + def create! + users = data + Chat::DirectMessageChannelCreator.create!(acting_user: users[0], target_users: users) + end + end +end diff --git a/plugins/chat/lib/discourse_dev/message.rb b/plugins/chat/lib/discourse_dev/message.rb new file mode 100644 index 0000000000..6cd72225a1 --- /dev/null +++ b/plugins/chat/lib/discourse_dev/message.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class Message < Record + def initialize + super(::ChatMessage, 200) + end + + def data + if Faker::Boolean.boolean(true_ratio: 0.5) + channel = ::ChatChannel.where(chatable_type: "DirectMessage").order("RANDOM()").first + channel.user_chat_channel_memberships.update_all(following: true) + user = channel.chatable.users.order("RANDOM()").first + else + membership = ::UserChatChannelMembership.order("RANDOM()").first + channel = membership.chat_channel + user = membership.user + end + + { user: user, content: Faker::Lorem.paragraph, chat_channel: channel } + end + + def create! + Chat::ChatMessageCreator.create(data) + end + end +end diff --git a/plugins/chat/lib/discourse_dev/public_channel.rb b/plugins/chat/lib/discourse_dev/public_channel.rb new file mode 100644 index 0000000000..cb9c672caa --- /dev/null +++ b/plugins/chat/lib/discourse_dev/public_channel.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class PublicChannel < Record + def initialize + super(::CategoryChannel, 5) + end + + def data + chatable = Category.random + + { + chatable: chatable, + description: Faker::Lorem.paragraph, + user_count: 1, + name: Faker::Company.name, + created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), + } + end + + def create! + super do |channel| + Faker::Number + .between(from: 5, to: 10) + .times do + if Faker::Boolean.boolean(true_ratio: 0.5) + admin_username = + begin + DiscourseDev::Config.new.config[:admin][:username] + rescue StandardError + nil + end + admin_user = ::User.find_by(username: admin_username) if admin_username + end + + Chat::ChatChannelMembershipManager.new(channel).follow(admin_user || User.new.create!) + end + end + end + end +end diff --git a/plugins/chat/lib/duplicate_message_validator.rb b/plugins/chat/lib/duplicate_message_validator.rb new file mode 100644 index 0000000000..c66420f9d7 --- /dev/null +++ b/plugins/chat/lib/duplicate_message_validator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Chat::DuplicateMessageValidator + attr_reader :chat_message + + def initialize(chat_message) + @chat_message = chat_message + end + + def validate + return if SiteSetting.chat_duplicate_message_sensitivity.zero? + matrix = + Chat::DuplicateMessageValidator.sensitivity_matrix( + SiteSetting.chat_duplicate_message_sensitivity, + ) + + # Check if the length of the message is too short to check for a duplicate message + return if chat_message.message.length < matrix[:min_message_length] + + # Check if there are enough users in the channel to check for a duplicate message + return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count] + + # Check if the same duplicate message has been posted in the last N seconds by any user + if !chat_message + .chat_channel + .chat_messages + .where("created_at > ?", matrix[:min_past_seconds].seconds.ago) + .where(message: chat_message.message) + .exists? + return + end + + chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message")) + end + + def self.sensitivity_matrix(sensitivity) + { + # 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users. + min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i, + # 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars. + min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i, + # 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds. + min_past_seconds: (55.55 * sensitivity + 4.5).to_i, + } + end +end diff --git a/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb b/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb new file mode 100644 index 0000000000..ab4b06a757 --- /dev/null +++ b/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EmailControllerHelper + class ChatSummaryUnsubscriber < BaseEmailUnsubscriber + def prepare_unsubscribe_options(controller) + super(controller) + + chat_email_frequencies = + UserOption.chat_email_frequencies.map do |(freq, _)| + [I18n.t("unsubscribe.chat_summary.#{freq}"), freq] + end + + controller.instance_variable_set(:@chat_email_frequencies, chat_email_frequencies) + controller.instance_variable_set( + :@current_chat_email_frequency, + key_owner.user_option.chat_email_frequency, + ) + end + + def unsubscribe(params) + updated = super(params) + + if params[:chat_email_frequency] + key_owner.user_option.update!(chat_email_frequency: params[:chat_email_frequency]) + updated = true + end + + updated + end + end +end diff --git a/plugins/chat/lib/extensions/category_extension.rb b/plugins/chat/lib/extensions/category_extension.rb new file mode 100644 index 0000000000..a424ac3810 --- /dev/null +++ b/plugins/chat/lib/extensions/category_extension.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat::CategoryExtension + extend ActiveSupport::Concern + + include Chatable + + prepended { has_one :category_channel, as: :chatable } + + def cannot_delete_reason + return I18n.t("category.cannot_delete.has_chat_channels") if category_channel + super + end + + def deletable_for_chat? + return true if !category_channel + category_channel.chat_messages_empty? + end +end diff --git a/plugins/chat/lib/extensions/user_email_extension.rb b/plugins/chat/lib/extensions/user_email_extension.rb new file mode 100644 index 0000000000..6742dccbe3 --- /dev/null +++ b/plugins/chat/lib/extensions/user_email_extension.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat::UserEmailExtension + def execute(args) + super(args) + + if args[:type] == "chat_summary" && args[:memberships_to_update_data].present? + args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id| + UserChatChannelMembership.find_by(user: args[:user_id], id: membership_id.to_i)&.update( + last_unread_mention_when_emailed_id: max_unread_mention_id.to_i, + ) + end + end + end +end diff --git a/plugins/chat/lib/extensions/user_extension.rb b/plugins/chat/lib/extensions/user_extension.rb new file mode 100644 index 0000000000..b4c041d4d8 --- /dev/null +++ b/plugins/chat/lib/extensions/user_extension.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat::UserExtension + extend ActiveSupport::Concern + + prepended do + has_many :user_chat_channel_memberships, dependent: :destroy + has_many :chat_message_reactions, dependent: :destroy + has_many :chat_mentions + end +end diff --git a/plugins/chat/lib/extensions/user_notifications_extension.rb b/plugins/chat/lib/extensions/user_notifications_extension.rb new file mode 100644 index 0000000000..6dd71b609c --- /dev/null +++ b/plugins/chat/lib/extensions/user_notifications_extension.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Chat::UserNotificationsExtension + def chat_summary(user, opts) + guardian = Guardian.new(user) + return unless guardian.can_chat?(user) + + @messages = + ChatMessage + .joins(:user, :chat_channel) + .where.not(user: user) + .where("chat_messages.created_at > ?", 1.week.ago) + .joins("LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id") + .joins( + "INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id", + ) + .where(<<~SQL, user_id: user.id) + uccm.user_id = :user_id AND + (uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (cm.user_id = :user_id AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR + (chat_channels.chatable_type = 'DirectMessage') + ) + SQL + .to_a + + return if @messages.empty? + @grouped_messages = @messages.group_by { |message| message.chat_channel } + @grouped_messages = + @grouped_messages.select { |channel, _| guardian.can_see_chat_channel?(channel) } + return if @grouped_messages.empty? + + @grouped_messages.each do |chat_channel, messages| + @grouped_messages[chat_channel] = messages.sort_by(&:created_at) + end + @user = user + @user_tz = UserOption.user_tzinfo(user.id) + @display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + + build_summary_for(user) + @preferences_path = "#{Discourse.base_url}/my/preferences/chat" + + # TODO(roman): Remove after the 2.9 release + add_unsubscribe_link = UnsubscribeKey.respond_to?(:get_unsubscribe_strategy_for) + + if add_unsubscribe_link + unsubscribe_key = UnsubscribeKey.create_key_for(@user, "chat_summary") + @unsubscribe_link = "#{Discourse.base_url}/email/unsubscribe/#{unsubscribe_key}" + opts[:unsubscribe_url] = @unsubscribe_link + end + + opts = { + from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title), + subject: summary_subject(user, @grouped_messages), + add_unsubscribe_link: add_unsubscribe_link, + } + + build_email(user.email, opts) + end + + def summary_subject(user, grouped_messages) + channels = grouped_messages.keys + grouped_channels = channels.partition { |c| !c.direct_message_channel? } + non_dm_channels = grouped_channels.first + dm_users = grouped_channels.last.flat_map { |c| grouped_messages[c].map(&:user) }.uniq + + total_count_for_subject = non_dm_channels.size + dm_users.size + first_message_from = non_dm_channels.pop + if first_message_from + first_message_title = first_message_from.title(user) + subject_key = "chat_channel" + else + subject_key = "direct_message" + first_message_from = dm_users.pop + first_message_title = first_message_from.username + end + + subject_opts = { + email_prefix: @email_prefix, + count: total_count_for_subject, + message_title: first_message_title, + others: + other_channels_text( + user, + total_count_for_subject, + first_message_from, + non_dm_channels, + dm_users, + ), + } + + I18n.t(with_subject_prefix(subject_key), **subject_opts) + end + + def with_subject_prefix(key) + "user_notifications.chat_summary.subject.#{key}" + end + + def other_channels_text( + user, + total_count, + first_message_from, + other_non_dm_channels, + other_dm_users + ) + return if total_count <= 1 + return I18n.t(with_subject_prefix("others"), count: total_count - 1) if total_count > 2 + + if other_non_dm_channels.empty? + second_message_from = other_dm_users.first + second_message_title = second_message_from.username + else + second_message_from = other_non_dm_channels.first + second_message_title = second_message_from.title(user) + end + + return second_message_title if first_message_from.class == second_message_from.class + + I18n.t(with_subject_prefix("other_direct_message"), message_title: second_message_title) + end +end diff --git a/plugins/chat/lib/extensions/user_option_extension.rb b/plugins/chat/lib/extensions/user_option_extension.rb new file mode 100644 index 0000000000..ae2993a216 --- /dev/null +++ b/plugins/chat/lib/extensions/user_option_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat::UserOptionExtension + # TODO: remove last_emailed_for_chat and chat_isolated in 2023 + def self.prepended(base) + if base.ignored_columns + base.ignored_columns = base.ignored_columns + %i[last_emailed_for_chat chat_isolated] + else + base.ignored_columns = %i[last_emailed_for_chat chat_isolated] + end + + def base.chat_email_frequencies + @chat_email_frequencies ||= { never: 0, when_away: 1 } + end + + base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" + end +end diff --git a/plugins/chat/lib/guardian_extensions.rb b/plugins/chat/lib/guardian_extensions.rb new file mode 100644 index 0000000000..cc04ac8629 --- /dev/null +++ b/plugins/chat/lib/guardian_extensions.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Chat::GuardianExtensions + def can_moderate_chat?(chatable) + case chatable.class.name + when "Category" + is_staff? || is_category_group_moderator?(chatable) + else + is_staff? + end + end + + def can_chat?(user) + return false unless user + + user.staff? || user.in_any_groups?(Chat.allowed_group_ids) + end + + def can_create_chat_message? + !SpamRule::AutoSilence.prevent_posting?(@user) + end + + def can_create_direct_message? + is_staff? || @user.in_any_groups?(SiteSetting.direct_message_enabled_groups_map) + end + + def hidden_tag_names + @hidden_tag_names ||= DiscourseTagging.hidden_tag_names(self) + end + + def can_create_chat_channel? + is_staff? + end + + def can_delete_chat_channel? + is_staff? + end + + # Channel status intentionally has no bearing on whether the channel + # name and description can be edited. + def can_edit_chat_channel? + is_staff? + end + + def can_move_chat_messages?(channel) + can_moderate_chat?(channel.chatable) + end + + def can_create_channel_message?(chat_channel) + valid_statuses = is_staff? ? %w[open closed] : ["open"] + valid_statuses.include?(chat_channel.status) + end + + # This is intentionally identical to can_create_channel_message, we + # may want to have different conditions here in future. + def can_modify_channel_message?(chat_channel) + return chat_channel.open? || chat_channel.closed? if is_staff? + chat_channel.open? + end + + def can_change_channel_status?(chat_channel, target_status) + return false if chat_channel.status.to_sym == target_status.to_sym + return false if !is_staff? + + case target_status + when :closed + chat_channel.open? + when :open + chat_channel.closed? + when :archived + chat_channel.read_only? + when :read_only + chat_channel.closed? || chat_channel.open? + else + false + end + end + + def can_rebake_chat_message?(message) + return false if !can_modify_channel_message?(message.chat_channel) + is_staff? || @user.has_trust_level?(TrustLevel[4]) + end + + def can_see_chat_channel?(chat_channel) + return false unless chat_channel.chatable + + if chat_channel.direct_message_channel? + chat_channel.chatable.user_can_access?(@user) + elsif chat_channel.category_channel? + can_see_category?(chat_channel.chatable) + else + true + end + end + + def can_flag_chat_messages? + return false if @user.silenced? + + @user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map) + end + + def can_flag_in_chat_channel?(chat_channel) + return false if !can_modify_channel_message?(chat_channel) + + can_see_chat_channel?(chat_channel) + end + + def can_flag_chat_message?(chat_message) + return false if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user + return false if chat_message.user.staff? && !SiteSetting.allow_flagging_staff + return false if chat_message.user_id == @user.id + + can_flag_chat_messages? && can_flag_in_chat_channel?(chat_message.chat_channel) + end + + def can_flag_message_as?(chat_message, flag_type_id, opts) + return false if !is_staff? && (opts[:take_action] || opts[:queue_for_review]) + + if flag_type_id == ReviewableScore.types[:notify_user] + is_warning = ActiveRecord::Type::Boolean.new.deserialize(opts[:is_warning]) + + return false if is_warning && !is_staff? + end + + true + end + + def can_delete_chat?(message, chatable) + return false if @user.silenced? + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + can_delete_own_chats?(chatable) + else + can_delete_other_chats?(chatable) + end + end + + def can_delete_own_chats?(chatable) + return false if (SiteSetting.max_post_deletions_per_day < 1) + return true if can_moderate_chat?(chatable) + + true + end + + def can_delete_other_chats?(chatable) + return true if can_moderate_chat?(chatable) + + false + end + + def can_restore_chat?(message, chatable) + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + case chatable + when Category + return can_see_category?(chatable) + when DirectMessage + return true + end + end + + can_delete_other_chats?(chatable) + end + + def can_restore_other_chats?(chatable) + can_moderate_chat?(chatable) + end + + def can_edit_chat?(message) + message.user_id == @user.id && !@user.silenced? + end + + def can_react? + can_create_chat_message? + end + + def can_delete_category?(category) + super && category.deletable_for_chat? + end +end diff --git a/plugins/chat/lib/message_mover.rb b/plugins/chat/lib/message_mover.rb new file mode 100644 index 0000000000..fc290f757c --- /dev/null +++ b/plugins/chat/lib/message_mover.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +## +# Used to move chat messages from a chat channel to some other +# location. +# +# Channel -> Channel: +# ------------------- +# +# Messages are sometimes misplaced and must be moved to another channel. For +# now we only support moving messages between public channels, handling the +# permissions and membership around moving things in and out of DMs is a little +# much for V1. +# +# The original messages will be deleted, and then similar to PostMover in core, +# all of the references associated to a chat message (e.g. reactions, bookmarks, +# notifications, revisions, mentions, uploads) will be updated to the new +# message IDs via a moved_chat_messages temporary table. +class Chat::MessageMover + class NoMessagesFound < StandardError + end + class InvalidChannel < StandardError + end + + def initialize(acting_user:, source_channel:, message_ids:) + @source_channel = source_channel + @acting_user = acting_user + @source_message_ids = message_ids + @source_messages = find_messages(@source_message_ids, source_channel) + @ordered_source_message_ids = @source_messages.map(&:id) + end + + def move_to_channel(destination_channel) + if !@source_channel.public_channel? || !destination_channel.public_channel? + raise InvalidChannel.new(I18n.t("chat.errors.message_move_invalid_channel")) + end + + if @ordered_source_message_ids.empty? + raise NoMessagesFound.new(I18n.t("chat.errors.message_move_no_messages_found")) + end + + moved_messages = nil + + ChatMessage.transaction do + create_temp_table + moved_messages = + find_messages( + create_destination_messages_in_channel(destination_channel), + destination_channel, + ) + bulk_insert_movement_metadata + update_references + delete_source_messages + end + + add_moved_placeholder(destination_channel, moved_messages.first) + moved_messages + end + + private + + def find_messages(message_ids, channel) + ChatMessage.where(id: message_ids, chat_channel_id: channel.id).order("created_at ASC, id ASC") + end + + def create_temp_table + DB.exec("DROP TABLE IF EXISTS moved_chat_messages") if Rails.env.test? + + DB.exec <<~SQL + CREATE TEMPORARY TABLE moved_chat_messages ( + old_chat_message_id INTEGER, + new_chat_message_id INTEGER + ) ON COMMIT DROP; + + CREATE INDEX moved_chat_messages_old_chat_message_id ON moved_chat_messages(old_chat_message_id); + SQL + end + + def bulk_insert_movement_metadata + values_sql = @movement_metadata.map { |mm| "(#{mm[:old_id]}, #{mm[:new_id]})" }.join(",\n") + DB.exec( + "INSERT INTO moved_chat_messages(old_chat_message_id, new_chat_message_id) VALUES #{values_sql}", + ) + end + + ## + # We purposefully omit in_reply_to_id when creating the messages in the + # new channel, because it could be pointing to a message that has not + # been moved. + def create_destination_messages_in_channel(destination_channel) + query_args = { + message_ids: @ordered_source_message_ids, + destination_channel_id: destination_channel.id, + } + moved_message_ids = DB.query_single(<<~SQL, query_args) + INSERT INTO chat_messages( + chat_channel_id, user_id, last_editor_id, message, cooked, cooked_version, created_at, updated_at + ) + SELECT :destination_channel_id, + user_id, + last_editor_id, + message, + cooked, + cooked_version, + CLOCK_TIMESTAMP(), + CLOCK_TIMESTAMP() + FROM chat_messages + WHERE id IN (:message_ids) + RETURNING id + SQL + + @movement_metadata = + moved_message_ids.map.with_index do |chat_message_id, idx| + { old_id: @ordered_source_message_ids[idx], new_id: chat_message_id } + end + moved_message_ids + end + + def update_references + DB.exec(<<~SQL) + UPDATE chat_message_reactions cmr + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cmr.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_uploads cu + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cu.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_mentions cment + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cment.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_message_revisions crev + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE crev.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_webhook_events cweb + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cweb.chat_message_id = mm.old_chat_message_id + SQL + end + + def delete_source_messages + @source_messages.update_all(deleted_at: Time.zone.now, deleted_by_id: @acting_user.id) + ChatPublisher.publish_bulk_delete!(@source_channel, @source_message_ids) + end + + def add_moved_placeholder(destination_channel, first_moved_message) + Chat::ChatMessageCreator.create( + chat_channel: @source_channel, + user: Discourse.system_user, + content: + I18n.t( + "chat.channel.messages_moved", + count: @source_message_ids.length, + acting_username: @acting_user.username, + channel_name: destination_channel.title(@acting_user), + first_moved_message_url: first_moved_message.url, + ), + ) + end +end diff --git a/plugins/chat/lib/onebox/templates/discourse_chat.mustache b/plugins/chat/lib/onebox/templates/discourse_chat.mustache new file mode 100644 index 0000000000..b0bcc8ef5e --- /dev/null +++ b/plugins/chat/lib/onebox/templates/discourse_chat.mustache @@ -0,0 +1,58 @@ +{{^cooked}} + +{{/cooked}} + +{{#cooked}} + +{{/cooked}} diff --git a/plugins/chat/lib/post_notification_handler.rb b/plugins/chat/lib/post_notification_handler.rb new file mode 100644 index 0000000000..beefe24ab7 --- /dev/null +++ b/plugins/chat/lib/post_notification_handler.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +## +# Handles :post_alerter_after_save_post events from +# core. Used for notifying users that their chat message +# has been quoted in a post. +class Chat::PostNotificationHandler + attr_reader :post + + def initialize(post, notified_users) + @post = post + @notified_users = notified_users + end + + def handle + return false if post.post_type == Post.types[:whisper] + return false if post.topic.blank? + return false if post.topic.private_message? + + quoted_users = extract_quoted_users(post) + if @notified_users.present? + quoted_users = quoted_users.where("users.id NOT IN (?)", @notified_users) + end + + opts = { user_id: post.user.id, display_username: post.user.username } + quoted_users.each do |user| + # PostAlerter.create_notification handles many edge cases, such as + # muting, ignoring, double notifications etc. + PostAlerter.new.create_notification(user, Notification.types[:chat_quoted], post, opts) + end + end + + private + + def extract_quoted_users(post) + usernames = + post.raw.scan(/\[chat quote=\"([^;]+);.+\"\]/).uniq.map { |q| q.first.strip.downcase } + User.where.not(id: post.user_id).where(username_lower: usernames) + end +end diff --git a/plugins/chat/lib/secure_uploads_compatibility.rb b/plugins/chat/lib/secure_uploads_compatibility.rb new file mode 100644 index 0000000000..6fd898f10b --- /dev/null +++ b/plugins/chat/lib/secure_uploads_compatibility.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Chat::SecureUploadsCompatibility + ## + # At this point in time, secure uploads is not compatible with chat, + # so if it is enabled then chat uploads must be disabled to avoid undesirable + # behaviour. + # + # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep + # it enabled, but this is strongly advised against. + def self.update_settings + if SiteSetting.secure_uploads && SiteSetting.chat_allow_uploads && + !GlobalSetting.allow_unsecure_chat_uploads + SiteSetting.chat_allow_uploads = false + StaffActionLogger.new(Discourse.system_user).log_site_setting_change( + "chat_allow_uploads", + true, + false, + context: "Disabled because secure_uploads is enabled", + ) + end + end +end diff --git a/plugins/chat/lib/slack_compatibility.rb b/plugins/chat/lib/slack_compatibility.rb new file mode 100644 index 0000000000..106af32caf --- /dev/null +++ b/plugins/chat/lib/slack_compatibility.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +## +# Processes slack-formatted text messages, as Mattermost does with +# Slack incoming webhook interoperability, for example links in the +# format and , and mentions. +# +# See https://api.slack.com/reference/surfaces/formatting for all of +# the different formatting slack supports with mrkdwn which is mostly +# identical to Markdown. +# +# Mattermost docs for translating the slack format: +# +# https://docs.mattermost.com/developer/webhooks-incoming.html?highlight=translate%20slack%20data%20format%20mattermost#translate-slack-s-data-format-to-mattermost +# +# We may want to process attachments and blocks from slack in future, and +# convert user IDs into user mentions. +class Chat::SlackCompatibility + MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze + + class << self + def process_text(text) + text = text.gsub("", "@here") + text = text.gsub("", "@all") + + text.scan(MRKDWN_LINK_REGEX) do |match| + match = match.first + + if match.include?("|") + link, title = match.split("|")[0..1] + else + link = match + end + + title = title&.gsub(/<|>/, "") + link = link&.gsub(/<|>/, "") + + if title + text = text.gsub(match, "[#{title}](#{link})") + else + text = text.gsub(match, "#{link}") + end + end + + text + end + + # TODO: This is quite hacky and is only here to support a single + # attachment for our OpsGenie integration. In future we would + # want to iterate through this attachments array and extract + # things properly. + # + # See https://api.slack.com/reference/messaging/attachments for + # more details on what fields are here. + def process_legacy_attachments(attachments) + text = CGI.unescape(attachments[0][:fallback]) + process_text(text) + end + end +end diff --git a/plugins/chat/lib/tasks/chat.rake b/plugins/chat/lib/tasks/chat.rake new file mode 100644 index 0000000000..a53e1b319c --- /dev/null +++ b/plugins/chat/lib/tasks/chat.rake @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +if Discourse.allow_dev_populate? + chat_task = Rake::Task["dev:populate"] + chat_task.enhance do + SiteSetting.chat_enabled = true + DiscourseDev::PublicChannel.populate! + DiscourseDev::DirectChannel.populate! + DiscourseDev::Message.populate! + end + + desc "Generates sample content for chat" + task "chat:populate" => ["db:load_config"] do |_, args| + DiscourseDev::PublicChannel.new.populate!(ignore_current_count: true) + DiscourseDev::DirectChannel.new.populate!(ignore_current_count: true) + DiscourseDev::Message.new.populate!(ignore_current_count: true) + end + + desc "Generates sample messages in channels" + task "chat:message:populate" => ["db:load_config"] do |_, args| + DiscourseDev::Message.new.populate!(ignore_current_count: true) + end +end diff --git a/plugins/chat/lib/tasks/chat_message.rake b/plugins/chat/lib/tasks/chat_message.rake new file mode 100644 index 0000000000..603722e4ba --- /dev/null +++ b/plugins/chat/lib/tasks/chat_message.rake @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +task "chat_messages:rebake_uncooked_chat_messages" => :environment do + # rebaking uncooked chat_messages can very quickly saturate sidekiq + # this provides an insurance policy so you can safely run and stop + # this rake task without worrying about your sidekiq imploding + Jobs.run_immediately! + + ENV["RAILS_DB"] ? rebake_uncooked_chat_messages : rebake_uncooked_chat_messages_all_sites +end + +def rebake_uncooked_chat_messages_all_sites + RailsMultisite::ConnectionManagement.each_connection { |db| rebake_uncooked_chat_messages } +end + +def rebake_uncooked_chat_messages + puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}" + uncooked = ChatMessage.uncooked + + rebaked = 0 + total = uncooked.count + + ids = uncooked.pluck(:id) + # work randomly so you can run this job from lots of consoles if needed + ids.shuffle! + + ids.each do |id| + # may have been cooked in interim + chat_message = uncooked.where(id: id).first + + rebake_chat_message(chat_message) if chat_message + + print_status(rebaked += 1, total) + end + + puts "", "#{rebaked} chat messages done!", "" +end + +def rebake_chat_message(chat_message, opts = {}) + opts[:priority] = :ultra_low if !opts[:priority] + chat_message.rebake!(**opts) +rescue => e + puts "", + "Failed to rebake chat message (chat_message_id: #{chat_message.id})", + e, + e.backtrace.join("\n") +end + +task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environment do |t, args| + user_for_membership = args[:user_for_membership] + + # do not want this running in production! + return if !Rails.env.development? + + require "fabrication" + Dir[Rails.root.join("spec/fabricators/*.rb")].each { |f| require f } + + messages = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Cras sit **amet** metus eget nisl accumsan ullamcorper.", + "Vestibulum commodo justo _quis_ fringilla fringilla.", + "Etiam malesuada erat eget aliquam interdum.", + "Praesent mattis lacus nec ~~orci~~ [spoiler]semper[/spoiler], et fermentum augue tincidunt.", + "Duis vel tortor suscipit justo fringilla faucibus id tempus purus.", + "Phasellus *tempus erat* sit amet pharetra facilisis.", + "Fusce egestas urna ut nisi ornare, ut malesuada est fermentum.", + "Aenean ornare arcu vitae pulvinar dictum.", + "Nam at turpis eu magna sollicitudin fringilla sed sed diam.", + "Proin non [enim](https://discourse.org/team) nec mauris efficitur convallis.", + "Nullam cursus lacus non libero vulputate ornare.", + "In eleifend ante ut ullamcorper ultrices.", + "In placerat diam sit amet nibh feugiat, in posuere metus feugiat.", + "Nullam porttitor leo a leo `cursus`, id hendrerit dui ultrices.", + "Pellentesque ut @#{user_for_membership} ut ex pulvinar pharetra sit amet ac leo.", + "Vestibulum sit amet enim et lectus tincidunt rhoncus hendrerit in enim.", + <<~MSG, + some bigger message + + ```ruby + beep = \"wow\" + puts beep + ``` + MSG + ] + + topic = nil + chat_channel = nil + + Topic.transaction do + topic = + Fabricate( + :topic, + user: make_test_user, + title: "Testing topic for chat archiving #{SecureRandom.hex(4)}", + ) + Fabricate( + :post, + topic: topic, + user: topic.user, + raw: "This is some cool first post for archive stuff", + ) + chat_channel = + ChatChannel.create( + chatable: topic, + chatable_type: "Topic", + name: "testing channel for archiving #{SecureRandom.hex(4)}", + ) + end + + puts "topic: #{topic.id}, #{topic.title}" + puts "channel: #{chat_channel.id}, #{chat_channel.name}" + + users = [make_test_user, make_test_user, make_test_user] + + ChatChannel.transaction do + start_time = Time.now + + puts "creating 1039 messages for the channel" + 1039.times do + cm = ChatMessage.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) + cm.cook + cm.save! + end + + puts "message creation done" + puts "took #{Time.now - start_time} seconds" + + UserChatChannelMembership.create( + chat_channel: chat_channel, + last_read_message_id: 0, + user: User.find_by(username: user_for_membership), + following: true, + ) + end + + puts "channel is located at #{chat_channel.url}" +end + +def make_test_user + return if !Rails.env.development? + unique_prefix = "archiveuser#{SecureRandom.hex(4)}" + Fabricate(:user, username: unique_prefix, email: "#{unique_prefix}@testemail.com") +end diff --git a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb new file mode 100644 index 0000000000..bd7bbd4b02 --- /dev/null +++ b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ChatAllowUploadsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + return false if value == "t" && prevent_enabling_chat_uploads? + true + end + + def error_message + if prevent_enabling_chat_uploads? + I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads") + end + end + + def prevent_enabling_chat_uploads? + SiteSetting.secure_uploads && !GlobalSetting.allow_unsecure_chat_uploads + end +end diff --git a/plugins/chat/lib/validators/chat_default_channel_validator.rb b/plugins/chat/lib/validators/chat_default_channel_validator.rb new file mode 100644 index 0000000000..917663fcfe --- /dev/null +++ b/plugins/chat/lib/validators/chat_default_channel_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ChatDefaultChannelValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + !!(value == "" || ChatChannel.find_by(id: value.to_i)&.public_channel?) + end + + def error_message + I18n.t("site_settings.errors.chat_default_channel") + end +end diff --git a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb new file mode 100644 index 0000000000..bcd5490512 --- /dev/null +++ b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DirectMessageEnabledGroupsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + val.present? && val != "" + end + + def error_message + I18n.t("site_settings.errors.direct_message_enabled_groups_invalid") + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb new file mode 100644 index 0000000000..933266ad5e --- /dev/null +++ b/plugins/chat/plugin.rb @@ -0,0 +1,726 @@ +# frozen_string_literal: true + +# name: chat +# about: Chat inside Discourse +# version: 0.4 +# authors: Kane York, Mark VanLandingham, Martin Brennan, Joffrey Jaffeux +# url: https://github.com/discourse/discourse/tree/main/plugins/chat +# transpile_js: true + +enabled_site_setting :chat_enabled + +register_asset "stylesheets/mixins/chat-scrollbar.scss" +register_asset "stylesheets/common/core-extensions.scss" +register_asset "stylesheets/common/chat-emoji-picker.scss" +register_asset "stylesheets/common/chat-channel-card.scss" +register_asset "stylesheets/common/dc-filter-input.scss" +register_asset "stylesheets/common/common.scss" +register_asset "stylesheets/common/chat-browse.scss" +register_asset "stylesheets/common/chat-drawer.scss" +register_asset "stylesheets/mobile/chat-index.scss", :mobile +register_asset "stylesheets/common/chat-channel-preview-card.scss" +register_asset "stylesheets/common/chat-channel-info.scss" +register_asset "stylesheets/common/chat-draft-channel.scss" +register_asset "stylesheets/common/chat-tabs.scss" +register_asset "stylesheets/common/chat-form.scss" +register_asset "stylesheets/common/d-progress-bar.scss" +register_asset "stylesheets/common/incoming-chat-webhooks.scss" +register_asset "stylesheets/mobile/chat-message.scss", :mobile +register_asset "stylesheets/desktop/chat-message.scss", :desktop +register_asset "stylesheets/common/chat-channel-title.scss" +register_asset "stylesheets/desktop/chat-channel-title.scss", :desktop +register_asset "stylesheets/common/full-page-chat-header.scss" +register_asset "stylesheets/common/chat-reply.scss" +register_asset "stylesheets/common/chat-message.scss" +register_asset "stylesheets/common/chat-message-left-gutter.scss" +register_asset "stylesheets/common/chat-message-info.scss" +register_asset "stylesheets/common/chat-composer-inline-button.scss" +register_asset "stylesheets/common/chat-replying-indicator.scss" +register_asset "stylesheets/common/chat-composer.scss" +register_asset "stylesheets/desktop/chat-composer.scss", :desktop +register_asset "stylesheets/mobile/chat-composer.scss", :mobile +register_asset "stylesheets/common/direct-message-creator.scss" +register_asset "stylesheets/common/chat-message-collapser.scss" +register_asset "stylesheets/common/chat-message-images.scss" +register_asset "stylesheets/common/chat-transcript.scss" +register_asset "stylesheets/common/chat-composer-dropdown.scss" +register_asset "stylesheets/common/chat-retention-reminder.scss" +register_asset "stylesheets/common/chat-composer-uploads.scss" +register_asset "stylesheets/desktop/chat-composer-uploads.scss", :desktop +register_asset "stylesheets/common/chat-composer-upload.scss" +register_asset "stylesheets/common/chat-selection-manager.scss" +register_asset "stylesheets/mobile/chat-selection-manager.scss", :mobile +register_asset "stylesheets/common/chat-channel-selector-modal.scss" +register_asset "stylesheets/mobile/mobile.scss", :mobile +register_asset "stylesheets/desktop/desktop.scss", :desktop +register_asset "stylesheets/sidebar-extensions.scss" +register_asset "stylesheets/desktop/sidebar-extensions.scss", :desktop +register_asset "stylesheets/common/chat-message-actions.scss" +register_asset "stylesheets/desktop/chat-message-actions.scss", :desktop +register_asset "stylesheets/mobile/chat-message-actions.scss", :mobile +register_asset "stylesheets/common/chat-message-separator.scss" +register_asset "stylesheets/common/chat-onebox.scss" +register_asset "stylesheets/common/chat-skeleton.scss" +register_asset "stylesheets/colors.scss", :color_definitions +register_asset "stylesheets/common/reviewable-chat-message.scss" + +register_svg_icon "comments" +register_svg_icon "comment-slash" +register_svg_icon "hashtag" +register_svg_icon "lock" + +register_svg_icon "file-audio" +register_svg_icon "file-video" +register_svg_icon "file-image" + +# route: /admin/plugins/chat +add_admin_route "chat.admin.title", "chat" + +# Site setting validators must be loaded before initialize +require_relative "lib/validators/chat_default_channel_validator.rb" +require_relative "lib/validators/chat_allow_uploads_validator.rb" +require_relative "lib/validators/direct_message_enabled_groups_validator.rb" +require_relative "app/core_ext/plugin_instance.rb" + +GlobalSetting.add_default(:allow_unsecure_chat_uploads, false) + +after_initialize do + module ::Chat + PLUGIN_NAME = "chat" + HAS_CHAT_ENABLED = "has_chat_enabled" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Chat + end + + def self.allowed_group_ids + SiteSetting.chat_allowed_groups_map + end + + def self.onebox_template + @onebox_template ||= + begin + path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" + File.read(path) + end + end + end + + register_seedfu_fixtures(Rails.root.join("plugins", "chat", "db", "fixtures")) + + load File.expand_path( + "../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb", + __FILE__, + ) + load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__) + load File.expand_path("../app/controllers/chat_controller.rb", __FILE__) + load File.expand_path("../app/controllers/chat_channels_controller.rb", __FILE__) + load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__) + load File.expand_path("../app/controllers/direct_messages_controller.rb", __FILE__) + load File.expand_path("../app/controllers/incoming_chat_webhooks_controller.rb", __FILE__) + load File.expand_path("../app/models/concerns/chatable.rb", __FILE__) + load File.expand_path("../app/models/deleted_chat_user.rb", __FILE__) + load File.expand_path("../app/models/user_chat_channel_membership.rb", __FILE__) + load File.expand_path("../app/models/chat_channel.rb", __FILE__) + load File.expand_path("../app/models/chat_channel_archive.rb", __FILE__) + load File.expand_path("../app/models/chat_draft.rb", __FILE__) + load File.expand_path("../app/models/chat_message.rb", __FILE__) + load File.expand_path("../app/models/chat_message_reaction.rb", __FILE__) + load File.expand_path("../app/models/chat_message_revision.rb", __FILE__) + load File.expand_path("../app/models/chat_mention.rb", __FILE__) + load File.expand_path("../app/models/chat_upload.rb", __FILE__) + load File.expand_path("../app/models/chat_webhook_event.rb", __FILE__) + load File.expand_path("../app/models/direct_message_channel.rb", __FILE__) + load File.expand_path("../app/models/direct_message.rb", __FILE__) + load File.expand_path("../app/models/direct_message_user.rb", __FILE__) + load File.expand_path("../app/models/incoming_chat_webhook.rb", __FILE__) + load File.expand_path("../app/models/reviewable_chat_message.rb", __FILE__) + load File.expand_path("../app/models/chat_view.rb", __FILE__) + load File.expand_path("../app/models/category_channel.rb", __FILE__) + load File.expand_path("../app/serializers/structured_channel_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_webhook_event_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_in_reply_to_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/user_chat_channel_membership_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_message_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__) + load File.expand_path( + "../app/serializers/user_with_custom_fields_and_status_serializer.rb", + __FILE__, + ) + load File.expand_path("../app/serializers/direct_message_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/incoming_chat_webhook_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__) + load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__) + load File.expand_path("../lib/chat_mailer.rb", __FILE__) + load File.expand_path("../lib/chat_message_creator.rb", __FILE__) + load File.expand_path("../lib/chat_message_processor.rb", __FILE__) + load File.expand_path("../lib/chat_message_updater.rb", __FILE__) + load File.expand_path("../lib/chat_message_rate_limiter.rb", __FILE__) + load File.expand_path("../lib/chat_message_reactor.rb", __FILE__) + load File.expand_path("../lib/chat_notifier.rb", __FILE__) + load File.expand_path("../lib/chat_seeder.rb", __FILE__) + load File.expand_path("../lib/chat_statistics.rb", __FILE__) + load File.expand_path("../lib/chat_transcript_service.rb", __FILE__) + load File.expand_path("../lib/duplicate_message_validator.rb", __FILE__) + load File.expand_path("../lib/message_mover.rb", __FILE__) + load File.expand_path("../lib/chat_channel_membership_manager.rb", __FILE__) + load File.expand_path("../lib/chat_message_bookmarkable.rb", __FILE__) + load File.expand_path("../lib/chat_channel_archive_service.rb", __FILE__) + load File.expand_path("../lib/chat_review_queue.rb", __FILE__) + load File.expand_path("../lib/direct_message_channel_creator.rb", __FILE__) + load File.expand_path("../lib/guardian_extensions.rb", __FILE__) + load File.expand_path("../lib/extensions/user_option_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_notifications_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_email_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/category_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_extension.rb", __FILE__) + load File.expand_path("../lib/slack_compatibility.rb", __FILE__) + load File.expand_path("../lib/post_notification_handler.rb", __FILE__) + load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__) + load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__) + load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__) + load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_channel_archive.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_channel_delete.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_notify_mentioned.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_notify_watching.rb", __FILE__) + load File.expand_path("../app/jobs/regular/update_channel_user_count.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/delete_old_chat_messages.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/update_user_counts_for_chat_channels.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/email_chat_notifications.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__) + load File.expand_path("../app/services/chat_publisher.rb", __FILE__) + load File.expand_path("../app/controllers/api_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_channel_memberships_controller.rb", __FILE__) + load File.expand_path( + "../app/controllers/api/chat_channel_notifications_settings_controller.rb", + __FILE__, + ) + load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) + load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) + + if Discourse.allow_dev_populate? + load File.expand_path("../lib/discourse_dev/public_channel.rb", __FILE__) + load File.expand_path("../lib/discourse_dev/direct_channel.rb", __FILE__) + load File.expand_path("../lib/discourse_dev/message.rb", __FILE__) + end + + UserNotifications.append_view_path(File.expand_path("../app/views", __FILE__)) + + register_category_custom_field_type(Chat::HAS_CHAT_ENABLED, :boolean) + + UserUpdater::OPTION_ATTR.push(:chat_enabled) + UserUpdater::OPTION_ATTR.push(:only_chat_push_notifications) + UserUpdater::OPTION_ATTR.push(:chat_sound) + UserUpdater::OPTION_ATTR.push(:ignore_channel_wide_mention) + UserUpdater::OPTION_ATTR.push(:chat_email_frequency) + + register_reviewable_type ReviewableChatMessage + + reloadable_patch do |plugin| + ReviewableScore.add_new_types([:needs_review]) + + Site.preloaded_category_custom_fields << Chat::HAS_CHAT_ENABLED + Site.markdown_additional_options["chat"] = { + limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES, + limited_pretty_text_markdown_rules: ChatMessage::MARKDOWN_IT_RULES, + } + + Guardian.prepend Chat::GuardianExtensions + UserNotifications.prepend Chat::UserNotificationsExtension + UserOption.prepend Chat::UserOptionExtension + Category.prepend Chat::CategoryExtension + User.prepend Chat::UserExtension + Jobs::UserEmail.prepend Chat::UserEmailExtension + + Bookmark.register_bookmarkable(ChatMessageBookmarkable) + end + + if Oneboxer.respond_to?(:register_local_handler) + Oneboxer.register_local_handler("chat/chat") do |url, route| + queryParams = + begin + CGI.parse(URI.parse(url).query) + rescue StandardError + {} + end + messageId = queryParams["messageId"]&.first + + if messageId.present? + message = ChatMessage.find_by(id: messageId) + next if !message + + chat_channel = message.chat_channel + user = message.user + next if !chat_channel || !user + else + chat_channel = ChatChannel.find_by(id: route[:channel_id]) + next if !chat_channel + end + + next if !Guardian.new.can_see_chat_channel?(chat_channel) + + name = (chat_channel.name if chat_channel.name.present?) + + users = + chat_channel + .user_chat_channel_memberships + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .limit(10) + .map do |membership| + { + username: membership.user.username, + avatar_url: membership.user.avatar_template_url.gsub("{size}", "60"), + } + end + + remaining_user_count_str = + if chat_channel.user_count > users.size + I18n.t("chat.onebox.and_x_others", count: chat_channel.user_count - users.size) + end + + args = { + url: url, + channel_id: chat_channel.id, + channel_name: name, + description: chat_channel.description, + user_count_str: I18n.t("chat.onebox.x_members", count: chat_channel.user_count), + users: users, + remaining_user_count_str: remaining_user_count_str, + is_category: chat_channel.chatable_type == "Category", + color: chat_channel.chatable_type == "Category" ? chat_channel.chatable.color : nil, + } + + if message.present? + args[:message_id] = message.id + args[:username] = message.user.username + args[:avatar_url] = message.user.avatar_template_url.gsub("{size}", "20") + args[:cooked] = message.cooked + args[:created_at] = message.created_at + args[:created_at_str] = message.created_at.iso8601 + end + + Mustache.render(Chat.onebox_template, args) + end + end + + if InlineOneboxer.respond_to?(:register_local_handler) + InlineOneboxer.register_local_handler("chat/chat") do |url, route| + queryParams = + begin + CGI.parse(URI.parse(url).query) + rescue StandardError + {} + end + messageId = queryParams["messageId"]&.first + + if messageId.present? + message = ChatMessage.find_by(id: messageId) + next if !message + + chat_channel = message.chat_channel + user = message.user + next if !chat_channel || !user + + title = + I18n.t( + "chat.onebox.inline_to_message", + message_id: message.id, + chat_channel: chat_channel.name, + username: user.username, + ) + else + chat_channel = ChatChannel.find_by(id: route[:channel_id]) + next if !chat_channel + + title = + if chat_channel.name.present? + I18n.t("chat.onebox.inline_to_channel", chat_channel: chat_channel.name) + end + end + + next if !Guardian.new.can_see_chat_channel?(chat_channel) + + { url: url, title: title } + end + end + + if respond_to?(:register_upload_unused) + register_upload_unused do |uploads| + uploads.joins("LEFT JOIN chat_uploads cu ON cu.upload_id = uploads.id").where( + "cu.upload_id IS NULL", + ) + end + end + + if respond_to?(:register_upload_in_use) + register_upload_in_use do |upload| + ChatMessage.where( + "message LIKE ? OR message LIKE ?", + "%#{upload.sha1}%", + "%#{upload.base62_sha1}%", + ).exists? || + ChatDraft.where( + "data LIKE ? OR data LIKE ?", + "%#{upload.sha1}%", + "%#{upload.base62_sha1}%", + ).exists? + end + end + + add_to_serializer(:user_card, :can_chat_user) do + return false if !SiteSetting.chat_enabled + return false if scope.user.blank? + + scope.user.id != object.id && scope.can_chat?(scope.user) && scope.can_chat?(object) + end + + add_to_serializer(:current_user, :can_chat) { true } + + add_to_serializer(:current_user, :include_can_chat?) do + return @can_chat if defined?(@can_chat) + + @can_chat = SiteSetting.chat_enabled && scope.can_chat?(object) + end + + add_to_serializer(:current_user, :has_chat_enabled) { true } + + add_to_serializer(:current_user, :include_has_chat_enabled?) do + return @has_chat_enabled if defined?(@has_chat_enabled) + + @has_chat_enabled = include_can_chat? && object.user_option.chat_enabled + end + + add_to_serializer(:current_user, :chat_sound) { object.user_option.chat_sound } + + add_to_serializer(:current_user, :include_chat_sound?) do + include_has_chat_enabled? && object.user_option.chat_sound + end + + add_to_serializer(:current_user, :needs_channel_retention_reminder) { true } + + add_to_serializer(:current_user, :needs_dm_retention_reminder) { true } + + add_to_serializer(:current_user, :has_joinable_public_channels) do + Chat::ChatChannelFetcher.secured_public_channels( + self.scope, + Chat::ChatChannelMembershipManager.all_for_user(self.scope.user), + following: false, + limit: 1, + status: :open, + ).present? + end + + add_to_serializer(:current_user, :chat_channels) do + structured = Chat::ChatChannelFetcher.structured(self.scope) + ChatChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json + end + + add_to_serializer(:current_user, :include_needs_channel_retention_reminder?) do + include_has_chat_enabled? && object.staff? && + !object.user_option.dismissed_channel_retention_reminder && + !SiteSetting.chat_channel_retention_days.zero? + end + + add_to_serializer(:current_user, :include_needs_dm_retention_reminder?) do + include_has_chat_enabled? && !object.user_option.dismissed_dm_retention_reminder && + !SiteSetting.chat_dm_retention_days.zero? + end + + add_to_serializer(:current_user, :chat_drafts) do + ChatDraft + .where(user_id: object.id) + .pluck(:chat_channel_id, :data) + .map { |row| { channel_id: row[0], data: row[1] } } + end + + add_to_serializer(:current_user, :include_chat_drafts?) { include_has_chat_enabled? } + + add_to_serializer(:user_option, :chat_enabled) { object.chat_enabled } + + add_to_serializer(:user_option, :chat_sound) { object.chat_sound } + + add_to_serializer(:user_option, :include_chat_sound?) { !object.chat_sound.blank? } + + add_to_serializer(:user_option, :only_chat_push_notifications) do + object.only_chat_push_notifications + end + + add_to_serializer(:user_option, :ignore_channel_wide_mention) do + object.ignore_channel_wide_mention + end + + add_to_serializer(:user_option, :chat_email_frequency) { object.chat_email_frequency } + + RETENTION_SETTINGS_TO_USER_OPTION_FIELDS = { + chat_channel_retention_days: :dismissed_channel_retention_reminder, + chat_dm_retention_days: :dismissed_dm_retention_reminder, + } + on(:site_setting_changed) do |name, old_value, new_value| + user_option_field = RETENTION_SETTINGS_TO_USER_OPTION_FIELDS[name.to_sym] + begin + if user_option_field && old_value != new_value && !new_value.zero? + UserOption.where(user_option_field => true).update_all(user_option_field => false) + end + rescue => e + Rails.logger.warn( + "Error updating user_options fields after chat retention settings changed: #{e}", + ) + end + + if name == :secure_uploads && old_value == false && new_value == true + Chat::SecureUploadsCompatibility.update_settings + end + end + + on(:post_alerter_after_save_post) do |post, new_record, notified| + next if !new_record + Chat::PostNotificationHandler.new(post, notified).handle + end + + register_presence_channel_prefix("chat") do |channel_name| + next nil unless channel_name == "/chat/online" + config = PresenceChannel::Config.new + config.allowed_group_ids = Chat.allowed_group_ids + config + end + + register_presence_channel_prefix("chat-reply") do |channel_name| + if chat_channel_id = channel_name[%r{/chat-reply/(\d+)}, 1] + chat_channel = ChatChannel.find(chat_channel_id) + + PresenceChannel::Config.new.tap do |config| + config.allowed_group_ids = chat_channel.allowed_group_ids + config.allowed_user_ids = chat_channel.allowed_user_ids + config.public = !chat_channel.read_restricted? + end + end + rescue ActiveRecord::RecordNotFound + nil + end + + register_presence_channel_prefix("chat-user") do |channel_name| + if user_id = channel_name[%r{/chat-user/(chat|core)/(\d+)}, 2] + user = User.find(user_id) + config = PresenceChannel::Config.new + config.allowed_user_ids = [user.id] + config + end + rescue ActiveRecord::RecordNotFound + nil + end + + CHAT_NOTIFICATION_TYPES = [Notification.types[:chat_mention], Notification.types[:chat_message]] + register_push_notification_filter do |user, payload| + if user.user_option.only_chat_push_notifications && user.user_option.chat_enabled + CHAT_NOTIFICATION_TYPES.include?(payload[:notification_type]) + else + true + end + end + + on(:user_seen) do |user| + if user.last_seen_at == user.first_seen_at + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + end + + on(:user_confirmed_email) do |user| + if user.active? + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + end + + on(:user_added_to_group) do |user, group| + channels_to_add = + ChatChannel + .distinct + .where(auto_join_users: true, chatable_type: "Category") + .joins( + "INNER JOIN category_groups ON category_groups.category_id = chat_channels.chatable_id", + ) + .where(category_groups: { group_id: group.id }) + + channels_to_add.each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + + on(:category_updated) do |category| + # TODO(roman): remove early return after 2.9 release. + # There's a bug on core where this event is triggered with an `#update` result (true/false) + return if !category.is_a?(Category) + category_channel = ChatChannel.find_by(auto_join_users: true, chatable: category) + + if category_channel + Chat::ChatChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships + end + end + + Chat::Engine.routes.draw do + namespace :api do + get "/chat_channels" => "chat_channels#index" + get "/chat_channels/:chat_channel_id/memberships" => "chat_channel_memberships#index" + put "/chat_channels/:chat_channel_id" => "chat_channels#update" + put "/chat_channels/:chat_channel_id/notifications_settings" => + "chat_channel_notifications_settings#update" + + # hints controller. Only used by staff members, we don't want to leak category permissions. + get "/category-chatables/:id/permissions" => "category_chatables#permissions", + :format => :json, + :constraints => StaffConstraint.new + end + + # direct_messages_controller routes + get "/direct_messages" => "direct_messages#index" + post "/direct_messages/create" => "direct_messages#create" + + # incoming_webhooks_controller routes + post "/hooks/:key" => "incoming_chat_webhooks#create_message" + + # incoming_webhooks_controller routes + post "/hooks/:key/slack" => "incoming_chat_webhooks#create_message_slack_compatible" + + # chat_channel_controller routes + get "/chat_channels" => "chat_channels#index" + put "/chat_channels" => "chat_channels#create" + get "/chat_channels/search" => "chat_channels#search" + post "/chat_channels/:chat_channel_id" => "chat_channels#edit" + post "/chat_channels/:chat_channel_id/notification_settings" => + "chat_channels#notification_settings" + post "/chat_channels/:chat_channel_id/follow" => "chat_channels#follow" + post "/chat_channels/:chat_channel_id/unfollow" => "chat_channels#unfollow" + get "/chat_channels/:chat_channel_id" => "chat_channels#show" + put "/chat_channels/:chat_channel_id/archive" => "chat_channels#archive" + put "/chat_channels/:chat_channel_id/retry_archive" => "chat_channels#retry_archive" + put "/chat_channels/:chat_channel_id/change_status" => "chat_channels#change_status" + delete "/chat_channels/:chat_channel_id" => "chat_channels#destroy" + + # chat_controller routes + get "/" => "chat#respond" + get "/browse" => "chat#respond" + get "/browse/all" => "chat#respond" + get "/browse/closed" => "chat#respond" + get "/browse/open" => "chat#respond" + get "/browse/archived" => "chat#respond" + get "/draft-channel" => "chat#respond" + get "/channel/:channel_id" => "chat#respond" + get "/channel/:channel_id/:channel_title" => "chat#respond", :as => "channel" + get "/channel/:channel_id/:channel_title/info" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/about" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/members" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/settings" => "chat#respond" + post "/enable" => "chat#enable_chat" + post "/disable" => "chat#disable_chat" + post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" + get "/:chat_channel_id/messages" => "chat#messages" + get "/message/:message_id" => "chat#message_link" + put ":chat_channel_id/edit/:message_id" => "chat#edit_message" + put ":chat_channel_id/react/:message_id" => "chat#react" + delete "/:chat_channel_id/:message_id" => "chat#delete" + put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" + post "/:chat_channel_id/:message_id/flag" => "chat#flag" + post "/:chat_channel_id/quote" => "chat#quote_messages" + put "/:chat_channel_id/move_messages_to_channel" => "chat#move_messages_to_channel" + put "/:chat_channel_id/restore/:message_id" => "chat#restore" + get "/lookup/:message_id" => "chat#lookup_message" + put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" + put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" + put "/:chat_channel_id/invite" => "chat#invite_users" + post "/drafts" => "chat#set_draft" + post "/:chat_channel_id" => "chat#create_message" + put "/flag" => "chat#flag" + get "/emojis" => "emojis#index" + end + + Discourse::Application.routes.append do + mount ::Chat::Engine, at: "/chat" + get "/admin/plugins/chat" => "chat/admin_incoming_chat_webhooks#index", + :constraints => StaffConstraint.new + post "/admin/plugins/chat/hooks" => "chat/admin_incoming_chat_webhooks#create", + :constraints => StaffConstraint.new + put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => + "chat/admin_incoming_chat_webhooks#update", + :constraints => StaffConstraint.new + delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => + "chat/admin_incoming_chat_webhooks#destroy", + :constraints => StaffConstraint.new + get "u/:username/preferences/chat" => "users#preferences", + :constraints => { + username: RouteFormat.username, + } + end + + if defined?(DiscourseAutomation) + add_automation_scriptable("send_chat_message") do + field :chat_channel_id, component: :text, required: true + field :message, component: :message, required: true, accepts_placeholders: true + field :sender, component: :user + + placeholder :channel_name + + triggerables [:recurring] + + script do |context, fields, automation| + sender = User.find_by(username: fields.dig("sender", "value")) || Discourse.system_user + channel = ChatChannel.find_by(id: fields.dig("chat_channel_id", "value")) + + placeholders = { channel_name: channel.title(sender) }.merge(context["placeholders"] || {}) + + creator = + Chat::ChatMessageCreator.create( + chat_channel: channel, + user: sender, + content: utils.apply_placeholders(fields.dig("message", "value"), placeholders), + ) + + if creator.failed? + Rails.logger.warn "[discourse-automation] Chat message failed to send, error was: #{creator.error}" + end + end + end + end + + add_api_key_scope( + :chat, + { create_message: { actions: %w[chat/chat#create_message], params: %i[chat_channel_id] } }, + ) + + # Dark mode email styles + Email::Styles.register_plugin_style do |fragment| + fragment.css(".chat-summary-header").each { |element| element[:dm] = "header" } + fragment.css(".chat-summary-content").each { |element| element[:dm] = "body" } + end + + # TODO(roman): Remove `respond_to?` after 2.9 release + if respond_to?(:register_email_unsubscriber) + load File.expand_path("../lib/email_controller_helper/chat_summary_unsubscriber.rb", __FILE__) + register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) + end + + register_about_stat_group("chat_messages", show_in_ui: true) { Chat::Statistics.about_messages } + + register_about_stat_group("chat_channels") { Chat::Statistics.about_channels } + + register_about_stat_group("chat_users") { Chat::Statistics.about_users } +end + +if Rails.env == "test" + Dir[Rails.root.join("plugins/chat/spec/support/**/*.rb")].each { |f| require f } +end diff --git a/plugins/chat/public/audio/bell.mp3 b/plugins/chat/public/audio/bell.mp3 new file mode 100644 index 0000000000..1f5a34a427 Binary files /dev/null and b/plugins/chat/public/audio/bell.mp3 differ diff --git a/plugins/chat/public/audio/ding.mp3 b/plugins/chat/public/audio/ding.mp3 new file mode 100644 index 0000000000..82c885ce07 Binary files /dev/null and b/plugins/chat/public/audio/ding.mp3 differ diff --git a/plugins/chat/public/images/deleted-chat-user-avatar.png b/plugins/chat/public/images/deleted-chat-user-avatar.png new file mode 100644 index 0000000000..88146bdc28 Binary files /dev/null and b/plugins/chat/public/images/deleted-chat-user-avatar.png differ diff --git a/plugins/chat/spec/components/chat_mailer_spec.rb b/plugins/chat/spec/components/chat_mailer_spec.rb new file mode 100644 index 0000000000..20229526a8 --- /dev/null +++ b/plugins/chat/spec/components/chat_mailer_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMailer do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } + fab!(:user_1) { Fabricate(:user, group_ids: [chatters_group.id], last_seen_at: 15.minutes.ago) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, user: sender, chat_channel: chat_channel) } + fab!(:user_1_chat_channel_membership) do + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: chat_channel, + last_read_message_id: nil, + ) + end + fab!(:private_chat_channel) do + Group.refresh_automatic_groups! + Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user_1]) + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = chatters_group.id + + Fabricate(:user_chat_channel_membership, user: sender, chat_channel: chat_channel) + end + + def assert_summary_skipped + expect( + job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }), + ).to eq(false) + end + + def assert_only_queued_once + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + describe "for chat mentions" do + fab!(:mention) { Fabricate(:chat_mention, user: user_1, chat_message: chat_message) } + + it "skips users without chat access" do + chatters_group.remove(user_1) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips users with summaries disabled" do + user_1.user_option.update(chat_email_frequency: UserOption.chat_email_frequencies[:never]) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips a job if the user haven't read the channel since the last summary" do + user_1_chat_channel_membership.update!(last_unread_mention_when_emailed_id: chat_message.id) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips without chat enabled" do + user_1.user_option.update( + chat_enabled: false, + chat_email_frequency: UserOption.chat_email_frequencies[:when_away], + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "queues a job for users that was mentioned and never read the channel before" do + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips the job when the user was mentioned but already read the message" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips the job when the user is not following a public channel anymore" do + user_1_chat_channel_membership.update!( + last_read_message_id: chat_message.id - 1, + following: false, + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "doesn’t skip the job when the user is not following a direct channel" do + private_chat_channel + .user_chat_channel_memberships + .where(user_id: user_1.id) + .update!(last_read_message_id: chat_message.id - 1, following: false) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips users with unread messages from a different channel" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id) + second_channel = Fabricate(:category_channel) + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: second_channel, + last_read_message_id: chat_message.id - 1, + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "only queues the job once for users who are member of multiple groups with chat access" do + chatters_group_2 = Fabricate(:group, users: [user_1]) + SiteSetting.chat_allowed_groups = [chatters_group, chatters_group_2].map(&:id).join("|") + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips users when the mention was deleted" do + chat_message.trash! + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "queues the job if the user has unread mentions and already read all the messages in the previous summary" do + user_1_chat_channel_membership.update!( + last_read_message_id: chat_message.id, + last_unread_mention_when_emailed_id: chat_message.id, + ) + unread_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: unread_message) + + described_class.send_unread_mentions_summary + + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + it "skips users who were seen recently" do + user_1.update!(last_seen_at: 2.minutes.ago) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "doesn't mix mentions from other users" do + mention.destroy! + user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago) + user_2_membership = + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: chat_channel, + last_read_message_id: nil, + ) + new_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_2, chat_message: new_message) + + described_class.send_unread_mentions_summary + + expect( + job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }), + ).to eq(false) + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_2.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + it "skips users when the message is older than 1 week" do + chat_message.update!(created_at: 1.5.weeks.ago) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "queues a job when the chat_allowed_groups is set to everyone" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + describe "update the user membership after we send the email" do + before { Jobs.run_immediately! } + + it "doesn't send the same summary the summary again if the user haven't read any channel messages since the last one" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1) + described_class.send_unread_mentions_summary + + expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq( + chat_message.id, + ) + + another_channel_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: another_channel_message) + + expect { described_class.send_unread_mentions_summary }.not_to change( + Jobs::UserEmail.jobs, + :size, + ) + end + + it "only updates the last_message_read_when_emailed_id on the channel with unread mentions" do + another_channel = Fabricate(:category_channel) + another_channel_message = + Fabricate(:chat_message, chat_channel: another_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: another_channel_message) + another_channel_membership = + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: another_channel, + last_read_message_id: another_channel_message.id, + ) + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1) + + described_class.send_unread_mentions_summary + + expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq( + chat_message.id, + ) + expect(another_channel_membership.reload.last_unread_mention_when_emailed_id).to be_nil + end + end + end + + describe "for direct messages" do + before { Fabricate(:chat_message, user: sender, chat_channel: private_chat_channel) } + + it "queue a job when the user has unread private mentions" do + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "only queues the job once when the user has mentions and private messages" do + Fabricate(:chat_mention, user: user_1, chat_message: chat_message) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "Doesn't mix or update mentions from other users when joining tables" do + user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago) + user_2_membership = + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: chat_channel, + last_read_message_id: chat_message.id, + ) + Fabricate(:chat_mention, user: user_2, chat_message: chat_message) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + expect(user_2_membership.reload.last_unread_mention_when_emailed_id).to be_nil + end + end +end diff --git a/plugins/chat/spec/components/chat_message_creator_spec.rb b/plugins/chat/spec/components/chat_message_creator_spec.rb new file mode 100644 index 0000000000..bebdd30296 --- /dev/null +++ b/plugins/chat/spec/components/chat_message_creator_spec.rb @@ -0,0 +1,581 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageCreator do + fab!(:admin1) { Fabricate(:admin) } + fab!(:admin2) { Fabricate(:admin) } + fab!(:user1) { Fabricate(:user, group_ids: [Group::AUTO_GROUPS[:everyone]]) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:admin_group) do + Fabricate( + :public_group, + users: [admin1, admin2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_group) do + Fabricate( + :public_group, + users: [user1, user2, user3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_without_memberships) { Fabricate(:user) } + fab!(:public_chat_channel) { Fabricate(:category_channel) } + fab!(:dm_chat_channel) do + Fabricate( + :direct_message_channel, + chatable: Fabricate(:direct_message, users: [user1, user2, user3]), + ) + end + let(:direct_message_channel) do + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user2], + ) + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + + # Create channel memberships + [admin1, admin2, user1, user2, user3].each do |user| + Fabricate(:user_chat_channel_membership, chat_channel: public_chat_channel, user: user) + end + + Group.refresh_automatic_groups! + direct_message_channel + end + + describe "Integration tests with jobs running immediately" do + before { Jobs.run_immediately! } + + it "errors when length is less than `chat_minimum_message_length`" do + SiteSetting.chat_minimum_message_length = 10 + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "2 short", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t( + "chat.errors.minimum_length_not_met", + { minimum: SiteSetting.chat_minimum_message_length }, + ), + ) + end + + it "allows message creation when length is less than `chat_minimum_message_length` when upload is present" do + upload = Fabricate(:upload, user: user1) + SiteSetting.chat_minimum_message_length = 10 + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "2 short", + upload_ids: [upload.id], + ) + }.to change { ChatMessage.count }.by(1) + end + + it "creates messages for users who can see the channel" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ) + }.to change { ChatMessage.count }.by(1) + end + + it "sets the last_editor_id to the user who created the message" do + message = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ).chat_message + expect(message.last_editor_id).to eq(user1.id) + end + + it "publishes a DiscourseEvent for new messages" do + events = DiscourseEvent.track_events { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ) + } + expect(events.map { _1[:event_name] }).to include(:chat_message_created) + end + + it "creates mention notifications for public chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: + "this is a @#{user1.username} message with @system @mentions @#{user2.username} and @#{user3.username}", + ) + # Only 2 mentions are created because user mentioned themselves, system, and an invalid username. + }.to change { ChatMention.count }.by(2).and not_change { user1.chat_mentions.count } + end + + it "mentions are case insensitive" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hey @#{user2.username.upcase}", + ) + }.to change { user2.chat_mentions.count }.by(1) + end + + it "notifies @all properly" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@all", + ) + }.to change { ChatMention.count }.by(4) + + UserChatChannelMembership.where(user: user2, chat_channel: public_chat_channel).update_all( + following: false, + ) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "again! @all", + ) + }.to change { ChatMention.count }.by(3) + end + + it "notifies @here properly" do + admin1.update(last_seen_at: 1.year.ago) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: Time.now) + user2.update(last_seen_at: Time.now) + user3.update(last_seen_at: Time.now) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ) + }.to change { ChatMention.count }.by(2) + end + + it "doesn't sent double notifications when '@here' is mentioned" do + user2.update(last_seen_at: Time.now) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here @#{user2.username}", + ) + }.to change { user2.chat_mentions.count }.by(1) + end + + it "notifies @here plus other mentions" do + admin1.update(last_seen_at: Time.now) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: 1.year.ago) + user2.update(last_seen_at: 1.year.ago) + user3.update(last_seen_at: 1.year.ago) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here plus @#{user3.username}", + ) + }.to change { user3.chat_mentions.count }.by(1) + end + + it "doesn't create mention notifications for users without a membership record" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{user_without_memberships.username}", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mention notifications for users who cannot chat" do + new_group = Group.create + SiteSetting.chat_allowed_groups = new_group.id + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username} @#{user3.username}", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mention notifications for users with chat disabled" do + user2.user_option.update(chat_enabled: false) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username}", + ) + }.not_to change { ChatMention.count } + end + + it "creates only mention notifications for users with access in private chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello there @#{user2.username} and @#{user3.username}", + ) + # Only user2 should be notified + }.to change { user2.chat_mentions.count }.by(1).and not_change { user3.chat_mentions.count } + end + + it "creates a mention notifications for group users that are participating in private chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello there @#{user_group.name}", + ) + # Only user2 should be notified + }.to change { user2.chat_mentions.count }.by(1).and not_change { user3.chat_mentions.count } + end + + it "publishes inaccessible mentions when user isn't aren't a part of the channel" do + ChatPublisher.expects(:publish_inaccessible_mentions).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{user4.username}", + ) + end + + it "publishes inaccessible mentions when user doesn't have chat access" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + ChatPublisher.expects(:publish_inaccessible_mentions).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{user3.username}", + ) + end + + it "doesn't publish inaccessible mentions when user is following channel" do + ChatPublisher.expects(:publish_inaccessible_mentions).never + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{admin2.username}", + ) + end + + it "does not create mentions for suspended users" do + user2.update(suspended_till: Time.now + 10.years) + expect { + Chat::ChatMessageCreator.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello @#{user2.username}", + ) + }.not_to change { user2.chat_mentions.count } + end + + it "does not create @all mentions for users when ignore_channel_wide_mention is enabled" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@all", + ) + }.to change { ChatMention.count }.by(4) + + user2.user_option.update(ignore_channel_wide_mention: true) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi! @all", + ) + }.to change { ChatMention.count }.by(3) + end + + it "does not create @here mentions for users when ignore_channel_wide_mention is enabled" do + admin1.update(last_seen_at: 1.year.ago) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: Time.now) + user2.update(last_seen_at: Time.now) + user2.user_option.update(ignore_channel_wide_mention: true) + user3.update(last_seen_at: Time.now) + + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ) + }.to change { ChatMention.count }.by(1) + end + + describe "group mentions" do + it "creates chat mentions for group mentions where the group is mentionable" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1) + end + + it "doesn't mention users twice if they are direct mentioned and group mentioned" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{admin1.username} and @#{admin2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1) + end + + it "creates chat mentions for group mentions and direct mentions" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{user2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1).and change { user2.chat_mentions.count }.by(1) + end + + it "creates chat mentions for group mentions and direct mentions" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{user_group.name}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1).and change { user2.chat_mentions.count }.by(1).and change { + user3.chat_mentions.count + }.by(1) + end + + it "doesn't create chat mentions for group mentions where the group is un-mentionable" do + admin_group.update(mentionable_level: Group::ALIAS_LEVELS[:nobody]) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name}", + ) + }.not_to change { ChatMention.count } + end + end + + describe "push notifications" do + before do + UserChatChannelMembership.where(user: user1, chat_channel: public_chat_channel).update( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + PresenceChannel.clear_all! + end + + it "sends a push notification to watching users who are not in chat" do + PostAlerter.expects(:push_notification).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user2, + content: "Beep boop", + ) + end + + it "does not send a push notification to watching users who are in chat" do + PresenceChannel.new("/chat/online").present(user_id: user1.id, client_id: 1) + PostAlerter.expects(:push_notification).never + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user2, + content: "Beep boop", + ) + end + end + + describe "with uploads" do + fab!(:upload1) { Fabricate(:upload, user: user1) } + fab!(:upload2) { Fabricate(:upload, user: user1) } + fab!(:private_upload) { Fabricate(:upload, user: user2) } + + it "can attach 1 upload to a new message" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(upload_id: upload1.id).count }.by(1) + end + + it "can attach multiple uploads to a new message" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id, upload2.id], + ) + }.to change { ChatUpload.where(upload_id: upload1.id).count }.by(1).and change { + ChatUpload.where(upload_id: upload2.id).count + }.by(1) + end + + it "filters out uploads that weren't uploaded by the user" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [private_upload.id], + ) + }.not_to change { ChatUpload.where(upload_id: private_upload.id).count } + end + + it "doesn't attach uploads when `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.not_to change { ChatUpload.where(upload_id: upload1.id).count } + end + end + end + + it "destroys draft after message was created" do + ChatDraft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") + + expect do + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hi @#{user2.username}", + ) + end.to change { ChatDraft.count }.by(-1) + end + + describe "watched words" do + fab!(:watched_word) { Fabricate(:watched_word) } + + it "errors when a blocked word is present" do + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "bad word - #{watched_word.word}", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t("contains_blocked_word", { word: watched_word.word }), + ) + end + end + + describe "channel statuses" do + def create_message(user) + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user, + content: "test message", + ) + end + + context "when channel is closed" do + before { public_chat_channel.update(status: :closed) } + + it "errors when trying to create the message for non-staff" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + + it "does not error when trying to create a message for staff" do + expect { create_message(admin1) }.to change { ChatMessage.count }.by(1) + end + end + + context "when channel is read_only" do + before { public_chat_channel.update(status: :read_only) } + + it "errors when trying to create the message for all users" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + + context "when channel is archived" do + before { public_chat_channel.update(status: :archived) } + + it "errors when trying to create the message for all users" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + end +end diff --git a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb b/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb new file mode 100644 index 0000000000..b91616a359 --- /dev/null +++ b/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageRateLimiter do + fab!(:user) { Fabricate(:user, trust_level: 3) } + let(:limiter) { described_class.new(user) } + + before do + freeze_time + RateLimiter.enable + SiteSetting.chat_allowed_messages_for_trust_level_0 = 1 + SiteSetting.chat_allowed_messages_for_other_trust_levels = 2 + SiteSetting.chat_auto_silence_duration = 30 + end + + after { limiter.clear! } + + it "does nothing when rate limits are not exceeded" do + limiter.run! + expect(user.reload.silenced?).to be false + end + + it "silences the user for the correct amount of time when they exceed the limit" do + 2.times do + limiter.run! + expect(user.reload.silenced?).to be false + end + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + + expect(user.reload.silenced?).to be true + expect(user.silenced_till).to be_within(0.1).of(30.minutes.from_now) + end + + it "silences the user correctly based on trust level" do + user.update(trust_level: 0) # Should only be able to run once without hitting limit + limiter.run! + expect(user.reload.silenced?).to be false + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + expect(user.reload.silenced?).to be true + end + + it "doesn't hit limit if site setting for allowed messages equals 0" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 0 + 5.times do + limiter.run! + expect(user.reload.silenced?).to be false + end + end + + it "doesn't silence the user even when the limit is broken if auto_silence_duration is set to 0" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 1 + SiteSetting.chat_auto_silence_duration = 0 + limiter.run! + expect(user.reload.silenced?).to be false + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + expect(user.reload.silenced?).to be false + end + + it "logs a staff action when the user is silenced" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 1 + limiter.run! + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded).and change { + UserHistory.where( + target_user: user, + acting_user: Discourse.system_user, + action: UserHistory.actions[:silence_user], + ).count + }.by(1) + end +end diff --git a/plugins/chat/spec/components/chat_message_updater_spec.rb b/plugins/chat/spec/components/chat_message_updater_spec.rb new file mode 100644 index 0000000000..d31e4bae8e --- /dev/null +++ b/plugins/chat/spec/components/chat_message_updater_spec.rb @@ -0,0 +1,482 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageUpdater do + let(:guardian) { Guardian.new(user1) } + fab!(:admin1) { Fabricate(:admin) } + fab!(:admin2) { Fabricate(:admin) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:admin_group) do + Fabricate( + :public_group, + users: [admin1, admin2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_without_memberships) { Fabricate(:user) } + fab!(:public_chat_channel) { Fabricate(:category_channel) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + Jobs.run_immediately! + + [admin1, admin2, user1, user2, user3, user4].each do |user| + Fabricate(:user_chat_channel_membership, chat_channel: public_chat_channel, user: user) + end + Group.refresh_automatic_groups! + @direct_message_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: user1, target_users: [user1, user2]) + end + + def create_chat_message(user, message, channel, upload_ids: nil) + creator = + Chat::ChatMessageCreator.create( + chat_channel: channel, + user: user, + in_reply_to_id: nil, + content: message, + upload_ids: upload_ids, + ) + creator.chat_message + end + + it "errors when length is less than `chat_minimum_message_length`" do + SiteSetting.chat_minimum_message_length = 10 + og_message = "This won't be changed!" + chat_message = create_chat_message(user1, og_message, public_chat_channel) + new_message = "2 short" + + updater = + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_message, + ) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to match( + I18n.t( + "chat.errors.minimum_length_not_met", + { minimum: SiteSetting.chat_minimum_message_length }, + ), + ) + expect(chat_message.reload.message).to eq(og_message) + end + + it "errors if a user other than the message user is trying to edit the message" do + og_message = "This won't be changed!" + chat_message = create_chat_message(user1, og_message, public_chat_channel) + new_message = "2 short" + updater = Chat::ChatMessageUpdater.update( + guardian: Guardian.new(Fabricate(:user)), + chat_message: chat_message, + new_content: new_message, + ) + expect(updater.failed?).to eq(true) + expect(updater.error).to match(Discourse::InvalidAccess) + end + + it "it updates a messages content" do + chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) + new_message = "Change to this!" + + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_message, + ) + expect(chat_message.reload.message).to eq(new_message) + end + + it "publishes a DiscourseEvent for updated messages" do + chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) + events = DiscourseEvent.track_events { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "Change to this!", + ) + } + expect(events.map { _1[:event_name] }).to include(:chat_message_edited) + end + + it "creates mention notifications for unmentioned users" do + chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: + "this is a message with @system @mentions @#{user2.username} and @#{user3.username}", + ) + }.to change { user2.chat_mentions.count }.by(1).and change { user3.chat_mentions.count }.by(1) + end + + it "doesn't create mentions for already mentioned users" do + message = "ping @#{user2.username} @#{user3.username}" + chat_message = create_chat_message(user1, message, public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: message + " editedddd", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mentions for users without access" do + message = "ping" + chat_message = create_chat_message(user1, message, public_chat_channel) + + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: message + " @#{user_without_memberships.username}", + ) + }.not_to change { ChatMention.count } + end + + it "destroys mention notifications that should be removed" do + chat_message = + create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{user3.username}", + ) + }.to change { user2.chat_mentions.count }.by(-1).and not_change { user3.chat_mentions.count } + end + + it "creates new, leaves existing, and removes old mentions all at once" do + chat_message = + create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{user3.username} @#{user4.username}", + ) + + expect(user2.chat_mentions.where(chat_message: chat_message)).not_to be_present + expect(user3.chat_mentions.where(chat_message: chat_message)).to be_present + expect(user4.chat_mentions.where(chat_message: chat_message)).to be_present + end + + it "does not create new mentions in direct message for users who don't have access" do + chat_message = create_chat_message(user1, "ping nobody", @direct_message_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{admin1.username}", + ) + }.not_to change { ChatMention.count } + end + + describe "group mentions" do + it "creates group mentions on update" do + chat_message = create_chat_message(user1, "ping nobody", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{admin_group.name}", + ) + }.to change { ChatMention.where(chat_message: chat_message).count }.by(2) + + expect(admin1.chat_mentions.where(chat_message: chat_message)).to be_present + expect(admin2.chat_mentions.where(chat_message: chat_message)).to be_present + end + + it "doesn't duplicate mentions when the user is already direct mentioned and then group mentioned" do + chat_message = create_chat_message(user1, "ping @#{admin2.username}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{admin_group.name} @#{admin2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and not_change { admin2.chat_mentions.count } + end + + it "deletes old mentions when group mention is removed" do + chat_message = create_chat_message(user1, "ping @#{admin_group.name}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping nobody anymore!", + ) + }.to change { ChatMention.where(chat_message: chat_message).count }.by(-2) + + expect(admin1.chat_mentions.where(chat_message: chat_message)).not_to be_present + expect(admin2.chat_mentions.where(chat_message: chat_message)).not_to be_present + end + end + + it "creates a chat_message_revision record and sets last_editor_id for the message" do + old_message = "It's a thrsday!" + new_message = "It's a thursday!" + chat_message = create_chat_message(user1, old_message, public_chat_channel) + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_message, + ) + revision = chat_message.revisions.last + expect(revision.old_message).to eq(old_message) + expect(revision.new_message).to eq(new_message) + expect(revision.user_id).to eq(guardian.user.id) + expect(chat_message.reload.last_editor_id).to eq(guardian.user.id) + end + + describe "uploads" do + fab!(:upload1) { Fabricate(:upload, user: user1) } + fab!(:upload2) { Fabricate(:upload, user: user1) } + + it "does nothing if the passed in upload_ids match the existing upload_ids" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload2.id, upload1.id], + ) + }.not_to change { ChatUpload.count } + end + + it "removes uploads that should be removed" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(upload_id: upload2.id).count }.by(-1) + end + + it "removes all uploads if they should be removed" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(-2) + end + + it "adds one upload if none exist" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(1) + end + + it "adds multiple uploads if none exist" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id, upload2.id], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(2) + end + + it "doesn't remove existing uploads when upload ids that do not exist are passed in" do + chat_message = + create_chat_message(user1, "something", public_chat_channel, upload_ids: [upload1.id]) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [0], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "doesn't add uploads if `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id, upload2.id], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "doesn't remove existing uploads if `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "updates if upload is present even if length is less than `chat_minimum_message_length`" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + SiteSetting.chat_minimum_message_length = 10 + new_message = "hi :)" + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_message, + upload_ids: [upload1.id], + ) + expect(chat_message.reload.message).to eq(new_message) + end + end + + describe "watched words" do + fab!(:watched_word) { Fabricate(:watched_word) } + + it "errors when a blocked word is present" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "bad word - #{watched_word.word}", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t("contains_blocked_word", { word: watched_word.word }), + ) + end + end + + describe "channel statuses" do + fab!(:message) { Fabricate(:chat_message, user: user1, chat_channel: public_chat_channel) } + + def update_message(user) + message.update(user: user) + Chat::ChatMessageUpdater.update( + guardian: Guardian.new(user), + chat_message: message, + new_content: "I guess this is different", + ) + end + + context "when channel is closed" do + before { public_chat_channel.update(status: :closed) } + + it "errors when trying to update the message for non-staff" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + + it "does not error when trying to create a message for staff" do + update_message(admin1) + expect(message.reload.message).to eq("I guess this is different") + end + end + + context "when channel is read_only" do + before { public_chat_channel.update(status: :read_only) } + + it "errors when trying to update the message for all users" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + updater = update_message(admin1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + + context "when channel is archived" do + before { public_chat_channel.update(status: :archived) } + + it "errors when trying to update the message for all users" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + updater = update_message(admin1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + end +end diff --git a/plugins/chat/spec/components/chat_seeder_spec.rb b/plugins/chat/spec/components/chat_seeder_spec.rb new file mode 100644 index 0000000000..e0a7c5222a --- /dev/null +++ b/plugins/chat/spec/components/chat_seeder_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatSeeder do + fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) } + fab!(:general_category) { Fabricate(:category, name: "General") } + + fab!(:staff_user1) do + Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]]) + end + fab!(:staff_user2) do + Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]]) + end + + fab!(:regular_user) { Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:everyone]]) } + + before do + SiteSetting.staff_category_id = staff_category.id + SiteSetting.general_category_id = general_category.id + Jobs.run_immediately! + end + + def assert_channel_was_correctly_seeded(channel, group) + expect(channel).to be_present + expect(channel.auto_join_users).to eq(true) + + expected_members_count = GroupUser.where(group: group).count + memberships_count = + UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count + + expect(memberships_count).to eq(expected_members_count) + end + + it "seeds default channels" do + ChatSeeder.new.execute + + staff_channel = ChatChannel.find_by(chatable: staff_category) + general_channel = ChatChannel.find_by(chatable: general_category) + + assert_channel_was_correctly_seeded(staff_channel, Group[:staff]) + assert_channel_was_correctly_seeded(general_channel, Group[:everyone]) + + expect(staff_category.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true) + expect(general_category.reload.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true) + expect(SiteSetting.needs_chat_seeded).to eq(false) + end + + it "applies a name to the general category channel" do + expected_name = general_category.name + + ChatSeeder.new.execute + + general_channel = ChatChannel.find_by(chatable: general_category) + expect(general_channel.name).to eq(expected_name) + end + + it "applies a name to the staff category channel" do + expected_name = staff_category.name + + ChatSeeder.new.execute + + staff_channel = ChatChannel.find_by(chatable: staff_category) + expect(staff_channel.name).to eq(expected_name) + end + + it "does nothing when 'SiteSetting.needs_chat_seeded' is false" do + SiteSetting.needs_chat_seeded = false + + expect { ChatSeeder.new.execute }.not_to change { ChatChannel.count } + end +end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb new file mode 100644 index 0000000000..ee07f45022 --- /dev/null +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +Fabricator(:chat_channel) do + name do + sequence(:name) do |n| + random_name = [ + "Gaming Lounge", + "Music Lodge", + "Random", + "Politics", + "Sports Center", + "Kino Buffs", + ].sample + "#{random_name} #{n}" + end + end + chatable { Fabricate(:category) } + type do |attrs| + attrs[:chatable_type] == "Category" || attrs[:chatable]&.is_a?(Category) ? "CategoryChannel" : "DirectMessageChannel" + end + status { :open } +end + +Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {} + +Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_message_channel) do + transient :users + chatable do |attrs| + Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) + end + status { :open } +end + +Fabricator(:chat_message) do + chat_channel + user + message "Beep boop" + cooked { |attrs| ChatMessage.cook(attrs[:message]) } + cooked_version ChatMessage::BAKED_VERSION +end + +Fabricator(:chat_mention) do + chat_message { Fabricate(:chat_message) } + user { Fabricate(:user) } + notification { Fabricate(:notification) } +end + +Fabricator(:chat_message_reaction) do + chat_message { Fabricate(:chat_message) } + user { Fabricate(:user) } + emoji { %w[+1 tada heart joffrey_facepalm].sample } +end + +Fabricator(:chat_upload) do + chat_message { Fabricate(:chat_message) } + upload { Fabricate(:upload) } +end + +Fabricator(:chat_message_revision) do + chat_message { Fabricate(:chat_message) } + old_message { "something old" } + new_message { "something new" } + user { |attrs| attrs[:chat_message].user } +end + +Fabricator(:reviewable_chat_message) do + reviewable_by_moderator true + type "ReviewableChatMessage" + created_by { Fabricate(:user) } + target_type "ChatMessage" + target { Fabricate(:chat_message) } + reviewable_scores { |p| [Fabricate.build(:reviewable_score, reviewable_id: p[:id])] } +end + +Fabricator(:direct_message) { users { [Fabricate(:user), Fabricate(:user)] } } + +Fabricator(:chat_webhook_event) do + chat_message { Fabricate(:chat_message) } + incoming_chat_webhook do |attrs| + Fabricate(:incoming_chat_webhook, chat_channel: attrs[:chat_message].chat_channel) + end +end + +Fabricator(:incoming_chat_webhook) do + name { sequence(:name) { |i| "#{i + 1}" } } + key { sequence(:key) { |i| "#{i + 1}" } } + chat_channel { Fabricate(:chat_channel, chatable: Fabricate(:category)) } +end + +Fabricator(:user_chat_channel_membership) do + user + chat_channel + following true +end + +Fabricator(:user_chat_channel_membership_for_dm, from: :user_chat_channel_membership) do + user + chat_channel + following true + desktop_notification_level 2 + mobile_notification_level 2 +end diff --git a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb new file mode 100644 index 0000000000..6fa39be884 --- /dev/null +++ b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "API keys scoped to chat#create_message" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:admin) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_channel_2) { Fabricate(:category_channel) } + + let(:chat_api_key) do + key = ApiKey.create! + ApiKeyScope.create!(resource: "chat", action: "create_message", api_key_id: key.id) + key + end + + let(:chat_channel_2_api_key) do + key = ApiKey.create! + ApiKeyScope.create!( + resource: "chat", + action: "create_message", + api_key_id: key.id, + allowed_parameters: { + "chat_channel_id" => [chat_channel_2.id.to_s], + }, + ) + key + end + + it "cannot hit any other endpoints" do + get "/admin/users/list/active.json", + headers: { + "Api-Key" => chat_api_key.key, + "Api-Username" => admin.username, + } + expect(response.status).to eq(404) + + get "/latest.json", headers: { "Api-Key" => chat_api_key.key, "Api-Username" => admin.username } + expect(response.status).to eq(403) + end + + it "can create chat messages" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + expect { + post "/chat/#{chat_channel.id}.json", + headers: { + "Api-Key" => chat_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + end + + it "cannot post in a channel it is not scoped for" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + expect { + post "/chat/#{chat_channel.id}.json", + headers: { + "Api-Key" => chat_channel_2_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(403) + end + + it "can only post in scoped channels" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel_2, following: true) + expect { + post "/chat/#{chat_channel_2.id}.json", + headers: { + "Api-Key" => chat_channel_2_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.to change { ChatMessage.where(chat_channel: chat_channel_2).count }.by(1) + expect(response.status).to eq(200) + end +end diff --git a/plugins/chat/spec/integration/plugin_api_spec.rb b/plugins/chat/spec/integration/plugin_api_spec.rb new file mode 100644 index 0000000000..a329d90682 --- /dev/null +++ b/plugins/chat/spec/integration/plugin_api_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Plugin API for chat" do + before { SiteSetting.chat_enabled = true } + + let(:metadata) do + metadata = Plugin::Metadata.new + metadata.name = "test" + metadata + end + + let(:plugin_instance) do + plugin = Plugin::Instance.new(nil, "/tmp/test.rb") + plugin.metadata = metadata + plugin + end + + describe "chat.enable_markdown_feature" do + it "stores the markdown feature" do + plugin_instance.chat.enable_markdown_feature(:foo) + + expect(DiscoursePluginRegistry.chat_markdown_features.include?(:foo)).to be_truthy + end + end +end diff --git a/plugins/chat/spec/integration/post_chat_quote_spec.rb b/plugins/chat/spec/integration/post_chat_quote_spec.rb new file mode 100644 index 0000000000..1c1e0430bd --- /dev/null +++ b/plugins/chat/spec/integration/post_chat_quote_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +describe "chat bbcode quoting in posts" do + fab!(:post) { Fabricate(:post) } + + before { SiteSetting.chat_enabled = true } + + it "can render the simplest version" do + post.update!( + raw: "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    +
    +
    + martin
    +
    + +
    +
    +
    +

    This is a chat message.

    +
    +
    + COOKED + end + + it "renders the channel name if provided with multiQuote" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" multiQuote=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    + Originally sent in Cool Cats Club +
    +
    +
    +
    + martin
    +
    + +
    +
    +
    +

    This is a chat message.

    +
    +
    + COOKED + end + + it "renders the channel name if provided without multiQuote" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    +
    +
    + martin
    +
    + +
    + + #Cool Cats Club +
    +
    +

    This is a chat message.

    +
    +
    + COOKED + end + + it "renders with the chained attribute for more compact quotes" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" chained=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    +
    +
    + martin
    +
    + +
    + + #Cool Cats Club +
    +
    +

    This is a chat message.

    +
    +
    + COOKED + end + + it "renders with the noLink attribute to remove the links to the individual messages from the datetimes" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" multiQuote=\"true\" noLink=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    + Originally sent in Cool Cats Club +
    +
    +
    +
    + martin
    +
    + +
    +
    +
    +

    This is a chat message.

    +
    +
    + COOKED + end + + it "renders with the reactions attribute" do + reactions_attr = "+1:martin;heart:martin,eviltrout" + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" reactions=\"#{reactions_attr}\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
    +
    +
    +
    + martin
    +
    + +
    + + #Cool Cats Club +
    +
    +

    This is a chat message.

    +
    +
    + +1 1
    +
    + heart 2
    +
    +
    +
    + COOKED + end + + it "correctly renders inline and non-inline oneboxes combined with chat quotes" do + full_onebox_html = <<~HTML.chomp + + HTML + SiteSetting.enable_inline_onebox_on_all_domains = true + Oneboxer + .stubs(:cached_onebox) + .with("https://en.wikipedia.org/wiki/Hyperlink") + .returns(full_onebox_html) + stub_request(:get, "https://en.wikipedia.org/wiki/Hyperlink").to_return( + status: 200, + body: "Hyperlink - Wikipedia", + ) + + post.update!(raw: <<~MD) +https://en.wikipedia.org/wiki/Hyperlink + +[chat quote=\"martin;2321;2022-01-25T05:40:39Z\"] +This is a chat message. +[/chat] + +https://en.wikipedia.org/wiki/Hyperlink + +This is an inline onebox https://en.wikipedia.org/wiki/Hyperlink. + MD + + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +#{full_onebox_html} +
    +
    +
    +
    +martin
    +
    + +
    +
    +
    +

    This is a chat message.

    +
    +
    +#{full_onebox_html} +

    This is an inline onebox https://en.wikipedia.org/wiki/Hyperlink.

    + COOKED + ensure + InlineOneboxer.invalidate("https://en.wikipedia.org/wiki/Hyperlink") + end +end diff --git a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb b/plugins/chat/spec/jobs/chat_channel_archive_spec.rb new file mode 100644 index 0000000000..a60c8de55d --- /dev/null +++ b/plugins/chat/spec/jobs/chat_channel_archive_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ChatChannelArchive do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, admin: true) } + fab!(:category) { Fabricate(:category) } + fab!(:chat_archive) do + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: user, + destination_topic_title: "This will be the archive topic", + destination_category_id: category.id, + total_messages: 10, + ) + end + + before { 10.times { Fabricate(:chat_message, chat_channel: chat_channel) } } + + def run_job + described_class.new.execute(chat_channel_archive_id: chat_archive.id) + end + + it "does nothing if the archive is already complete" do + chat_channel.chat_messages.destroy_all + chat_archive.update!(archived_messages: 10) + expect { run_job }.not_to change { Topic.count } + end + + it "does nothing if the archive does not exist" do + chat_archive.destroy + expect { run_job }.not_to change { Topic.count } + end + + it "processes the archive" do + Chat::ChatChannelArchiveService.any_instance.expects(:execute) + run_job + end +end diff --git a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb b/plugins/chat/spec/jobs/chat_channel_delete_spec.rb new file mode 100644 index 0000000000..55176fdd60 --- /dev/null +++ b/plugins/chat/spec/jobs/chat_channel_delete_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +describe Jobs::ChatChannelDelete do + fab!(:chat_channel) { Fabricate(:chat_channel) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + let(:users) { [user1, user2, user3] } + + before do + messages = [] + 20.times do + messages << Fabricate(:chat_message, chat_channel: chat_channel, user: users.sample) + end + @message_ids = messages.map(&:id) + + 10.times { ChatMessageReaction.create(chat_message: messages.sample, user: users.sample) } + + 10.times do + ChatUpload.create( + upload: Fabricate(:upload, user: users.sample), + chat_message: messages.sample, + ) + end + + ChatMention.create( + user: user2, + chat_message: messages.sample, + notification: Fabricate(:notification), + ) + + @incoming_chat_webhook_id = Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) + ChatWebhookEvent.create( + incoming_chat_webhook: @incoming_chat_webhook_id, + chat_message: messages.sample, + ) + + revision_message = messages.sample + Fabricate( + :chat_message_revision, + chat_message: revision_message, + old_message: "some old message", + new_message: revision_message.message, + ) + + ChatDraft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") + + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user1) + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user2) + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user3) + + chat_channel.trash! + end + + it "deletes all of the messages and related records completely" do + expect { described_class.new.execute(chat_channel_id: chat_channel.id) }.to change { + IncomingChatWebhook.where(chat_channel_id: chat_channel.id).count + }.by(-1).and change { + ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count + }.by(-1).and change { ChatDraft.where(chat_channel: chat_channel).count }.by( + -1, + ).and change { + UserChatChannelMembership.where(chat_channel: chat_channel).count + }.by(-3).and change { + ChatMessageRevision.where(chat_message_id: @message_ids).count + }.by(-1).and change { + ChatMention.where(chat_message_id: @message_ids).count + }.by(-1).and change { + ChatUpload.where(chat_message_id: @message_ids).count + }.by(-10).and change { + ChatMessage.where(id: @message_ids).count + }.by(-20).and change { + ChatMessageReaction.where( + chat_message_id: @message_ids, + ).count + }.by(-10) + end +end diff --git a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb b/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb new file mode 100644 index 0000000000..6aafe1984a --- /dev/null +++ b/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::DeleteOldChatMessages do + base_date = DateTime.parse("2020-12-01 00:00 UTC") + + fab!(:public_channel) { Fabricate(:category_channel) } + fab!(:public_days_old_0) do + Fabricate(:chat_message, chat_channel: public_channel, message: "hi", created_at: base_date) + end + fab!(:public_days_old_10) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 10.days - 1.second, + ) + end + fab!(:public_days_old_20) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 20.days - 1.second, + ) + end + fab!(:public_days_old_30) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + fab!(:public_trashed_days_old_30) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + + fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [Fabricate(:user)]) } + fab!(:dm_days_old_0) do + Fabricate(:chat_message, chat_channel: dm_channel, message: "hi", created_at: base_date) + end + fab!(:dm_days_old_10) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 10.days - 1.second, + ) + end + fab!(:dm_days_old_20) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 20.days - 1.second, + ) + end + fab!(:dm_days_old_30) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + fab!(:dm_trashed_days_old_30) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + + before { freeze_time(base_date) } + + it "doesn't delete messages when settings are 0" do + SiteSetting.chat_channel_retention_days = 0 + SiteSetting.chat_dm_retention_days = 0 + + expect { described_class.new.execute }.not_to change { ChatMessage.count } + end + + describe "public channels" do + it "deletes public messages correctly" do + SiteSetting.chat_channel_retention_days = 20 + described_class.new.execute + expect(public_days_old_0.deleted_at).to be_nil + expect(public_days_old_10.deleted_at).to be_nil + expect { public_days_old_20 }.to raise_exception(ActiveRecord::RecordNotFound) + expect { public_days_old_30 }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "deletes trashed messages correctly" do + SiteSetting.chat_channel_retention_days = 20 + public_trashed_days_old_30.trash! + described_class.new.execute + expect { public_trashed_days_old_30.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "does nothing when no messages fall in the time range" do + SiteSetting.chat_channel_retention_days = 800 + expect { described_class.new.execute }.not_to change { ChatMessage.in_public_channel.count } + end + + it "resets last_read_message_id from memberships" do + SiteSetting.chat_channel_retention_days = 20 + membership = + UserChatChannelMembership.create!( + user: Fabricate(:user), + chat_channel: public_channel, + last_read_message_id: public_days_old_30.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + described_class.new.execute + + expect(membership.reload.last_read_message_id).to be_nil + end + + it "deletes flags associated to deleted chat messages" do + SiteSetting.chat_channel_retention_days = 10 + guardian = Guardian.new(Discourse.system_user) + Chat::ChatReviewQueue.new.flag_message( + public_days_old_20, + guardian, + ReviewableScore.types[:off_topic], + ) + + reviewable = ReviewableChatMessage.last + expect(reviewable).to be_present + + described_class.new.execute + + expect { public_days_old_20.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { reviewable.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + + describe "dm channels" do + it "deletes public messages correctly" do + SiteSetting.chat_dm_retention_days = 20 + described_class.new.execute + expect(dm_days_old_0.deleted_at).to be_nil + expect(dm_days_old_10.deleted_at).to be_nil + expect { dm_days_old_20 }.to raise_exception(ActiveRecord::RecordNotFound) + expect { dm_days_old_30 }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "deletes trashed messages correctly" do + SiteSetting.chat_dm_retention_days = 20 + dm_trashed_days_old_30.trash! + described_class.new.execute + expect { dm_trashed_days_old_30.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "does nothing when no messages fall in the time range" do + SiteSetting.chat_dm_retention_days = 800 + expect { described_class.new.execute }.not_to change { ChatMessage.in_dm_channel.count } + end + + it "resets last_read_message_id from memberships" do + SiteSetting.chat_dm_retention_days = 20 + membership = + UserChatChannelMembership.create!( + user: Fabricate(:user), + chat_channel: dm_channel, + last_read_message_id: dm_days_old_30.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + described_class.new.execute + + expect(membership.reload.last_read_message_id).to be_nil + end + end +end diff --git a/plugins/chat/spec/jobs/process_chat_message_spec.rb b/plugins/chat/spec/jobs/process_chat_message_spec.rb new file mode 100644 index 0000000000..cb98c286af --- /dev/null +++ b/plugins/chat/spec/jobs/process_chat_message_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ProcessChatMessage do + fab!(:chat_message) { Fabricate(:chat_message, message: "https://discourse.org/team") } + + it "updates cooked with oneboxes" do + stub_request(:get, "https://discourse.org/team").to_return( + status: 200, + body: "a", + ) + + stub_request(:head, "https://discourse.org/team").to_return(status: 200) + + described_class.new.execute(chat_message_id: chat_message.id) + expect(chat_message.reload.cooked).to eq( + "

    https://discourse.org/team

    ", + ) + end + + context "when is_dirty args is true" do + fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } + + it "publishes the update" do + ChatPublisher.expects(:publish_processed!).once + described_class.new.execute(chat_message_id: chat_message.id, is_dirty: true) + end + end + + context "when is_dirty args is not true" do + fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } + + it "doesn’t publish the update" do + ChatPublisher.expects(:publish_processed!).never + described_class.new.execute(chat_message_id: chat_message.id) + end + + context "when the cooked message changed" do + it "publishes the update" do + chat_message.update!(cooked: "another lovely cat") + ChatPublisher.expects(:publish_processed!).once + described_class.new.execute(chat_message_id: chat_message.id) + end + end + end + + it "does not error when message is deleted" do + chat_message.destroy + expect { described_class.new.execute(chat_message_id: chat_message.id) }.not_to raise_exception + end +end diff --git a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb new file mode 100644 index 0000000000..568b94e8da --- /dev/null +++ b/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoJoinChannelBatch do + describe "#execute" do + fab!(:category) { Fabricate(:category) } + let!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: category) } + + it "joins all valid users in the batch" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "doesn't join users outside the batch" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, another_user) + end + + it "doesn't join suspended users" do + user.update!(suspended_till: 1.year.from_now) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "doesn't join users last_seen more than 3 months ago" do + user.update!(last_seen_at: 4.months.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "joins users with last_seen set to null" do + user.update!(last_seen_at: nil) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "does nothing if the channel is invalid" do + subject.execute(chat_channel_id: -1, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "does nothing if the channel chatable is not a category" do + direct_message = Fabricate(:direct_message) + channel.update!(chatable: direct_message) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "enqueues the user count update job and marks the channel user count as stale" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel.id }) + + expect(channel.reload.user_count_stale).to eq(true) + end + + it "does not enqueue the user count update job or mark the channel user count as stale when there is more than use user" do + user_2 = Fabricate(:user) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: channel.id, + }, + ) { subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) } + + expect(channel.reload.user_count_stale).to eq(false) + end + + it "ignores users without chat_enabled" do + user.user_option.update!(chat_enabled: false) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "sets the join reason to automatic" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(new_membership.automatic?).to eq(true) + end + + it "skips anonymous users" do + user_2 = Fabricate(:anonymous) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "skips non-active users" do + user_2 = Fabricate(:user, active: false, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "skips staged users" do + user_2 = Fabricate(:user, staged: true, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "adds every user in the batch" do + user_2 = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user, user_2]) + end + + it "publishes a message only to joined users" do + messages = + MessageBus.track_publish("/chat/new-channel") do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + end + + expect(messages.size).to eq(1) + expect(messages.first.data.dig(:chat_channel, :id)).to eq(channel.id) + end + + describe "context when the channel's category is read restricted" do + fab!(:chatters_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: chatters_group) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: private_category) } + + before { chatters_group.add(user) } + + it "only joins group members with access to the category" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: another_user.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, another_user) + end + + it "works if the user has access through more than one group" do + second_chatters_group = Fabricate(:group) + Fabricate(:category_group, category: category, group: second_chatters_group) + second_chatters_group.add(user) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "joins every user with access to the category" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + chatters_group.add(another_user) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: another_user.id) + + assert_users_follows_channel(channel, [user, another_user]) + end + end + end + + def assert_users_follows_channel(channel, users) + new_memberships = UserChatChannelMembership.where(user: users, chat_channel: channel) + expect(new_memberships.all?(&:following)).to eq(true) + end + + def assert_user_skipped(channel, user) + new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(new_membership).to be_nil + end +end diff --git a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb b/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb new file mode 100644 index 0000000000..b89e7f2c1f --- /dev/null +++ b/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoManageChannelMemberships do + let(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let(:category) { Fabricate(:category, user: user) } + let(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) } + + describe "queues batches to automatically add users to a channel" do + it "queues a batch for users with channel access" do + assert_batches_enqueued(channel, 1) + end + + it "does nothing when the channel doesn't exist" do + assert_batches_enqueued(ChatChannel.new(id: -1), 0) + end + + it "does nothing when the chatable is not a category" do + direct_message = Fabricate(:direct_message) + channel.update!(chatable: direct_message) + + assert_batches_enqueued(channel, 0) + end + + it "excludes users not seen in the last 3 months" do + user.update!(last_seen_at: 3.months.ago) + + assert_batches_enqueued(channel, 0) + end + + it "excludes users without chat enabled" do + user.user_option.update!(chat_enabled: false) + + assert_batches_enqueued(channel, 0) + end + + it "respects the max_chat_auto_joined_users setting" do + SiteSetting.max_chat_auto_joined_users = 0 + + assert_batches_enqueued(channel, 0) + end + + it "does nothing when we already reached the max_chat_auto_joined_users limit" do + SiteSetting.max_chat_auto_joined_users = 1 + user_2 = Fabricate(:user, last_seen_at: 2.minutes.ago) + UserChatChannelMembership.create!( + user: user_2, + chat_channel: channel, + following: true, + join_mode: UserChatChannelMembership.join_modes[:automatic], + ) + + assert_batches_enqueued(channel, 0) + end + + it "ignores users that are already channel members" do + UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) + + assert_batches_enqueued(channel, 0) + end + + it "doesn't queue a batch when the user doesn't follow the channel" do + UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) + + assert_batches_enqueued(channel, 0) + end + + it "skips non-active users" do + user.update!(active: false) + + assert_batches_enqueued(channel, 0) + end + + it "skips suspended users" do + user.update!(suspended_till: 3.years.from_now) + + assert_batches_enqueued(channel, 0) + end + + it "skips staged users" do + user.update!(staged: true) + + assert_batches_enqueued(channel, 0) + end + + context "when the category has read restricted access" do + fab!(:chatters_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: chatters_group) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: private_category) } + + it "doesn't queue a batch if the user is not a group member" do + assert_batches_enqueued(channel, 0) + end + + context "when the user has category access to a group" do + before { chatters_group.add(user) } + + it "queues a batch" do + assert_batches_enqueued(channel, 1) + end + end + end + + context "when chatable doesn’t exist anymore" do + before do + channel.chatable.destroy! + channel.reload + end + + it "does nothing" do + assert_batches_enqueued(channel, 0) + end + end + end + + def assert_batches_enqueued(channel, expected) + expect { subject.execute(chat_channel_id: channel.id) }.to change( + Jobs::AutoJoinChannelBatch.jobs, + :size, + ).by(expected) + end +end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb b/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb new file mode 100644 index 0000000000..32204adc9c --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ChatNotifyMentioned do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:public_channel) { Fabricate(:category_channel) } + + before do + Group.refresh_automatic_groups! + user_1.reload + user_2.reload + + @chat_group = Fabricate(:group, users: [user_1, user_2]) + @personal_chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: user_1, target_users: [user_1, user_2]) + + [user_1, user_2].each do |u| + Fabricate(:user_chat_channel_membership, chat_channel: public_channel, user: u) + end + end + + def create_chat_message(channel: public_channel, user: user_1) + Fabricate(:chat_message, chat_channel: channel, user: user, created_at: 10.minutes.ago) + end + + def track_desktop_notification( + user: user_2, + message:, + to_notify_ids_map:, + already_notified_user_ids: [] + ) + MessageBus + .track_publish("/chat/notification-alert/#{user.id}") do + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + already_notified_user_ids: already_notified_user_ids, + ) + end + .first + end + + def track_core_notification(user: user_2, message:, to_notify_ids_map:) + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + + Notification.where(user: user, notification_type: Notification.types[:chat_mention]).last + end + + describe "scenarios where we should skip sending notifications" do + let(:to_notify_ids_map) { { here_mentions: [user_2.id] } } + + it "does nothing if there is a newer version of the message" do + message = create_chat_message + Fabricate(:chat_message_revision, chat_message: message, old_message: "a", new_message: "b") + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing when user is not following the channel" do + message = create_chat_message + + UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( + following: false, + ) + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing when user doesn't have a membership record" do + message = create_chat_message + + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing if user is included in the already_notified_user_ids" do + message = create_chat_message + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification( + message: message, + to_notify_ids_map: to_notify_ids_map, + already_notified_user_ids: [user_2.id], + ) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing if user is not participating in a private channel" do + user_3 = Fabricate(:user) + @chat_group.add(user_3) + to_notify_map = { direct_mentions: [user_3.id] } + + message = create_chat_message(channel: @personal_chat_channel) + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_3, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "skips desktop notifications based on user preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_nil + end + + it "skips push notifications based on user preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + PostAlerter.expects(:push_notification).never + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + + it "skips desktop notifications based on user muting preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + muted: true, + ) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_nil + end + + it "skips push notifications based on user muting preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + muted: true, + ) + + PostAlerter.expects(:push_notification).never + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + end + + shared_examples "creates different notifications with basic data" do + let(:expected_channel_title) { public_channel.title(user_2) } + + it "works for desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_present + expect(desktop_notification.data[:notification_type]).to eq(Notification.types[:chat_mention]) + expect(desktop_notification.data[:username]).to eq(user_1.username) + expect(desktop_notification.data[:tag]).to eq( + Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + ) + expect(desktop_notification.data[:excerpt]).to eq(message.push_notification_excerpt) + expect(desktop_notification.data[:post_url]).to eq( + "/chat/channel/#{public_channel.id}/#{public_channel.slug}?messageId=#{message.id}", + ) + end + + it "works for push notifications" do + message = create_chat_message + + PostAlerter.expects(:push_notification).with( + user_2, + { + notification_type: Notification.types[:chat_mention], + username: user_1.username, + tag: Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + excerpt: message.push_notification_excerpt, + post_url: + "/chat/channel/#{public_channel.id}/#{public_channel.slug}?messageId=#{message.id}", + translated_title: payload_translated_title, + }, + ) + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + + it "works for core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(created_notification).to be_present + expect(created_notification.high_priority).to eq(true) + expect(created_notification.read).to eq(false) + + data_hash = created_notification.data_hash + + expect(data_hash[:chat_message_id]).to eq(message.id) + expect(data_hash[:chat_channel_id]).to eq(public_channel.id) + expect(data_hash[:mentioned_by_username]).to eq(user_1.username) + expect(data_hash[:is_direct_message_channel]).to eq(false) + expect(data_hash[:chat_channel_title]).to eq(expected_channel_title) + expect(data_hash[:chat_channel_slug]).to eq(public_channel.slug) + + chat_mention = + ChatMention.where(notification: created_notification, user: user_2, chat_message: message) + expect(chat_mention).to be_present + end + end + + describe "#execute" do + describe "global mention notifications" do + let(:to_notify_ids_map) { { global_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@all", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes global mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to eq("all") + end + + it "includes global mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@all", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "here mention notifications" do + let(:to_notify_ids_map) { { here_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@here", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to eq("here") + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@here", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "direct mention notifications" do + let(:to_notify_ids_map) { { direct_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.direct", + username: user_1.username, + identifier: "", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to be_nil + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.direct", + username: user_1.username, + identifier: "", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "group mentions" do + let(:to_notify_ids_map) { { @chat_group.name.to_sym => [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@#{@chat_group.name}", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to eq(@chat_group.name) + expect(data_hash[:is_group_mention]).to eq(true) + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@#{@chat_group.name}", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + end +end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb b/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb new file mode 100644 index 0000000000..1690c3b018 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::ChatNotifyWatching do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:group) { Fabricate(:group) } + let(:except_user_ids) { [] } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + def run_job + described_class.new.execute(chat_message_id: message.id, except_user_ids: except_user_ids) + end + + def notification_messages_for(user) + MessageBus + .track_publish { run_job } + .filter { |m| m.channel == "/chat/notification-alert/#{user.id}" } + end + + context "for a category channel" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel) + end + fab!(:message) do + Fabricate(:chat_message, chat_channel: channel, user: user1, message: "this is a new message") + end + + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a desktop notification" do + messages = notification_messages_for(user2) + + expect(messages.first.data).to include( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: channel.relative_url, + translated_title: + I18n.t( + "discourse_push_notifications.popup.new_chat_message", + { username: user1.username, channel: channel.title(user2) }, + ), + tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + excerpt: message.message, + }, + ) + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + + context "when mobile_notification_level is always and desktop_notification_level is none" do + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a mobile notification" do + PostAlerter.expects(:push_notification).with( + user2, + has_entries( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: channel.relative_url, + translated_title: + I18n.t( + "discourse_push_notifications.popup.new_chat_message", + { username: user1.username, channel: channel.title(user2) }, + ), + tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + excerpt: message.message, + }, + ), + ) + messages = notification_messages_for(user2) + expect(messages.length).to be_zero + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + end + + context "when the target user cannot chat" do + before { SiteSetting.chat_allowed_groups = group.id } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user cannot see the chat channel" do + before { channel.update!(chatable: Fabricate(:private_category, group: group)) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user has seen the message already" do + before { membership2.update!(last_read_message_id: message.id) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is online via presence channel" do + before { PresenceChannel.any_instance.expects(:user_ids).returns([user2.id]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is suspended" do + before { user2.update!(suspended_till: 1.year.from_now) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is inside the except_user_ids array" do + let(:except_user_ids) { [user2.id] } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + end + + context "for a direct message channel" do + fab!(:channel) { Fabricate(:direct_message_channel, users: [user1, user2, user3]) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel) + end + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: user1) } + + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a desktop notification" do + messages = notification_messages_for(user2) + + expect(messages.first.data).to include( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: channel.relative_url, + translated_title: + I18n.t( + "discourse_push_notifications.popup.new_direct_chat_message", + { username: user1.username, channel: channel.title(user2) }, + ), + tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + excerpt: message.message, + }, + ) + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + + context "when mobile_notification_level is always and desktop_notification_level is none" do + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a mobile notification" do + PostAlerter.expects(:push_notification).with( + user2, + has_entries( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: channel.relative_url, + translated_title: + I18n.t( + "discourse_push_notifications.popup.new_direct_chat_message", + { username: user1.username, channel: channel.title(user2) }, + ), + tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + excerpt: message.message, + }, + ), + ) + messages = notification_messages_for(user2) + expect(messages.length).to be_zero + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + end + + context "when the target user cannot chat" do + before { SiteSetting.chat_allowed_groups = group.id } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user cannot see the chat channel" do + before { membership2.destroy! } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user has seen the message already" do + before { membership2.update!(last_read_message_id: message.id) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is online via presence channel" do + before { PresenceChannel.any_instance.expects(:user_ids).returns([user2.id]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is suspended" do + before { user2.update!(suspended_till: 1.year.from_now) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is inside the except_user_ids array" do + let(:except_user_ids) { [user2.id] } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is preventing communication from the message creator" do + before { UserCommScreener.any_instance.expects(:allowing_actor_communication).returns([]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + end +end diff --git a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb b/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb new file mode 100644 index 0000000000..6674e53b9e --- /dev/null +++ b/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::UpdateChannelUserCount do + fab!(:channel) { Fabricate(:category_channel, user_count: 0, user_count_stale: true) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user2) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user3) + end + + it "does nothing if the channel does not exist" do + channel.destroy + ChatPublisher.expects(:publish_chat_channel_metadata).never + expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) + end + + it "does nothing if the user count has not been marked stale" do + channel.update!(user_count_stale: false) + ChatPublisher.expects(:publish_chat_channel_metadata).never + expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) + end + + it "updates the channel user_count and sets user_count_stale back to false" do + ChatPublisher.expects(:publish_chat_channel_metadata).with(channel) + described_class.new.execute(chat_channel_id: channel.id) + channel.reload + expect(channel.user_count).to eq(3) + expect(channel.user_count_stale).to eq(false) + end +end diff --git a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb new file mode 100644 index 0000000000..e420b0a8a5 --- /dev/null +++ b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoJoinUsers do + it "works" do + Jobs.run_immediately! + channel = Fabricate(:category_channel, auto_join_users: true) + user = Fabricate(:user, last_seen_at: 1.minute.ago, active: true) + + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership).to be_nil + + subject.execute({}) + + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership.following).to eq(true) + end +end diff --git a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb b/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb new file mode 100644 index 0000000000..0741a655c3 --- /dev/null +++ b/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe Jobs::EmailChatNotifications do + before { Jobs.run_immediately! } + + context "when chat is enabled" do + before { SiteSetting.chat_enabled = true } + + it "starts the mailer" do + Chat::ChatMailer.expects(:send_unread_mentions_summary) + + Jobs.enqueue(:email_chat_notifications) + end + end + + context "when chat is not enabled" do + it "does nothing" do + Chat::ChatMailer.expects(:send_unread_mentions_summary).never + + Jobs.enqueue(:email_chat_notifications) + end + end +end diff --git a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb b/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb new file mode 100644 index 0000000000..753b7b7bdf --- /dev/null +++ b/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::UpdateUserCountsForChatChannels do + fab!(:chat_channel_1) { Fabricate(:category_channel, user_count: 0) } + fab!(:chat_channel_2) { Fabricate(:category_channel, user_count: 0) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:user_4) { Fabricate(:user) } + + def create_memberships + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: false) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + end + + it "sets the user_count correctly for each chat channel" do + create_memberships + + Jobs::UpdateUserCountsForChatChannels.new.execute + + expect(chat_channel_1.reload.user_count).to eq(2) + expect(chat_channel_2.reload.user_count).to eq(3) + end + + it "does not count suspended, non-activated, nor staged users" do + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_2.update(suspended_till: 3.weeks.from_now) + user_3.update(staged: true) + user_4.update(active: false) + + Jobs::UpdateUserCountsForChatChannels.new.execute + + expect(chat_channel_1.reload.user_count).to eq(1) + expect(chat_channel_2.reload.user_count).to eq(0) + end + + it "does not count archived, or read_only channels" do + create_memberships + + chat_channel_1.update!(status: :archived) + Jobs::UpdateUserCountsForChatChannels.new.execute + expect(chat_channel_1.reload.user_count).to eq(0) + + chat_channel_1.update!(status: :read_only) + Jobs::UpdateUserCountsForChatChannels.new.execute + expect(chat_channel_1.reload.user_count).to eq(0) + end +end diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb new file mode 100644 index 0000000000..5c4c97c392 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatChannelArchiveService do + class FakeArchiveError < StandardError + end + + fab!(:channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, admin: true) } + fab!(:category) { Fabricate(:category) } + let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } + subject { Chat::ChatChannelArchiveService } + + describe "#begin_archive_process" do + before { 3.times { Fabricate(:chat_message, chat_channel: channel) } } + + it "marks the channel as read_only" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect(channel.reload.status).to eq("read_only") + end + + it "creates the chat channel archive record to save progress and topic params" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect(channel_archive.archived_by).to eq(user) + expect(channel_archive.destination_topic_title).to eq("This will be a new topic") + expect(channel_archive.destination_category_id).to eq(category.id) + expect(channel_archive.total_messages).to eq(3) + expect(channel_archive.archived_messages).to eq(0) + end + + it "enqueues the archive job" do + channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + end + + it "does nothing if there is already an archive record for the channel" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect { + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + }.not_to change { ChatChannelArchive.count } + end + + it "does not count already deleted messages toward the archive total" do + new_message = Fabricate(:chat_message, chat_channel: channel) + new_message.trash! + channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect(channel_archive.total_messages).to eq(3) + end + end + + describe "#execute" do + def create_messages(num) + num.times { Fabricate(:chat_message, chat_channel: channel) } + end + + def start_archive + @channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + end + + context "when archiving to a new topic" do + let(:topic_params) do + { topic_title: "This will be a new topic", category_id: category.id, tags: %w[news gossip] } + end + + it "makes a topic, deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do + create_messages(50) && start_archive + reaction_message = ChatMessage.last + ChatMessageReaction.create!( + chat_message: reaction_message, + user: Fabricate(:user), + emoji: "+1", + ) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.destination_topic.title).to eq("This will be a new topic") + expect(@channel_archive.destination_topic.category).to eq(category) + expect(@channel_archive.destination_topic.user).to eq(Discourse.system_user) + expect(@channel_archive.destination_topic.tags.map(&:name)).to match_array(%w[news gossip]) + + topic = @channel_archive.destination_topic + expect(topic.posts.count).to eq(11) + topic + .posts + .where.not(post_number: 1) + .each do |post| + expect(post.raw).to include("[chat") + expect(post.raw).to include("noLink=\"true\"") + expect(post.user).to eq(Discourse.system_user) + + if post.raw.include?(";#{reaction_message.id};") + expect(post.raw).to include("reactions=") + end + end + expect(topic.archived).to eq(true) + + expect(@channel_archive.archived_messages).to eq(50) + expect(@channel_archive.chat_channel.status).to eq("archived") + expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) + end + + it "does not stop the process if the post length is too high (validations disabled)" do + create_messages(50) && start_archive + SiteSetting.max_post_length = 1 + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + end + + it "successfully links uploads from messages to the post" do + create_messages(3) && start_archive + ChatUpload.create(chat_message: ChatMessage.last, upload: Fabricate(:upload)) + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect(@channel_archive.destination_topic.posts.last.upload_references.count).to eq(1) + end + + it "successfully sends a private message to the archiving user" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + pm_topic = Topic.private_messages.last + expect(pm_topic.topic_allowed_users.first.user).to eq(@channel_archive.archived_by) + expect(pm_topic.title).to eq( + I18n.t("system_messages.chat_channel_archive_complete.subject_template"), + ) + end + + describe "channel members" do + before do + create_messages(3) + channel + .chat_messages + .map(&:user) + .each do |user| + UserChatChannelMembership.create!(chat_channel: channel, user: user, following: true) + end + end + + it "unfollows (leaves) the channel for all users" do + expect( + UserChatChannelMembership.where(chat_channel: channel, following: true).count, + ).to eq(3) + start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect( + UserChatChannelMembership.where(chat_channel: channel, following: true).count, + ).to eq(0) + end + + it "resets unread state for all users" do + UserChatChannelMembership.last.update!( + last_read_message_id: channel.chat_messages.first.id, + ) + start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect(UserChatChannelMembership.last.last_read_message_id).to eq( + channel.chat_messages.last.id, + ) + end + end + + describe "chat_archive_destination_topic_status setting" do + context "when set to archived" do + before { SiteSetting.chat_archive_destination_topic_status = "archived" } + + it "archives the topic" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(true) + end + end + + context "when set to open" do + before { SiteSetting.chat_archive_destination_topic_status = "open" } + + it "leaves the topic open" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.open?).to eq(true) + end + end + + context "when set to closed" do + before { SiteSetting.chat_archive_destination_topic_status = "closed" } + + it "closes the topic" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.closed?).to eq(true) + end + end + + context "when archiving to an existing topic" do + it "does not change the status of the topic" do + create_messages(3) && start_archive + @channel_archive.update( + destination_topic_title: nil, + destination_topic_id: Fabricate(:topic).id, + ) + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.closed?).to eq(false) + end + end + end + end + + context "when archiving to an existing topic" do + fab!(:topic) { Fabricate(:topic) } + let(:topic_params) { { topic_id: topic.id } } + + before { 3.times { Fabricate(:post, topic: topic) } } + + it "deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do + create_messages(50) && start_archive + reaction_message = ChatMessage.last + ChatMessageReaction.create!( + chat_message: reaction_message, + user: Fabricate(:user), + emoji: "+1", + ) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.destination_topic.title).to eq(topic.title) + expect(@channel_archive.destination_topic.category).to eq(topic.category) + expect(@channel_archive.destination_topic.user).to eq(topic.user) + + topic = @channel_archive.destination_topic + + # existing posts + 10 archive posts + expect(topic.posts.count).to eq(13) + topic + .posts + .where.not(post_number: [1, 2, 3]) + .each do |post| + expect(post.raw).to include("[chat") + expect(post.raw).to include("noLink=\"true\"") + expect(post.user).to eq(Discourse.system_user) + + if post.raw.include?(";#{reaction_message.id};") + expect(post.raw).to include("reactions=") + end + end + expect(topic.archived).to eq(false) + + expect(@channel_archive.archived_messages).to eq(50) + expect(@channel_archive.chat_channel.status).to eq("archived") + expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) + end + + it "handles errors gracefully, sends a private message to the archiving user, and is idempotent on retry" do + Rails.logger = @fake_logger = FakeLogger.new + create_messages(35) && start_archive + + Chat::ChatChannelArchiveService + .any_instance + .stubs(:create_post) + .raises(FakeArchiveError.new("this is a test error")) + + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + expect { subject.new(@channel_archive).execute }.to raise_error(FakeArchiveError) + end + + expect(@channel_archive.reload.archive_error).to eq("this is a test error") + + pm_topic = Topic.private_messages.last + expect(pm_topic.topic_allowed_users.first.user).to eq(@channel_archive.archived_by) + expect(pm_topic.title).to eq( + I18n.t("system_messages.chat_channel_archive_failed.subject_template"), + ) + + Chat::ChatChannelArchiveService.any_instance.unstub(:create_post) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.archive_error).to eq(nil) + expect(@channel_archive.archived_messages).to eq(35) + expect(@channel_archive.complete?).to eq(true) + # existing posts + 7 archive posts + expect(topic.posts.count).to eq(10) + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb new file mode 100644 index 0000000000..5a51d999d7 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +describe Chat::ChatChannelFetcher do + fab!(:category) { Fabricate(:category, name: "support") } + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:category_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_channel1) { Fabricate(:direct_message) } + fab!(:dm_channel2) { Fabricate(:direct_message) } + fab!(:direct_message_channel1) { Fabricate(:direct_message_channel, chatable: dm_channel1) } + fab!(:direct_message_channel2) { Fabricate(:direct_message_channel, chatable: dm_channel2) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + def guardian + Guardian.new(user1) + end + + def memberships + UserChatChannelMembership.where(user: user1) + end + + describe ".structured" do + it "returns open channel only" do + category_channel.user_chat_channel_memberships.create!(user: user1, following: true) + + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to contain_exactly(category_channel) + + category_channel.closed!(Discourse.system_user) + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to be_blank + end + + it "returns followed channel only" do + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to be_blank + + category_channel.user_chat_channel_memberships.create!(user: user1, following: true) + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to contain_exactly(category_channel) + end + end + + describe ".unread_counts" do + context "when user is member of the channel" do + before do + Fabricate(:user_chat_channel_membership, chat_channel: category_channel, user: user1) + end + + context "with unread messages" do + before do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + Fabricate(:chat_message, chat_channel: category_channel, message: "bonjour", user: user2) + end + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(2) + end + end + + context "with no unread messages" do + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + + context "when last unread message has been deleted" do + fab!(:last_unread) do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + end + + before { last_unread.update!(deleted_at: Time.zone.now) } + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + end + + context "when user is not member of the channel" do + context "when the channel has new messages" do + before do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + end + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + end + end + + describe ".all_secured_channel_ids" do + it "returns nothing by default if the user has no memberships" do + expect(subject.all_secured_channel_ids(guardian)).to eq([]) + end + + context "when the user has memberships to all the channels" do + before do + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + UserChatChannelMembership.create!( + user: user1, + chat_channel: direct_message_channel1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "returns category channel because they are public by default" do + expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + end + + it "returns all the channels if the user is a member of the DM channel also" do + DirectMessageUser.create!(user: user1, direct_message: dm_channel1) + expect(subject.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id, direct_message_channel1.id], + ) + end + + it "does not include the category channel if the category is a private category the user cannot see" do + category_channel.update!(chatable: private_category) + expect(subject.all_secured_channel_ids(guardian)).to be_empty + GroupUser.create!(group: private_category.groups.last, user: user1) + expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + end + end + end + + describe "#secured_public_channels" do + let(:following) { false } + + it "does not include DM channels" do + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by channel name, or category name" do + expect( + subject.secured_public_channels( + guardian, + memberships, + following: following, + filter: "support", + ).map(&:id), + ).to match_array([category_channel.id]) + + category_channel.update!(name: "cool stuff") + + expect( + subject.secured_public_channels( + guardian, + memberships, + following: following, + filter: "cool stuff", + ).map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by status" do + expect( + subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + ).to match_array([]) + + category_channel.closed!(Discourse.system_user) + + expect( + subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by following" do + expect( + subject.secured_public_channels(guardian, memberships, following: true).map(&:id), + ).to be_blank + end + + it "can filter by not following" do + category_channel.user_chat_channel_memberships.create!(user: user1, following: false) + another_channel = Fabricate(:category_channel) + + expect( + subject.secured_public_channels(guardian, memberships, following: false).map(&:id), + ).to match_array([category_channel.id, another_channel.id]) + end + + it "ensures offset is >= 0" do + expect( + subject.secured_public_channels(guardian, memberships, offset: -235).map(&:id), + ).to match_array([category_channel.id]) + end + + it "ensures limit is > 0" do + expect( + subject.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map(&:id), + ).to match_array([category_channel.id]) + end + + it "ensures limit has a max value" do + over_limit = Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 + over_limit.times { Fabricate(:category_channel) } + + expect( + subject.secured_public_channels(guardian, memberships, limit: over_limit).length, + ).to eq(Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) + end + + it "does not show the user category channels they cannot access" do + category_channel.update!(chatable: private_category) + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to be_empty + end + + context "when scoping to the user's channel memberships" do + let(:following) { true } + + it "only returns channels where the user is a member and is following the channel" do + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to be_empty + + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to match_array([category_channel.id]) + end + + it "includes the unread count based on mute settings" do + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + + Fabricate(:chat_message, user: user2, chat_channel: category_channel) + Fabricate(:chat_message, user: user2, chat_channel: category_channel) + + resolved_memberships = memberships + subject.secured_public_channels(guardian, resolved_memberships, following: following) + + expect( + resolved_memberships + .find { |membership| membership.chat_channel_id == category_channel.id } + .unread_count, + ).to eq(2) + + resolved_memberships.last.update!(muted: true) + + resolved_memberships = memberships + subject.secured_public_channels(guardian, resolved_memberships, following: following) + + expect( + resolved_memberships + .find { |membership| membership.chat_channel_id == category_channel.id } + .unread_count, + ).to eq(0) + end + end + end + + describe "#secured_direct_message_channels" do + it "includes direct message channels the user is a member of ordered by last_message_sent_at" do + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel2, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message: dm_channel2, user: user1) + DirectMessageUser.create!(direct_message: dm_channel2, user: user2) + + direct_message_channel1.update!(last_message_sent_at: 1.day.ago) + direct_message_channel2.update!(last_message_sent_at: 1.hour.ago) + + expect( + subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + ).to eq([direct_message_channel2.id, direct_message_channel1.id]) + end + + it "does not include direct message channels where the user is a member but not a direct_message_user" do + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + + expect( + subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + ).not_to include(direct_message_channel1.id) + end + + it "includes the unread count based on mute settings for the user's channel membership" do + membership = + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + + Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) + Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) + resolved_memberships = memberships + + subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + target_membership = + resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } + expect(target_membership.unread_count).to eq(2) + + resolved_memberships = memberships + target_membership = + resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } + target_membership.update!(muted: true) + subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + expect(target_membership.unread_count).to eq(0) + end + end + + describe ".find_with_access_check" do + it "raises NotFound if the channel does not exist" do + category_channel.destroy! + expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( + Discourse::NotFound, + ) + end + + it "raises InvalidAccess if the user cannot see the channel" do + category_channel.update!(chatable: private_category) + expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( + Discourse::InvalidAccess, + ) + end + + it "returns the chat channel if it is found and accessible" do + expect(subject.find_with_access_check(category_channel.id, guardian)).to eq(category_channel) + end + end +end diff --git a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb b/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb new file mode 100644 index 0000000000..f5c9c69479 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Chat::ChatChannelMembershipManager do + fab!(:user) { Fabricate(:user) } + fab!(:channel1) { Fabricate(:category_channel) } + fab!(:channel2) { Fabricate(:category_channel) } + + describe ".find_for_user" do + let!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel1, following: true) + end + + it "returns nil if it cannot find a membership for the user and channel" do + expect(described_class.new(channel2).find_for_user(user)).to be_blank + end + + it "returns the membership for the channel and user" do + membership = described_class.new(channel1).find_for_user(user) + expect(membership.chat_channel_id).to eq(channel1.id) + expect(membership.user_id).to eq(user.id) + expect(membership.following).to eq(true) + end + + it "scopes by following and returns nil if it does not match the scope" do + membership.update!(following: false) + expect(described_class.new(channel1).find_for_user(user, following: true)).to be_blank + end + end + + describe ".follow" do + it "creates a membership if one does not exist for the user and channel already" do + membership = nil + expect { membership = described_class.new(channel1).follow(user) }.to change { + UserChatChannelMembership.count + }.by(1) + expect(membership.following).to eq(true) + expect(membership.chat_channel).to eq(channel1) + expect(membership.user).to eq(user) + end + + it "enqueues user_count recalculation and marks user_count_stale as true" do + described_class.new(channel1).follow(user) + expect(channel1.reload.user_count_stale).to eq(true) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + end + + it "updates the membership to following if it already existed" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: false, + ) + expect { membership = described_class.new(channel1).follow(user) }.not_to change { + UserChatChannelMembership.count + } + expect(membership.reload.following).to eq(true) + end + end + + describe ".unfollow" do + it "does nothing if the user is not following the channel" do + expect(described_class.new(channel2).unfollow(user)).to be_blank + end + + it "updates following for the membership to false and recalculates the user count" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: true, + ) + described_class.new(channel1).unfollow(user) + membership.reload + expect(membership.following).to eq(false) + expect(channel1.reload.user_count_stale).to eq(true) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + end + + it "does not recalculate user count if the user was already not following the channel" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: false, + ) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: channel1.id, + }, + ) { described_class.new(channel1).unfollow(user) } + expect(channel1.reload.user_count_stale).to eq(false) + end + end +end diff --git a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb b/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb new file mode 100644 index 0000000000..a78aa55588 --- /dev/null +++ b/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessageBookmarkable do + fab!(:user) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user) } + fab!(:other_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:category_channel) { Fabricate(:category_channel, chatable: other_category) } + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:channel) { Fabricate(:category_channel) } + + before do + Bookmark.register_bookmarkable(ChatMessageBookmarkable) + UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + end + + let!(:message1) { Fabricate(:chat_message, chat_channel: channel) } + let!(:message2) { Fabricate(:chat_message, chat_channel: channel) } + let!(:bookmark1) do + Fabricate(:bookmark, user: user, bookmarkable: message1, name: "something i gotta do") + end + let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: message2) } + let!(:bookmark3) { Fabricate(:bookmark) } + + subject { RegisteredBookmarkable.new(ChatMessageBookmarkable) } + + describe "#perform_list_query" do + it "returns all the user's bookmarks" do + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for messages inside category chat channels the user cannot access" do + channel.update(chatable: other_category) + expect(subject.perform_list_query(user, guardian)).to eq(nil) + other_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for messages inside direct message chat channels the user cannot access" do + direct_message = Fabricate(:direct_message) + channel.update(chatable: direct_message) + expect(subject.perform_list_query(user, guardian)).to eq(nil) + DirectMessageUser.create(user: user, direct_message: direct_message) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for deleted messages" do + message1.trash! + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array([bookmark2.id]) + end + end + + describe "#perform_search_query" do + before { SearchIndexer.enable } + + it "returns bookmarks that match by name" do + ts_query = Search.ts_query(term: "gotta", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%gotta%", + ts_query, + ).map(&:id), + ).to match_array([bookmark1.id]) + end + + it "returns bookmarks that match by chat message message content" do + message2.update(message: "some good soup") + + ts_query = Search.ts_query(term: "good soup", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%good soup%", + ts_query, + ).map(&:id), + ).to match_array([bookmark2.id]) + + ts_query = Search.ts_query(term: "blah", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%blah%", + ts_query, + ).map(&:id), + ).to eq([]) + end + end + + describe "#can_send_reminder?" do + it "cannot send the reminder if the message or channel is deleted" do + expect(subject.can_send_reminder?(bookmark1)).to eq(true) + bookmark1.bookmarkable.trash! + bookmark1.reload + expect(subject.can_send_reminder?(bookmark1)).to eq(false) + ChatMessage.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! + bookmark1.reload + bookmark1.bookmarkable.chat_channel.trash! + bookmark1.reload + expect(subject.can_send_reminder?(bookmark1)).to eq(false) + end + end + + describe "#reminder_handler" do + it "creates a notification for the user with the correct details" do + expect { subject.send_reminder_notification(bookmark1) }.to change { Notification.count }.by( + 1, + ) + notification = user.notifications.last + expect(notification.notification_type).to eq(Notification.types[:bookmark_reminder]) + expect(notification.data).to eq( + { + title: + I18n.t( + "chat.bookmarkable.notification_title", + channel_name: bookmark1.bookmarkable.chat_channel.title(bookmark1.user), + ), + bookmarkable_url: bookmark1.bookmarkable.url, + display_username: bookmark1.user.username, + bookmark_name: bookmark1.name, + bookmark_id: bookmark1.id, + }.to_json, + ) + end + end + + describe "#can_see?" do + it "returns false if the chat message is in a channel the user cannot see" do + expect(subject.can_see?(guardian, bookmark1)).to eq(true) + bookmark1.bookmarkable.chat_channel.update!(chatable: private_category) + expect(subject.can_see?(guardian, bookmark1)).to eq(false) + private_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.can_see?(guardian, bookmark1)).to eq(true) + end + end + + describe "#validate_before_create" do + it "raises InvalidAccess if the user cannot see the chat channel" do + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + bookmark1.bookmarkable.chat_channel.update!(chatable: private_category) + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.to raise_error( + Discourse::InvalidAccess, + ) + private_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + end + + it "raises InvalidAccess if the chat message is deleted" do + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + bookmark1.bookmarkable.trash! + bookmark1.reload + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.to raise_error( + Discourse::InvalidAccess, + ) + end + end + + describe "#cleanup_deleted" do + it "deletes bookmarks for chat messages deleted more than 3 days ago" do + bookmark_post = Fabricate(:bookmark, bookmarkable: Fabricate(:post)) + bookmark1.bookmarkable.trash! + bookmark1.bookmarkable.update!(deleted_at: 4.days.ago) + subject.cleanup_deleted + expect(Bookmark.exists?(id: bookmark1.id)).to eq(false) + expect(Bookmark.exists?(id: bookmark2.id)).to eq(true) + expect(Bookmark.exists?(id: bookmark_post.id)).to eq(true) + end + end +end diff --git a/plugins/chat/spec/lib/chat_message_reactor_spec.rb b/plugins/chat/spec/lib/chat_message_reactor_spec.rb new file mode 100644 index 0000000000..ef15858637 --- /dev/null +++ b/plugins/chat/spec/lib/chat_message_reactor_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageReactor do + fab!(:reacting_user) { Fabricate(:user) } + fab!(:channel) { Fabricate(:category_channel) } + fab!(:reactor) { described_class.new(reacting_user, channel) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel, user: reacting_user) } + let(:subject) { described_class.new(reacting_user, channel) } + + it "raises an error if the user cannot see the channel" do + channel.update!(chatable: Fabricate(:private_category, group: Group[:staff])) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + end + + it "raises an error if the user cannot react" do + SpamRule::AutoSilence.new(reacting_user).silence_user + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + end + + it "raises an error if the channel status is not open" do + channel.update!(status: ChatChannel.statuses[:archived]) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + channel.update!(status: ChatChannel.statuses[:open]) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to change(ChatMessageReaction, :count).by(1) + end + + it "raises an error if the reaction is not valid" do + expect { + reactor.react!(message_id: message_1.id, react_action: :foo, emoji: ":+1:") + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error if the emoji does not exist" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":woohoo:") + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error if the message is not found" do + expect { + reactor.react!(message_id: -999, react_action: :add, emoji: ":woohoo:") + }.to raise_error(Discourse::InvalidParameters) + end + + context "when max reactions has been reached" do + before do + emojis = Emoji.all.slice(0, Chat::ChatMessageReactor::MAX_REACTIONS_LIMIT) + emojis.each do |emoji| + ChatMessageReaction.create!( + chat_message: message_1, + user: reacting_user, + emoji: ":#{emoji.name}:", + ) + end + end + + it "adding a reaction raises an error" do + expect { + reactor.react!( + message_id: message_1.id, + react_action: :add, + emoji: ":#{Emoji.all.last.name}:", + ) + }.to raise_error(Discourse::InvalidAccess) + end + + it "removing a reaction works" do + expect { + reactor.react!( + message_id: message_1.id, + react_action: :add, + emoji: ":#{Emoji.all.first.name}:", + ) + }.to_not raise_error + end + end + + it "creates a membership when not present" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.to change(UserChatChannelMembership, :count).by(1) + end + + it "doesn’t create a membership when present" do + UserChatChannelMembership.create!(user: reacting_user, chat_channel: channel, following: true) + + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.not_to change(UserChatChannelMembership, :count) + end + + it "can add a reaction" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.to change(ChatMessageReaction, :count).by(1) + end + + it "doesn’t duplicate reactions" do + ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.not_to change(ChatMessageReaction, :count) + end + + it "can remove an existing reaction" do + ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + + expect { + reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") + }.to change(ChatMessageReaction, :count).by(-1) + end + + it "does nothing when removing if no reaction found" do + expect { + reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") + }.not_to change(ChatMessageReaction, :count) + end + + it "publishes the reaction" do + ChatPublisher.expects(:publish_reaction!).once + + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + end +end diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat_notifier_spec.rb new file mode 100644 index 0000000000..fa787797d7 --- /dev/null +++ b/plugins/chat/spec/lib/chat_notifier_spec.rb @@ -0,0 +1,540 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatNotifier do + describe "#notify_new" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + + before do + @chat_group = + Fabricate( + :group, + users: [user_1, user_2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + SiteSetting.chat_allowed_groups = @chat_group.id + + [user_1, user_2].each do |u| + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: u) + end + end + + def build_cooked_msg(message_body, user, chat_channel: channel) + ChatMessage.new( + chat_channel: chat_channel, + user: user, + message: message_body, + created_at: 5.minutes.ago, + ).tap(&:cook) + end + + shared_examples "channel-wide mentions" do + it "returns an empty list when the message doesn't include a channel mention" do + msg = build_cooked_msg(mention.gsub("@", ""), user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "will never include someone who is not accepting channel-wide notifications" do + user_2.user_option.update!(ignore_channel_wide_mention: true) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "includes all members of a channel except the sender" do + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + end + + shared_examples "ensure only channel members are notified" do + it "will never include someone outside the channel" do + user3 = Fabricate(:user) + @chat_group.add(user3) + another_channel = Fabricate(:category_channel) + Fabricate(:user_chat_channel_membership, chat_channel: another_channel, user: user3) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "will never include someone not following the channel anymore" do + user3 = Fabricate(:user) + @chat_group.add(user3) + Fabricate( + :user_chat_channel_membership, + following: false, + chat_channel: channel, + user: user3, + ) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "will never include someone who is suspended" do + user3 = Fabricate(:user, suspended_till: 2.years.from_now) + @chat_group.add(user3) + Fabricate( + :user_chat_channel_membership, + following: true, + chat_channel: channel, + user: user3, + ) + + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + end + + describe "global_mentions" do + let(:mention) { "hello @all!" } + let(:list_key) { :global_mentions } + + include_examples "channel-wide mentions" + include_examples "ensure only channel members are notified" + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "here_mentions" do + let(:mention) { "hello @here!" } + let(:list_key) { :here_mentions } + + before { user_2.update!(last_seen_at: 4.minutes.ago) } + + include_examples "channel-wide mentions" + include_examples "ensure only channel members are notified" + + it "includes users seen less than 5 minutes ago" do + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "excludes users seen more than 5 minutes ago" do + user_2.update!(last_seen_at: 6.minutes.ago) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "excludes users mentioned directly" do + msg = build_cooked_msg("hello @here @#{user_2.username}!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "direct_mentions" do + it "only include mentioned users who are already in the channel" do + user_3 = Fabricate(:user) + @chat_group.add(user_3) + another_channel = Fabricate(:category_channel) + Fabricate(:user_chat_channel_membership, chat_channel: another_channel, user: user_3) + msg = build_cooked_msg("Is @#{user_3.username} here? And @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + it "include users as direct mentions even if there's a @here mention" do + msg = build_cooked_msg("Hello @here and @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:here_mentions]).to be_empty + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + it "include users as direct mentions even if there's a @all mention" do + msg = build_cooked_msg("Hello @all and @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:global_mentions]).to be_empty + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + describe "users ignoring or muting the user creating the message" do + it "does not publish new mentions to these users" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) + + ChatPublisher.expects(:publish_new_mention).never + to_notify = described_class.new(msg, msg.created_at).notify_new + end + + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("hey @#{user_2.username} stop ignoring me!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "group mentions" do + fab!(:user_3) { Fabricate(:user) } + fab!(:group) do + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:other_channel) { Fabricate(:category_channel) } + + before { @chat_group.add(user_3) } + + let(:mention) { "hello @#{group.name}!" } + let(:list_key) { group.name } + + include_examples "ensure only channel members are notified" + + it "establishes a far-left precedence among group mentions" do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user_3, + following: true, + ) + msg = build_cooked_msg("Hello @#{@chat_group.name} and @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[@chat_group.name]).to contain_exactly(user_2.id, user_3.id) + expect(to_notify[list_key]).to be_empty + + second_msg = build_cooked_msg("Hello @#{group.name} and @#{@chat_group.name}", user_1) + + to_notify_2 = described_class.new(second_msg, second_msg.created_at).notify_new + + expect(to_notify_2[list_key]).to contain_exactly(user_2.id, user_3.id) + expect(to_notify_2[@chat_group.name]).to be_empty + end + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user inside the group who is muting the acting user" do + group.add(user_3) + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user_3) + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + expect(to_notify[group.name]).to contain_exactly(user_3.id) + end + + it "does not send notifications to the user inside the group who is ignoring the acting user" do + group.add(user_3) + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user_3) + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + expect(to_notify[group.name]).to contain_exactly(user_3.id) + end + end + end + + describe "unreachable users" do + fab!(:user_3) { Fabricate(:user) } + + it "notify poster of users who are not allowed to use chat" do + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + + context "when in a personal message" do + let(:personal_chat_channel) do + Group.refresh_automatic_groups! + Chat::DirectMessageChannelCreator.create!( + acting_user: user_1, + target_users: [user_1, user_2], + ) + end + + before { @chat_group.add(user_3) } + + it "notify posts of users who are not participating in a personal message" do + msg = + build_cooked_msg( + "Hello @#{user_3.username}", + user_1, + chat_channel: personal_chat_channel, + ) + + messages = + MessageBus.track_publish("/chat/#{personal_chat_channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + + it "notify posts of users who are part of the mentioned group but participating" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + msg = + build_cooked_msg("Hello @#{group.name}", user_1, chat_channel: personal_chat_channel) + + messages = + MessageBus.track_publish("/chat/#{personal_chat_channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[group.name]).to contain_exactly(user_2.id) + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + end + end + + describe "users who can be invited to join the channel" do + fab!(:user_3) { Fabricate(:user) } + + before { @chat_group.add(user_3) } + + it "can invite chat user without channel membership" do + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "cannot invite chat user without channel membership if they are ignoring the user who created the message" do + Fabricate(:ignored_user, user: user_3, ignored_user: user_1) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "cannot invite chat user without channel membership if they are muting the user who created the message" do + Fabricate(:muted_user, user: user_3, muted_user: user_1) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "can invite chat user who no longer follows the channel" do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user_3, + following: false, + ) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "can invite other group members to channel" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "cannot invite a member of a group who is ignoring the user who created the message" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + Fabricate(:ignored_user, user: user_3, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "cannot invite a member of a group who is muting the user who created the message" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + Fabricate(:muted_user, user: user_3, muted_user: user_1) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_review_queue_spec.rb b/plugins/chat/spec/lib/chat_review_queue_spec.rb new file mode 100644 index 0000000000..23433dab01 --- /dev/null +++ b/plugins/chat/spec/lib/chat_review_queue_spec.rb @@ -0,0 +1,439 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatReviewQueue do + fab!(:message_poster) { Fabricate(:user) } + fab!(:flagger) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:message) { Fabricate(:chat_message, user: message_poster, chat_channel: chat_channel) } + + fab!(:admin) { Fabricate(:admin) } + let(:guardian) { Guardian.new(flagger) } + let(:admin_guardian) { Guardian.new(admin) } + + subject(:queue) { described_class.new } + + before do + chat_channel.add(message_poster) + chat_channel.add(flagger) + Group.refresh_automatic_groups! + end + + describe "#flag_message" do + it "raises an error when the user is not allowed to flag" do + UserSilencer.new(flagger).silence + + expect { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) }.to raise_error( + Discourse::InvalidAccess, + ) + end + + it "stores the message cooked content inside the reviewable" do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + + expect(reviewable.payload["message_cooked"]).to eq(message.cooked) + end + + context "when the user already flagged the post" do + let(:second_flag_result) do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + end + + before { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + + it "returns an error" do + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + + it "returns an error when trying to use notify_moderators and the previous flag is still pending" do + notify_moderators_result = + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: "Look at this please, moderators", + ) + + expect(notify_moderators_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + end + + context "when a different user already flagged the post" do + let(:second_flag_result) { queue.flag_message(message, admin_guardian, second_flag_type) } + + before { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + + it "appends a new score to the existing reviewable" do + second_flag_result = + queue.flag_message(message, admin_guardian, ReviewableScore.types[:off_topic]) + expect(second_flag_result).to include success: true + + reviewable = ReviewableChatMessage.find_by(target: message) + scores = reviewable.reviewable_scores + + expect(scores.size).to eq(2) + expect(scores.map(&:reviewable_score_type)).to contain_exactly( + *ReviewableScore.types.slice(:off_topic, :spam).values, + ) + end + + it "returns an error when someone already used the same flag type" do + second_flag_result = + queue.flag_message(message, admin_guardian, ReviewableScore.types[:spam]) + + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + end + + context "when a flags exists but staff already handled it" do + let(:second_flag_result) do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + end + + before do + queue.flag_message(message, guardian, ReviewableScore.types[:spam]) + + reviewable = ReviewableChatMessage.last + reviewable.perform(admin, :ignore) + end + + it "raises an error when we are inside the cooldown window" do + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + + it "allows the user to re-flag after the cooldown period" do + reviewable = ReviewableChatMessage.last + reviewable.update!(updated_at: (SiteSetting.cooldown_hours_until_reflag.to_i + 1).hours.ago) + + expect(second_flag_result).to include success: true + end + + it "ignores the cooldown window when the message is edited" do + Chat::ChatMessageUpdater.update( + guardian: Guardian.new(message.user), + chat_message: message, + new_content: "I'm editing this message. Please flag it.", + ) + + expect(second_flag_result).to include success: true + end + + it "ignores the cooldown window when using the notify_moderators flag type" do + notify_moderators_result = + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: "Look at this please, moderators", + ) + + expect(notify_moderators_result).to include success: true + end + end + + it "publishes a message to the flagger" do + messages = + MessageBus + .track_publish { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + .map(&:data) + + self_flag_msg = messages.detect { |m| m["type"] == "self_flagged" } + + expect(self_flag_msg["user_flag_status"]).to eq(ReviewableScore.statuses[:pending]) + expect(self_flag_msg["chat_message_id"]).to eq(message.id) + end + + it "publishes a message to tell staff there is a new reviewable" do + messages = + MessageBus + .track_publish { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + .map(&:data) + + flag_msg = messages.detect { |m| m["type"] == "flag" } + new_reviewable = ReviewableChatMessage.find_by(target: message) + + expect(flag_msg["chat_message_id"]).to eq(message.id) + expect(flag_msg["reviewable_id"]).to eq(new_reviewable.id) + end + + let(:flag_message) { "I just flagged your chat message..." } + + context "when creating a notify_user flag" do + it "creates a companion PM" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + ) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + pm_post = pm_topic.first_post + + expect(pm_topic.allowed_users).to include(message.user) + expect(pm_topic.subtype).to eq(TopicSubtype.notify_user) + expect(pm_post.raw).to include(flag_message) + expect(pm_topic.title).to eq("Your chat message in \"#{chat_channel.title(message.user)}\"") + end + + it "doesn't create a PM if there is no message" do + queue.flag_message(message, guardian, ReviewableScore.types[:notify_user]) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + + expect(pm_topic).to be_nil + end + + it "allow staff to tag PM as a warning" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + is_warning: true, + ) + + expect(UserWarning.exists?(user: message.user)).to eq(true) + end + + it "only allows staff members to send warnings" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + is_warning: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when creating a notify_moderators flag" do + it "creates a companion PM and gives moderators access to it" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: flag_message, + ) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + pm_post = pm_topic.first_post + + expect(pm_topic.allowed_groups).to contain_exactly(Group[:moderators]) + expect(pm_topic.subtype).to eq(TopicSubtype.notify_moderators) + expect(pm_post.raw).to include(flag_message) + expect(pm_topic.title).to eq( + "A chat message in \"#{chat_channel.title(message.user)}\" requires staff attention", + ) + end + + it "ignores the is_warning flag when notifying moderators" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: flag_message, + is_warning: true, + ) + + expect(UserWarning.exists?(user: message.user)).to eq(false) + end + end + + context "when immediately taking action" do + it "agrees with the flag and deletes the chat message" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + + reviewable = ReviewableChatMessage.find_by(target: message) + + expect(reviewable.approved?).to eq(true) + expect(message.reload.trashed?).to eq(true) + end + + it "publishes an when deleting the message" do + messages = + MessageBus + .track_publish do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + end + .map(&:data) + + delete_msg = messages.detect { |m| m[:type] == "delete" } + + expect(delete_msg[:deleted_id]).to eq(message.id) + end + + it "agrees with other flags on the same message" do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + scores = reviewable.reviewable_scores + + expect(scores.size).to eq(1) + expect(scores.all?(&:pending?)).to eq(true) + + queue.flag_message(message, admin_guardian, ReviewableScore.types[:spam], take_action: true) + + scores = reviewable.reload.reviewable_scores + + expect(scores.size).to eq(2) + expect(scores.all?(&:agreed?)).to eq(true) + end + + it "raises an exception if the user is not a staff member" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when queueing for review" do + it "sets a reason on the score" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + queue_for_review: true, + ) + + reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + score = reviewable.reviewable_scores.first + + expect(score.reason).to eq("chat_message_queued_by_staff") + end + + it "only allows staff members to queue for review" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:off_topic], + queue_for_review: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when the auto silence threshold is met" do + it "silences the user" do + SiteSetting.chat_auto_silence_from_flags_duration = 1 + flagger.update!(trust_level: TrustLevel[4]) # Increase Score due to TL Bonus. + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(true) + end + + it "does nothing if the new score is less than the auto-silence threshold" do + SiteSetting.chat_auto_silence_from_flags_duration = 50 + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(false) + end + + it "does nothing if the silence duration is set to 0" do + SiteSetting.chat_auto_silence_from_flags_duration = 0 + flagger.update!(trust_level: TrustLevel[4]) # Increase Score due to TL Bonus. + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(false) + end + end + + context "when flagging a DM" do + fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [message_poster, flagger]) } + + 12.times do |i| + fab!("dm_message_#{i + 1}") do + Fabricate( + :chat_message, + user: message_poster, + chat_channel: dm_channel, + message: "This is my message number #{i + 1}. Hello chat!", + ) + end + end + + it "raises an exception when using the notify_moderators flag type" do + expect { + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:notify_moderators]) + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an exception when using the notify_user flag type" do + expect { + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:notify_user]) + }.to raise_error(Discourse::InvalidParameters) + end + + it "includes a transcript of the previous 10 message for the rest of the flags" do + queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + expect(reviewable.target).to eq(dm_message_12) + transcript_post = Post.find_by(topic_id: reviewable.payload["transcript_topic_id"]) + + expect(transcript_post.cooked).to include(dm_message_2.message) + expect(transcript_post.cooked).to include(dm_message_5.message) + expect(transcript_post.cooked).not_to include(dm_message_1.message) + end + + it "doesn't include a transcript if there a no previous messages" do + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + + expect(reviewable.payload["transcript_topic_id"]).to be_nil + end + + it "the transcript is only available to moderators and the system user" do + moderator = Fabricate(:moderator) + admin = Fabricate(:admin) + leader = Fabricate(:leader) + tl4 = Fabricate(:trust_level_4) + + queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + transcript_topic = Topic.find(reviewable.payload["transcript_topic_id"]) + + expect(guardian.can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(leader).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(tl4).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(dm_message_12.user).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(moderator).can_see_topic?(transcript_topic)).to eq(true) + expect(Guardian.new(admin).can_see_topic?(transcript_topic)).to eq(true) + expect(Guardian.new(Discourse.system_user).can_see_topic?(transcript_topic)).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_statistics_spec.rb b/plugins/chat/spec/lib/chat_statistics_spec.rb new file mode 100644 index 0000000000..0215412831 --- /dev/null +++ b/plugins/chat/spec/lib/chat_statistics_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +describe Chat::Statistics do + fab!(:frozen_time) { DateTime.parse("2022-07-08 09:30:00") } + + def minus_time(time) + frozen_time - time + end + + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:user5) { Fabricate(:user) } + + fab!(:channel1) { Fabricate(:chat_channel, created_at: minus_time(1.hour)) } + fab!(:channel2) { Fabricate(:chat_channel, created_at: minus_time(2.days)) } + fab!(:channel3) { Fabricate(:chat_channel, created_at: minus_time(6.days)) } + fab!(:channel3) { Fabricate(:chat_channel, created_at: minus_time(20.days)) } + fab!(:channel4) { Fabricate(:chat_channel, created_at: minus_time(21.days), status: :closed) } + fab!(:channel5) { Fabricate(:chat_channel, created_at: minus_time(24.days)) } + fab!(:channel6) { Fabricate(:chat_channel, created_at: minus_time(40.days)) } + fab!(:channel7) { Fabricate(:chat_channel, created_at: minus_time(100.days), status: :archived) } + + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel1) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel1) + end + + fab!(:message1) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(5.minutes), user: user1) + end + fab!(:message2) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(2.days), user: user2) + end + fab!(:message3) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(6.days), user: user2) + end + fab!(:message4) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(11.days), user: user2) + end + fab!(:message5) do + Fabricate(:chat_message, chat_channel: channel4, created_at: minus_time(12.days), user: user3) + end + fab!(:message6) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(13.days), user: user2) + end + fab!(:message7) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(16.days), user: user1) + end + fab!(:message8) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(42.days), user: user3) + end + fab!(:message9) do + Fabricate( + :chat_message, + chat_channel: channel1, + created_at: minus_time(42.days), + user: user3, + deleted_at: minus_time(10.days), + deleted_by: user3, + ) + end + fab!(:message10) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(50.days), user: user4) + end + fab!(:message10) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(62.days), user: user4) + end + + before { freeze_time(DateTime.parse("2022-07-08 09:30:00")) } + + describe "#about_messages" do + it "counts non-deleted messages created in all status channels in the time period accurately" do + about_messages = described_class.about_messages + expect(about_messages[:last_day]).to eq(1) + expect(about_messages["7_days"]).to eq(3) + expect(about_messages["30_days"]).to eq(7) + expect(about_messages[:previous_30_days]).to eq(2) + expect(about_messages[:count]).to eq(10) + end + end + + describe "#about_channels" do + it "counts open channels created in the time period accurately" do + about_channels = described_class.about_channels + expect(about_channels[:last_day]).to eq(1) + expect(about_channels["7_days"]).to eq(3) + expect(about_channels["30_days"]).to eq(5) + expect(about_channels[:previous_30_days]).to eq(1) + expect(about_channels[:count]).to eq(6) + end + end + + describe "#about_users" do + it "counts any users who have sent any message to a chat channel in the time periods accurately" do + about_users = described_class.about_users + expect(about_users[:last_day]).to eq(1) + expect(about_users["7_days"]).to eq(2) + expect(about_users["30_days"]).to eq(3) + expect(about_users[:previous_30_days]).to eq(2) + expect(about_users[:count]).to eq(4) + end + end + + describe "#monthly" do + it "has the correct counts of users, messages, and channels created since the start of this month" do + monthly = described_class.monthly + expect(monthly[:messages]).to eq(3) + expect(monthly[:channels]).to eq(3) + expect(monthly[:users]).to eq(2) + end + end +end diff --git a/plugins/chat/spec/lib/chat_transcript_service_spec.rb b/plugins/chat/spec/lib/chat_transcript_service_spec.rb new file mode 100644 index 0000000000..0f14d92f01 --- /dev/null +++ b/plugins/chat/spec/lib/chat_transcript_service_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatTranscriptService do + let(:acting_user) { Fabricate(:user) } + let(:user1) { Fabricate(:user, username: "martinchat") } + let(:user2) { Fabricate(:user, username: "brucechat") } + let(:channel) { Fabricate(:category_channel, name: "The Beam Discussions") } + + def service(message_ids, opts: {}) + described_class.new(channel, acting_user, messages_or_ids: Array.wrap(message_ids), opts: opts) + end + + it "generates a simple chat transcript from one message" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + an extremely insightful response :) + [/chat] + MARKDOWN + end + + it "generates a single chat transcript from multiple subsequent messages from the same user" do + message1 = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = + Fabricate(:chat_message, user: user1, chat_channel: channel, message: "if i say so myself") + message3 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "yay!") + + rendered = service([message1.id, message2.id, message3.id]).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true"] + an extremely insightful response :) + + if i say so myself + + yay! + [/chat] + MARKDOWN + end + + it "generates chat messages in created_at order no matter what order the message_ids are passed in" do + message1 = + Fabricate( + :chat_message, + created_at: 10.minute.ago, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = + Fabricate( + :chat_message, + created_at: 5.minutes.ago, + user: user1, + chat_channel: channel, + message: "if i say so myself", + ) + message3 = + Fabricate( + :chat_message, + created_at: 1.minutes.ago, + user: user1, + chat_channel: channel, + message: "yay!", + ) + + rendered = service([message3.id, message1.id, message2.id]).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true"] + an extremely insightful response :) + + if i say so myself + + yay! + [/chat] + MARKDOWN + end + + it "generates multiple chained chat transcripts for interleaving messages from different users" do + message1 = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = Fabricate(:chat_message, user: user2, chat_channel: channel, message: "says you!") + message3 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "aw :(") + + expect(service([message1.id, message2.id, message3.id]).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true"] + an extremely insightful response :) + [/chat] + + [chat quote="brucechat;#{message2.id};#{message2.created_at.iso8601}" chained="true"] + says you! + [/chat] + + [chat quote="martinchat;#{message3.id};#{message3.created_at.iso8601}" chained="true"] + aw :( + [/chat] + MARKDOWN + end + + it "generates image / attachment / video / audio markdown inside the [chat] bbcode for upload-only messages" do + SiteSetting.authorized_extensions = "mp4|mp3|pdf|jpg" + message = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "") + video = Fabricate(:upload, original_filename: "test_video.mp4", extension: "mp4") + audio = Fabricate(:upload, original_filename: "test_audio.mp3", extension: "mp3") + attachment = Fabricate(:upload, original_filename: "test_file.pdf", extension: "pdf") + image = + Fabricate( + :upload, + width: 100, + height: 200, + original_filename: "test_img.jpg", + extension: "jpg", + ) + cu1 = ChatUpload.create(chat_message: message, created_at: 10.seconds.ago, upload: video) + cu2 = ChatUpload.create(chat_message: message, created_at: 9.seconds.ago, upload: audio) + cu3 = ChatUpload.create(chat_message: message, created_at: 8.seconds.ago, upload: attachment) + cu4 = ChatUpload.create(chat_message: message, created_at: 7.seconds.ago, upload: image) + video_markdown = UploadMarkdown.new(video).to_markdown + audio_markdown = UploadMarkdown.new(audio).to_markdown + attachment_markdown = UploadMarkdown.new(attachment).to_markdown + image_markdown = UploadMarkdown.new(image).to_markdown + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + #{video_markdown} + #{audio_markdown} + #{attachment_markdown} + #{image_markdown} + [/chat] + MARKDOWN + end + + it "generates the correct markdown if a message has text and an upload" do + SiteSetting.authorized_extensions = "mp4|mp3|pdf|jpg" + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "this is a cool and funny picture", + ) + image = + Fabricate( + :upload, + width: 100, + height: 200, + original_filename: "test_img.jpg", + extension: "jpg", + ) + cu = ChatUpload.create(chat_message: message, created_at: 7.seconds.ago, upload: image) + image_markdown = UploadMarkdown.new(image).to_markdown + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + this is a cool and funny picture + + #{image_markdown} + [/chat] + MARKDOWN + end + + it "generates a transcript with the noLink option" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + + expect(service(message.id, opts: { no_link: true }).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" noLink="true"] + an extremely insightful response :) + [/chat] + MARKDOWN + end + + it "generates reaction data for single and subsequent messages" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "wow so tru") + message3 = + Fabricate(:chat_message, user: user2, chat_channel: channel, message: "a new perspective") + + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "bjorn"), + emoji: "heart", + ) + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "sigurd"), + emoji: "heart", + ) + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "hvitserk"), + emoji: "+1", + ) + ChatMessageReaction.create!( + chat_message: message2, + user: Fabricate(:user, username: "ubbe"), + emoji: "money_mouth_face", + ) + ChatMessageReaction.create!( + chat_message: message3, + user: Fabricate(:user, username: "ivar"), + emoji: "sob", + ) + + expect( + service( + [message.id, message2.id, message3.id], + opts: { + include_reactions: true, + }, + ).generate_markdown, + ).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true" reactions="+1:hvitserk;heart:bjorn,sigurd;money_mouth_face:ubbe"] + an extremely insightful response :) + + wow so tru + [/chat] + + [chat quote="brucechat;#{message3.id};#{message3.created_at.iso8601}" chained="true" reactions="sob:ivar"] + a new perspective + [/chat] + MARKDOWN + end +end diff --git a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb b/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb new file mode 100644 index 0000000000..efdb0310a6 --- /dev/null +++ b/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::DirectMessageChannelCreator do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + + before { Group.refresh_automatic_groups! } + + context "with an existing direct message channel" do + fab!(:dm_chat_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2, user_3]) } + fab!(:own_chat_channel) { Fabricate(:direct_message_channel, users: [user_1]) } + + it "doesn't create a new chat channel" do + existing_channel = nil + expect { + existing_channel = + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { ChatChannel.count } + expect(existing_channel).to eq(dm_chat_channel) + end + + it "creates UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: dm_chat_channel, + following: false, + muted: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + Fabricate( + :user_chat_channel_membership, + user: user_3, + chat_channel: dm_chat_channel, + following: false, + muted: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { UserChatChannelMembership.count }.by(1) + + user_1_membership = + UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: dm_chat_channel) + expect(user_1_membership.last_read_message_id).to eq(nil) + expect(user_1_membership.desktop_notification_level).to eq("always") + expect(user_1_membership.mobile_notification_level).to eq("always") + expect(user_1_membership.muted).to eq(false) + expect(user_1_membership.following).to eq(true) + + user_2_membership = + UserChatChannelMembership.find_by(user_id: user_2.id, chat_channel_id: dm_chat_channel) + expect(user_2_membership.last_read_message_id).to eq(nil) + expect(user_2_membership.desktop_notification_level).to eq("never") + expect(user_2_membership.mobile_notification_level).to eq("never") + expect(user_2_membership.muted).to eq(true) + expect(user_2_membership.following).to eq(false) + + user_3_membership = + UserChatChannelMembership.find_by(user_id: user_3.id, chat_channel_id: dm_chat_channel) + expect(user_3_membership.last_read_message_id).to eq(nil) + expect(user_3_membership.desktop_notification_level).to eq("never") + expect(user_3_membership.mobile_notification_level).to eq("never") + expect(user_3_membership.muted).to eq(true) + expect(user_3_membership.following).to eq(false) + end + + it "publishes the new DM channel message bus message for each user" do + messages = + MessageBus + .track_publish do + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + end + .filter { |m| m.channel == "/chat/new-channel" } + + expect(messages.count).to eq(3) + expect(messages.first[:data]).to be_kind_of(Hash) + expect(messages.map { |m| m.dig(:data, :chat_channel, :id) }).to eq( + [dm_chat_channel.id, dm_chat_channel.id, dm_chat_channel.id], + ) + end + + it "allows a user to create a direct message to themselves, without creating a new channel" do + existing_channel = nil + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1]) + }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + expect(existing_channel).to eq(own_chat_channel) + end + + it "deduplicates target_users" do + existing_channel = nil + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + expect(existing_channel).to eq(own_chat_channel) + end + + context "when the user is not a member of direct_message_enabled_groups" do + before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } + + it "raises an error and does not change membership or channel counts" do + channel_count = ChatChannel.count + membership_count = UserChatChannelMembership.count + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to raise_error(Discourse::InvalidAccess) + expect(ChatChannel.count).to eq(channel_count) + expect(UserChatChannelMembership.count).to eq(membership_count) + end + + context "when user is staff" do + before { user_1.update!(admin: true) } + + it "doesn't create an error and returns the existing channel" do + existing_channel = nil + expect { + existing_channel = + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { ChatChannel.count } + expect(existing_channel).to eq(dm_chat_channel) + end + end + end + end + + context "with non existing direct message channel" do + it "creates a new chat channel" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { + ChatChannel.count + }.by(1) + end + + it "creates UserChatChannelMembership records and sets their notification levels" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { + UserChatChannelMembership.count + }.by(2) + + chat_channel = ChatChannel.last + user_1_membership = + UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) + expect(user_1_membership.last_read_message_id).to eq(nil) + expect(user_1_membership.desktop_notification_level).to eq("always") + expect(user_1_membership.mobile_notification_level).to eq("always") + expect(user_1_membership.muted).to eq(false) + expect(user_1_membership.following).to eq(true) + end + + it "publishes the new DM channel message bus message for each user" do + messages = + MessageBus + .track_publish { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) } + .filter { |m| m.channel == "/chat/new-channel" } + + chat_channel = ChatChannel.last + expect(messages.count).to eq(2) + expect(messages.first[:data]).to be_kind_of(Hash) + expect(messages.map { |m| m.dig(:data, :chat_channel, :id) }).to eq( + [chat_channel.id, chat_channel.id], + ) + end + + it "allows a user to create a direct message to themselves" do + expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { + ChatChannel.count + }.by(1).and change { UserChatChannelMembership.count }.by(1) + end + + it "deduplicates target_users" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to change { + ChatChannel.count + }.by(1).and change { UserChatChannelMembership.count }.by(1) + end + + context "when the user is not a member of direct_message_enabled_groups" do + before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } + + it "raises an error and does not change membership or channel counts" do + channel_count = ChatChannel.count + membership_count = UserChatChannelMembership.count + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to raise_error(Discourse::InvalidAccess) + expect(ChatChannel.count).to eq(channel_count) + expect(UserChatChannelMembership.count).to eq(membership_count) + end + + context "when user is staff" do + before { user_1.update!(admin: true) } + + it "creates a new chat channel" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { ChatChannel.count }.by(1) + end + end + end + end + + describe "ignoring, muting, and preventing DMs from other users" do + context "when any of the users that the acting user is open in a DM with are ignoring the acting user" do + before do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + end + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the ignoring user create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_ignoring_target_user", username: user_1.username), + ) + end + end + + context "when any of the users that the acting user is open in a DM with are muting the acting user" do + before { Fabricate(:muted_user, user: user_2, muted_user: user_1) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the muting user create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_muting_target_user", username: user_1.username), + ) + end + end + + context "when any of the users that the acting user is open in a DM with is preventing private/direct messages" do + before { user_2.user_option.update(allow_private_messages: false) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_disallowed_dms"), + ) + end + end + + context "when any of the users that the acting user is open in a DM with only allow private/direct messages from certain users" do + before { user_2.user_option.update!(enable_allowed_pm_users: true) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error(Chat::DirectMessageChannelCreator::NotAllowed) + end + + it "does not raise an error if the acting user is allowed to send the PM" do + AllowedPmUser.create!(user: user_2, allowed_pm_user: user_1) + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { ChatChannel.count }.by(1) + end + + it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_preventing_target_user_from_dm", username: user_1.username), + ) + end + end + end +end diff --git a/plugins/chat/spec/lib/duplicate_message_validator_spec.rb b/plugins/chat/spec/lib/duplicate_message_validator_spec.rb new file mode 100644 index 0000000000..4da4da7bdd --- /dev/null +++ b/plugins/chat/spec/lib/duplicate_message_validator_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::DuplicateMessageValidator do + let(:chat_channel) { Fabricate(:chat_channel) } + + def message_blocked?(message) + chat_message = Fabricate.build(:chat_message, message: message, chat_channel: chat_channel) + described_class.new(chat_message).validate + chat_message.errors.full_messages.include?(I18n.t("chat.errors.duplicate_message")) + end + + it "adds no errors when chat_duplicate_message_sensitivity is 0" do + SiteSetting.chat_duplicate_message_sensitivity = 0 + expect(message_blocked?("test")).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 0.1" do + SiteSetting.chat_duplicate_message_sensitivity = 0.1 + + chat_channel.update!(user_count: 100) + message = "this is a 30 char message for test" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 11.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 0.5" do + SiteSetting.chat_duplicate_message_sensitivity = 0.5 + chat_channel.update!(user_count: 57) + message = "this is a 21 char msg" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 33.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 1.0" do + SiteSetting.chat_duplicate_message_sensitivity = 1.0 + chat_channel.update!(user_count: 5) + message = "10 char msg" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 61.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + describe "#sensitivity_matrix" do + describe "#min_user_count" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_user_count]).to eq(100) + expect(described_class.sensitivity_matrix(0.2)[:min_user_count]).to eq(89) + expect(described_class.sensitivity_matrix(0.3)[:min_user_count]).to eq(78) + expect(described_class.sensitivity_matrix(0.4)[:min_user_count]).to eq(68) + expect(described_class.sensitivity_matrix(0.5)[:min_user_count]).to eq(57) + expect(described_class.sensitivity_matrix(0.6)[:min_user_count]).to eq(47) + expect(described_class.sensitivity_matrix(0.7)[:min_user_count]).to eq(36) + expect(described_class.sensitivity_matrix(0.8)[:min_user_count]).to eq(26) + expect(described_class.sensitivity_matrix(0.9)[:min_user_count]).to eq(15) + expect(described_class.sensitivity_matrix(1.0)[:min_user_count]).to eq(5) + end + end + + describe "#min_message_length" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_message_length]).to eq(30) + expect(described_class.sensitivity_matrix(0.2)[:min_message_length]).to eq(27) + expect(described_class.sensitivity_matrix(0.3)[:min_message_length]).to eq(25) + expect(described_class.sensitivity_matrix(0.4)[:min_message_length]).to eq(23) + expect(described_class.sensitivity_matrix(0.5)[:min_message_length]).to eq(21) + expect(described_class.sensitivity_matrix(0.6)[:min_message_length]).to eq(18) + expect(described_class.sensitivity_matrix(0.7)[:min_message_length]).to eq(16) + expect(described_class.sensitivity_matrix(0.8)[:min_message_length]).to eq(14) + expect(described_class.sensitivity_matrix(0.9)[:min_message_length]).to eq(12) + expect(described_class.sensitivity_matrix(1.0)[:min_message_length]).to eq(10) + end + end + + describe "#min_past_seconds" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_past_seconds]).to eq(10) + expect(described_class.sensitivity_matrix(0.2)[:min_past_seconds]).to eq(15) + expect(described_class.sensitivity_matrix(0.3)[:min_past_seconds]).to eq(21) + expect(described_class.sensitivity_matrix(0.4)[:min_past_seconds]).to eq(26) + expect(described_class.sensitivity_matrix(0.5)[:min_past_seconds]).to eq(32) + expect(described_class.sensitivity_matrix(0.6)[:min_past_seconds]).to eq(37) + expect(described_class.sensitivity_matrix(0.7)[:min_past_seconds]).to eq(43) + expect(described_class.sensitivity_matrix(0.8)[:min_past_seconds]).to eq(48) + expect(described_class.sensitivity_matrix(0.9)[:min_past_seconds]).to eq(54) + expect(described_class.sensitivity_matrix(1.0)[:min_past_seconds]).to eq(60) + end + end + end +end diff --git a/plugins/chat/spec/lib/guardian_extensions_spec.rb b/plugins/chat/spec/lib/guardian_extensions_spec.rb new file mode 100644 index 0000000000..0afc6a0070 --- /dev/null +++ b/plugins/chat/spec/lib/guardian_extensions_spec.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::GuardianExtensions do + fab!(:user) { Fabricate(:user) } + fab!(:staff) { Fabricate(:user, admin: true) } + fab!(:chat_group) { Fabricate(:group) } + fab!(:channel) { Fabricate(:category_channel) } + fab!(:dm_channel) { Fabricate(:direct_message_channel) } + let(:guardian) { Guardian.new(user) } + let(:staff_guardian) { Guardian.new(staff) } + + before do + SiteSetting.chat_allowed_groups = chat_group.id + chat_group.add(user) + end + + it "cannot chat if the user is not in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = "" + expect(guardian.can_chat?(user)).to eq(false) + end + + it "staff can always chat regardless of chat_allowed_grups" do + SiteSetting.chat_allowed_groups = "" + expect(guardian.can_chat?(staff)).to eq(true) + end + + describe "chat channel" do + it "only staff can create channels" do + expect(guardian.can_create_chat_channel?).to eq(false) + expect(staff_guardian.can_create_chat_channel?).to eq(true) + end + + it "only staff can edit chat channels" do + expect(guardian.can_edit_chat_channel?).to eq(false) + expect(staff_guardian.can_edit_chat_channel?).to eq(true) + end + + it "only staff can close chat channels" do + channel.update(status: :open) + expect(guardian.can_change_channel_status?(channel, :closed)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :closed)).to eq(true) + end + + it "only staff can open chat channels" do + channel.update(status: :closed) + expect(guardian.can_change_channel_status?(channel, :open)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :open)).to eq(true) + end + + it "only staff can archive chat channels" do + channel.update(status: :read_only) + expect(guardian.can_change_channel_status?(channel, :archived)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :archived)).to eq(true) + end + + it "only staff can mark chat channels read_only" do + channel.update(status: :open) + expect(guardian.can_change_channel_status?(channel, :read_only)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :read_only)).to eq(true) + end + + describe "#can_see_chat_channel?" do + context "for direct message channels" do + fab!(:chatable) { Fabricate(:direct_message) } + fab!(:channel) { Fabricate(:direct_message_channel, chatable: chatable) } + + it "returns false if the user is not part of the direct message" do + expect(guardian.can_see_chat_channel?(channel)).to eq(false) + end + + it "returns true if the user is part of the direct message" do + DirectMessageUser.create!(user: user, direct_message: chatable) + expect(guardian.can_see_chat_channel?(channel)).to eq(true) + end + end + + context "for category channel" do + fab!(:category) { Fabricate(:category, read_restricted: true) } + + before { channel.update(chatable: category) } + + it "returns true if the user can see the category" do + expect(Guardian.new(user).can_see_chat_channel?(channel)).to eq(false) + group = Fabricate(:group) + CategoryGroup.create(group: group, category: category) + GroupUser.create(group: group, user: user) + + # have to make a new instance of guardian because `user.secure_category_ids` + # is memoized there + expect(Guardian.new(user).can_see_chat_channel?(channel)).to eq(true) + end + end + end + + describe "#can_flag_in_chat_channel?" do + alias_matcher :be_able_to_flag_in_chat_channel, :be_can_flag_in_chat_channel + + context "when channel is a direct message channel" do + let(:channel) { Fabricate(:direct_message_channel) } + + it "returns false" do + expect(guardian).not_to be_able_to_flag_in_chat_channel(channel) + end + end + + context "when channel is a category channel" do + it "returns true" do + expect(guardian).to be_able_to_flag_in_chat_channel(channel) + end + end + + context "with a private channel" do + let(:private_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: private_group) } + let(:private_channel) { Fabricate(:category_channel, chatable: private_category) } + + context "when the user can't see the channel" do + it "returns false" do + expect(guardian).not_to be_able_to_flag_in_chat_channel(private_channel) + end + end + + context "when the user can see the channel" do + before { private_group.add(user) } + + it "returns true" do + expect(guardian).to be_able_to_flag_in_chat_channel(private_channel) + end + end + end + end + + describe "#can_moderate_chat?" do + context "for category channel" do + fab!(:category) { Fabricate(:category, read_restricted: true) } + + before { channel.update(chatable: category) } + + it "returns true for staff and false for regular users" do + expect(staff_guardian.can_moderate_chat?(channel.chatable)).to eq(true) + expect(guardian.can_moderate_chat?(channel.chatable)).to eq(false) + end + + context "when enable_category_group_moderation is true" do + before { SiteSetting.enable_category_group_moderation = true } + + it "returns true if the regular user is part of the reviewable_by_group for the category" do + moderator = Fabricate(:user) + mods = Fabricate(:group) + mods.add(moderator) + category.update!(reviewable_by_group: mods) + expect(Guardian.new(Fabricate(:admin)).can_moderate_chat?(channel.chatable)).to eq(true) + expect(Guardian.new(moderator).can_moderate_chat?(channel.chatable)).to eq(true) + end + end + end + + context "for DM channel" do + fab!(:dm_channel) { DirectMessage.create! } + + before { channel.update(chatable_type: "DirectMessageType", chatable: dm_channel) } + + it "returns true for staff and false for regular users" do + expect(staff_guardian.can_moderate_chat?(channel.chatable)).to eq(true) + expect(guardian.can_moderate_chat?(channel.chatable)).to eq(false) + end + end + end + + describe "#can_restore_chat?" do + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: user) } + fab!(:chatable) { Fabricate(:category) } + + context "when channel is closed" do + before { channel.update!(status: :closed) } + + it "disallows a owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + it "allows a staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessage.create! } + + it "allows owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + + context "when user is not owner of the message" do + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: Fabricate(:user)) } + + context "when chatable is a category" do + context "when category is not restricted" do + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + end + + context "when category is restricted" do + fab!(:chatable) { Fabricate(:category, read_restricted: true) } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + context "when group moderation is enabled" do + before { SiteSetting.enable_category_group_moderation = true } + + it "allows a group moderator to restore" do + moderator = Fabricate(:user) + mods = Fabricate(:group) + mods.add(moderator) + chatable.update!(reviewable_by_group: mods) + expect(Guardian.new(moderator).can_restore_chat?(message, chatable)).to eq(true) + end + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessage.create! } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + end + end + end + + context "when user is owner of the message" do + context "when chatable is a category" do + it "allows to restore if owner can see category" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + context "when category is restricted" do + fab!(:chatable) { Fabricate(:category, read_restricted: true) } + + it "disallows to restore if owner can't see category" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessage.create! } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "allows owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + end + end + + describe "#can_delete_category?" do + alias_matcher :be_able_to_delete_category, :be_can_delete_category + + let(:category) { channel.chatable } + + context "when user is staff" do + context "when category has no channel" do + before do + category.category_channel.destroy + category.reload + end + + it "allows to delete the category" do + expect(staff_guardian).to be_able_to_delete_category(category) + end + end + + context "when category has a channel" do + context "when channel has no messages" do + it "allows to delete the category" do + expect(staff_guardian).to be_able_to_delete_category(category) + end + end + + context "when channel has messages" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + it "does not allow to delete the category" do + expect(staff_guardian).not_to be_able_to_delete_category(category) + end + end + end + end + + context "when user is not staff" do + context "when category has no channel" do + before do + category.category_channel.destroy + category.reload + end + + it "does not allow to delete the category" do + expect(guardian).not_to be_able_to_delete_category(category) + end + end + + context "when category has a channel" do + context "when channel has no messages" do + it "does not allow to delete the category" do + expect(guardian).not_to be_able_to_delete_category(category) + end + end + + context "when channel has messages" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + it "does not allow to delete the category" do + expect(guardian).not_to be_able_to_delete_category(category) + end + end + end + end + end + end + + describe "#can_create_channel_message?" do + context "when user is staff" do + it "returns true if the channel is open" do + channel.update!(status: :open) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns true if the channel is closed" do + channel.update!(status: :closed) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns false if the channel is archived" do + channel.update!(status: :archived) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(false) + end + + context "for direct message channels" do + it "returns true if the channel is open" do + dm_channel.update!(status: :open) + expect(staff_guardian.can_create_channel_message?(dm_channel)).to eq(true) + end + end + end + + context "when user is not staff" do + it "returns true if the channel is open" do + channel.update!(status: :open) + expect(guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns false if the channel is closed" do + channel.update!(status: :closed) + expect(guardian.can_create_channel_message?(channel)).to eq(false) + end + + it "returns false if the channel is archived" do + channel.update!(status: :archived) + expect(guardian.can_create_channel_message?(channel)).to eq(false) + end + + context "for direct message channels" do + before { Group.refresh_automatic_groups! } + + it "it still allows the user to message even if they are not in direct_message_enabled_groups because they are not creating the channel" do + SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] + dm_channel.update!(status: :open) + expect(guardian.can_create_channel_message?(dm_channel)).to eq(true) + end + end + end + end +end diff --git a/plugins/chat/spec/lib/message_mover_spec.rb b/plugins/chat/spec/lib/message_mover_spec.rb new file mode 100644 index 0000000000..43182c64c1 --- /dev/null +++ b/plugins/chat/spec/lib/message_mover_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::MessageMover do + fab!(:acting_user) { Fabricate(:admin, username: "testmovechat") } + fab!(:source_channel) { Fabricate(:category_channel) } + fab!(:destination_channel) { Fabricate(:category_channel) } + + fab!(:message1) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 3.minutes.ago, + message: "the first to be moved", + ) + end + fab!(:message2) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 2.minutes.ago, + message: "message deux @testmovechat", + ) + end + fab!(:message3) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 1.minute.ago, + message: "the third message", + ) + end + fab!(:message4) { Fabricate(:chat_message, chat_channel: destination_channel) } + fab!(:message5) { Fabricate(:chat_message, chat_channel: destination_channel) } + fab!(:message6) { Fabricate(:chat_message, chat_channel: destination_channel) } + let(:move_message_ids) { [message1.id, message2.id, message3.id] } + + subject do + described_class.new( + acting_user: acting_user, + source_channel: source_channel, + message_ids: move_message_ids, + ) + end + + describe "#move_to_channel" do + def move! + subject.move_to_channel(destination_channel) + end + + it "raises an error if either the source or destination channels are not public (they cannot be DM channels)" do + expect { + described_class.new( + acting_user: acting_user, + source_channel: Fabricate(:direct_message_channel), + message_ids: move_message_ids, + ).move_to_channel(destination_channel) + }.to raise_error(Chat::MessageMover::InvalidChannel) + expect { + described_class.new( + acting_user: acting_user, + source_channel: source_channel, + message_ids: move_message_ids, + ).move_to_channel(Fabricate(:direct_message_channel)) + }.to raise_error(Chat::MessageMover::InvalidChannel) + end + + it "raises an error if no messages are found using the message ids" do + other_channel = Fabricate(:chat_channel) + message1.update(chat_channel: other_channel) + message2.update(chat_channel: other_channel) + message3.update(chat_channel: other_channel) + expect { move! }.to raise_error(Chat::MessageMover::NoMessagesFound) + end + + it "deletes the messages from the source channel and sends messagebus delete messages" do + messages = MessageBus.track_publish { move! } + expect(ChatMessage.where(id: move_message_ids)).to eq([]) + deleted_messages = ChatMessage.with_deleted.where(id: move_message_ids).order(:id) + expect(deleted_messages.count).to eq(3) + expect(messages.first.channel).to eq("/chat/#{source_channel.id}") + expect(messages.first.data[:typ]).to eq("bulk_delete") + expect(messages.first.data[:deleted_ids]).to eq(deleted_messages.map(&:id)) + expect(messages.first.data[:deleted_at]).not_to eq(nil) + end + + it "creates a message in the source channel to indicate that the messages have been moved" do + move! + placeholder_message = ChatMessage.where(chat_channel: source_channel).order(:created_at).last + destination_first_moved_message = + ChatMessage.find_by(chat_channel: destination_channel, message: "the first to be moved") + expect(placeholder_message.message).to eq( + I18n.t( + "chat.channel.messages_moved", + count: move_message_ids.length, + acting_username: acting_user.username, + channel_name: destination_channel.title(acting_user), + first_moved_message_url: destination_first_moved_message.url, + ), + ) + end + + it "preserves the order of the messages in the destination channel" do + move! + moved_messages = + ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + expect(moved_messages.map(&:message)).to eq( + ["the first to be moved", "message deux @testmovechat", "the third message"], + ) + end + + it "updates references for reactions, uploads, revisions, mentions, etc." do + reaction = Fabricate(:chat_message_reaction, chat_message: message1) + upload = Fabricate(:chat_upload, chat_message: message1) + mention = Fabricate(:chat_mention, chat_message: message2, user: acting_user) + revision = Fabricate(:chat_message_revision, chat_message: message3) + webhook_event = Fabricate(:chat_webhook_event, chat_message: message3) + move! + + moved_messages = + ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + expect(reaction.reload.chat_message_id).to eq(moved_messages.first.id) + expect(upload.reload.chat_message_id).to eq(moved_messages.first.id) + expect(mention.reload.chat_message_id).to eq(moved_messages.second.id) + expect(revision.reload.chat_message_id).to eq(moved_messages.third.id) + expect(webhook_event.reload.chat_message_id).to eq(moved_messages.third.id) + end + end +end diff --git a/plugins/chat/spec/lib/post_notification_handler_spec.rb b/plugins/chat/spec/lib/post_notification_handler_spec.rb new file mode 100644 index 0000000000..620fe991e0 --- /dev/null +++ b/plugins/chat/spec/lib/post_notification_handler_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::PostNotificationHandler do + let(:acting_user) { Fabricate(:user) } + let(:post) { Fabricate(:post) } + let(:notified_users) { [] } + let(:subject) { Chat::PostNotificationHandler.new(post, notified_users) } + + fab!(:channel) { Fabricate(:category_channel) } + fab!(:message1) do + Fabricate(:chat_message, chat_channel: channel, message: "hey this is the first message :)") + end + fab!(:message2) do + Fabricate( + :chat_message, + chat_channel: channel, + message: "our true enemy. has yet. to reveal himself.", + ) + end + + before { Notification.destroy_all } + + def expect_no_notification + return_val = nil + expect { return_val = subject.handle }.not_to change { Notification.count } + expect(return_val).to eq(false) + end + + def update_post_with_chat_quote(messages) + quote_markdown = + ChatTranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown + post.update!(raw: post.raw + "\n\n" + quote_markdown) + end + + it "does nothing if the post is a whisper" do + post.update(post_type: Post.types[:whisper]) + expect_no_notification + end + + it "does nothing if the topic is deleted" do + post.topic.destroy && post.reload + expect_no_notification + end + + it "does nothing if the topic is a private message" do + post.update(topic: Fabricate(:private_message_topic)) + expect_no_notification + end + + it "sends notifications to all of the quoted users" do + update_post_with_chat_quote([message1, message2]) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + expect( + Notification.where( + user: message2.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + + it "does not send the same chat_quoted notification twice to the same post and user" do + update_post_with_chat_quote([message1, message2]) + subject.handle + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + + it "does not send a notification if the user has got a reply notification to the quoted user for the same post" do + update_post_with_chat_quote([message1, message2]) + Fabricate( + :notification, + notification_type: Notification.types[:replied], + post_number: post.post_number, + topic: post.topic, + user: message1.user, + ) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(0) + end + + context "when some users have already been notified for the post" do + let(:notified_users) { [message1.user] } + + it "does not send notifications to those users" do + update_post_with_chat_quote([message1, message2]) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(0) + expect( + Notification.where( + user: message2.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + end +end diff --git a/plugins/chat/spec/lib/slack_compatibility_spec.rb b/plugins/chat/spec/lib/slack_compatibility_spec.rb new file mode 100644 index 0000000000..1e8c550f08 --- /dev/null +++ b/plugins/chat/spec/lib/slack_compatibility_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::SlackCompatibility do + describe "#process_text" do + it "converts mrkdwn links to regular markdown" do + text = described_class.process_text("this is some text ") + expect(text).to eq("this is some text https://discourse.org") + end + + it "converts mrkdwn links with titles to regular markdown" do + text = + described_class.process_text("this is some text ") + expect(text).to eq("this is some text [Discourse Forums](https://discourse.org)") + end + + it "handles multiple links" do + text = + described_class.process_text( + "this is some text with a second link to ", + ) + expect(text).to eq( + "this is some text [Discourse Forums](https://discourse.org) with a second link to https://discourse.org/team", + ) + end + + it "converts and to our mention format" do + text = described_class.process_text(" this is some important stuff ") + expect(text).to eq("@here this is some important stuff @all") + end + end +end diff --git a/plugins/chat/spec/mailers/user_notifications_spec.rb b/plugins/chat/spec/mailers/user_notifications_spec.rb new file mode 100644 index 0000000000..d872efdab5 --- /dev/null +++ b/plugins/chat/spec/mailers/user_notifications_spec.rb @@ -0,0 +1,475 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UserNotifications do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } + fab!(:user) { Fabricate(:user, group_ids: [chatters_group.id]) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = chatters_group.id + end + + def refresh_auto_groups + Group.refresh_automatic_groups! + user.reload + sender.reload + end + + describe ".chat_summary" do + context "with private channel" do + fab!(:channel) do + refresh_auto_groups + Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user]) + end + + describe "email subject" do + it "includes the sender username in the subject" do + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.direct_message", + count: 1, + email_prefix: SiteSetting.title, + message_title: sender.username, + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(sender.username) + end + + it "only includes the name of the user who sent the message even if the DM has multiple participants" do + another_participant = Fabricate(:user, group_ids: [chatters_group.id]) + Fabricate( + :user_chat_channel_membership_for_dm, + user: another_participant, + chat_channel: channel, + ) + DirectMessageUser.create!(direct_message: channel.chatable, user: another_participant) + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.direct_message", + count: 1, + email_prefix: SiteSetting.title, + message_title: sender.username, + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(sender.username) + expect(email.subject).not_to include(another_participant.username) + end + + it "includes both channel titles when there are exactly two with unread messages" do + another_dm_user = Fabricate(:user, group_ids: [chatters_group.id]) + refresh_auto_groups + another_dm_user.reload + another_channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: user, + target_users: [another_dm_user, user], + ) + Fabricate(:chat_message, user: another_dm_user, chat_channel: another_channel) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(sender.username) + expect(email.subject).to include(another_dm_user.username) + end + + it "displays a count when there are more than two DMs with unread messages" do + user = Fabricate(:user, group_ids: [chatters_group.id]) + + 3.times do + sender = Fabricate(:user, group_ids: [chatters_group.id]) + refresh_auto_groups + sender.reload + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [user, sender], + ) + user + .user_chat_channel_memberships + .where(chat_channel_id: channel.id) + .update!(following: true) + + Fabricate(:chat_message, user: sender, chat_channel: channel) + end + + expected_count_text = I18n.t("user_notifications.chat_summary.subject.others", count: 2) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_count_text) + end + + it "returns an email if the user is not following the direct channel" do + user + .user_chat_channel_memberships + .where(chat_channel_id: channel.id) + .update!(following: false) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + end + end + + context "with public channel" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, user: sender, chat_channel: channel) } + fab!(:user_membership) do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user, + last_read_message_id: chat_message.id - 2, + ) + end + + it "doesn't return an email if there are no unread mentions" do + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + describe "email subject" do + context "with regular mentions" do + before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + + it "includes the sender username in the subject" do + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.chat_channel", + count: 1, + email_prefix: SiteSetting.title, + message_title: channel.title(user), + ) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(channel.title(user)) + end + + it "includes both channel titles when there are exactly two with unread mentions" do + another_chat_channel = Fabricate(:category_channel, name: "Test channel") + another_chat_message = + Fabricate(:chat_message, user: sender, chat_channel: another_chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: sender, + ) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: user, + last_read_message_id: another_chat_message.id - 2, + ) + Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(channel.title(user)) + expect(email.subject).to include(another_chat_channel.title(user)) + end + + it "displays a count when there are more than two channels with unread mentions" do + 2.times do |n| + another_chat_channel = Fabricate(:category_channel, name: "Test channel #{n}") + another_chat_message = + Fabricate(:chat_message, user: sender, chat_channel: another_chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: sender, + ) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: user, + last_read_message_id: another_chat_message.id - 2, + ) + Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + end + expected_count_text = I18n.t("user_notifications.chat_summary.subject.others", count: 2) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_count_text) + end + end + + context "with both unread DM messages and mentions" do + before do + refresh_auto_groups + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [sender, user], + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: chat_message) + end + + it "always includes the DM second" do + expected_other_text = + I18n.t( + "user_notifications.chat_summary.subject.other_direct_message", + message_title: sender.username, + ) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_other_text) + end + end + end + + describe "When there are mentions" do + before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + + describe "selecting mentions" do + it "doesn't return an email if the user can't see chat" do + SiteSetting.chat_allowed_groups = "" + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the user can't see any of the included channels" do + channel.chatable.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the user is not following the channel" do + user_membership.update!(following: false) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the membership object doesn't exist" do + user_membership.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the sender was deleted" do + sender.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email when the user already saw the mention" do + user_membership.update!(last_read_message_id: chat_message.id) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "returns an email when the user haven't read a message yet" do + user_membership.update!(last_read_message_id: nil) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "doesn't return an email when the unread count belongs to a different channel" do + user_membership.update!(last_read_message_id: chat_message.id) + second_channel = Fabricate(:chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: second_channel, + user: user, + last_read_message_id: chat_message.id - 1, + ) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the message was deleted" do + chat_message.trash! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "returns an email when the user has unread private messages" do + user_membership.update!(last_read_message_id: chat_message.id) + refresh_auto_groups + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [sender, user], + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "returns an email if the user read all the messages included in the previous summary" do + user_membership.update!( + last_read_message_id: chat_message.id, + last_unread_mention_when_emailed_id: chat_message.id, + ) + + new_message = Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: new_message) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "doesn't return an email if the mention is older than 1 week" do + chat_message.update!(created_at: 1.5.weeks.ago) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + end + + describe "mail contents" do + it "returns an email when the user has unread mentions" do + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + expect(email.html_part.body.to_s).to include(chat_message.cooked_for_excerpt) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("src").value).to eq(sender.small_avatar_url) + expect(user_avatar.attribute("alt").value).to eq(sender.username) + + more_messages_channel_link = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".more-messages-link") + + expect(more_messages_channel_link.attribute("href").value).to eq(chat_message.full_url) + expect(more_messages_channel_link.text).to include( + I18n.t("user_notifications.chat_summary.view_messages", count: 1), + ) + end + + it "displays the sender's name when the site is configured to prioritize it" do + SiteSetting.enable_names = true + SiteSetting.prioritize_username_in_ux = false + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.name) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.name) + end + + it "displays the sender's username when the site is configured to prioritize it" do + SiteSetting.enable_names = true + SiteSetting.prioritize_username_in_ux = true + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "displays the sender's username when names are disabled" do + SiteSetting.enable_names = false + SiteSetting.prioritize_username_in_ux = false + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "displays the sender's username when the site is configured to prioritize it" do + SiteSetting.enable_names = false + SiteSetting.prioritize_username_in_ux = true + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "includes a view more link when there are more than two mentions" do + 2.times do + msg = Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: msg) + end + + email = described_class.chat_summary(user, {}) + more_messages_channel_link = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".more-messages-link") + + expect(more_messages_channel_link.attribute("href").value).to eq(chat_message.full_url) + expect(more_messages_channel_link.text).to include( + I18n.t("user_notifications.chat_summary.view_more", count: 1), + ) + end + + it "doesn't repeat mentions we already sent" do + user_membership.update!( + last_read_message_id: chat_message.id - 1, + last_unread_mention_when_emailed_id: chat_message.id, + ) + + new_message = + Fabricate(:chat_message, user: sender, chat_channel: channel, cooked: "New message") + Fabricate(:chat_mention, user: user, chat_message: new_message) + + email = described_class.chat_summary(user, {}) + body = email.html_part.body.to_s + + expect(body).not_to include(chat_message.cooked_for_excerpt) + expect(body).to include(new_message.cooked_for_excerpt) + end + end + end + end + end +end diff --git a/plugins/chat/spec/models/category_channel_spec.rb b/plugins/chat/spec/models/category_channel_spec.rb new file mode 100644 index 0000000000..89c950f5f7 --- /dev/null +++ b/plugins/chat/spec/models/category_channel_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +RSpec.describe CategoryChannel do + subject(:channel) { Fabricate.build(:category_channel) } + + it_behaves_like "a chat channel model" + + it { is_expected.to delegate_method(:read_restricted?).to(:category) } + it { is_expected.to delegate_method(:url).to(:chatable).with_prefix } + + describe "#category_channel?" do + it "always returns true" do + expect(channel).to be_a_category_channel + end + end + + describe "#public_channel?" do + it "always returns true" do + expect(channel).to be_a_public_channel + end + end + + describe "#chatable_has_custom_fields?" do + it "always returns true" do + expect(channel).to be_a_chatable_has_custom_fields + end + end + + describe "#direct_message_channel?" do + it "always returns false" do + expect(channel).not_to be_a_direct_message_channel + end + end + + describe "#allowed_user_ids" do + it "always returns nothing" do + expect(channel.allowed_user_ids).to be_nil + end + end + + describe "#allowed_group_ids" do + subject(:allowed_group_ids) { channel.allowed_group_ids } + + context "when channel is public" do + let(:public_category) { Fabricate(:category, read_restricted: false) } + let(:channel) { Fabricate(:category_channel, chatable: public_category) } + + it "returns nothing" do + expect(allowed_group_ids).to be_nil + end + end + + context "when channel is not public" do + let(:staff_groups) { Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values } + let(:group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: group) } + let(:channel) { Fabricate(:category_channel, chatable: private_category) } + + it "returns groups with access to the associated category" do + expect(allowed_group_ids).to contain_exactly(*staff_groups, group.id) + end + end + end + + describe "#title" do + subject(:title) { channel.title(nil) } + + before { channel.name = custom_name } + + context "when 'name' is set" do + let(:custom_name) { "a custom name" } + + it "returns the name that has been set on the channel" do + expect(title).to eq(custom_name) + end + end + + context "when 'name' is not set" do + let(:custom_name) { nil } + + it "returns the name from the associated category" do + expect(title).to eq(channel.category.name) + end + end + end + + describe "slug generation" do + subject(:channel) { Fabricate(:category_channel) } + + context "when slug is not provided" do + before do + channel.slug = nil + end + + it "uses channel name when present" do + channel.name = "Some Cool Stuff" + channel.validate! + expect(channel.slug).to eq("some-cool-stuff") + end + + it "uses category name when present" do + channel.name = nil + channel.category.name = "some category stuff" + channel.validate! + expect(channel.slug).to eq("some-category-stuff") + end + end + + context "when slug is provided" do + context "when using encoded slug generator" do + before do + SiteSetting.slug_generation_method = "encoded" + channel.slug = "测试" + end + after { SiteSetting.slug_generation_method = "ascii" } + + it "creates a slug with the correct escaping" do + channel.validate! + expect(channel.slug).to eq("%E6%B5%8B%E8%AF%95") + end + end + + context "when slug ends up blank" do + it "adds a validation error" do + channel.slug = "-" + channel.validate + expect(channel.errors.full_messages).to include("Slug is invalid") + end + end + + context "when there is a duplicate slug" do + before { Fabricate(:category_channel, slug: "awesome-channel") } + + it "adds a validation error" do + channel.slug = "awesome-channel" + channel.validate + expect(channel.errors.full_messages.first).to include(I18n.t("chat.category_channel.errors.is_already_in_use")) + end + end + + context "if SiteSettings.slug_generation_method = ascii" do + before { SiteSetting.slug_generation_method = "ascii" } + + it "fails if slug contains non-ascii characters" do + channel.slug = "sem-acentuação" + channel.validate + expect(channel.errors.full_messages.first).to match(/#{I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")}/) + end + end + end + end +end diff --git a/plugins/chat/spec/models/category_spec.rb b/plugins/chat/spec/models/category_spec.rb new file mode 100644 index 0000000000..5a68f6449b --- /dev/null +++ b/plugins/chat/spec/models/category_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Category do + it_behaves_like "a chatable model" do + fab!(:chatable) { Fabricate(:category) } + let(:channel_class) { CategoryChannel } + end + + it { is_expected.to have_one(:category_channel) } + + describe "#cannot_delete_reason" do + subject(:reason) { category.cannot_delete_reason } + + context "when a chat channel is present" do + let(:channel) { Fabricate(:category_channel) } + let(:category) { channel.chatable } + + it "returns a message" do + expect(reason).to match I18n.t("category.cannot_delete.has_chat_channels") + end + end + end + + describe "#deletable_for_chat?" do + subject(:category) { Fabricate.build(:category) } + + context "when no category channel is present" do + it "returns true" do + expect(category).to be_deletable_for_chat + end + end + + context "when a category channel is present" do + let(:channel) { Fabricate(:category_channel) } + let(:category) { channel.chatable } + + context "when it has chat messages" do + before { Fabricate(:chat_message, chat_channel: channel) } + + it "returns false" do + expect(category).not_to be_deletable_for_chat + end + end + + context "when it has no chat messages" do + it "returns true" do + expect(category).to be_deletable_for_chat + end + end + end + end +end diff --git a/plugins/chat/spec/models/chat_channel_spec.rb b/plugins/chat/spec/models/chat_channel_spec.rb new file mode 100644 index 0000000000..475390a07a --- /dev/null +++ b/plugins/chat/spec/models/chat_channel_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe ChatChannel do + fab!(:category_channel) { Fabricate(:category_channel) } + fab!(:dm_channel) { Fabricate(:direct_message_channel) } + + describe "#relative_url" do + context "when the slug is nil" do + it "uses a - instead" do + category_channel.slug = nil + expect(category_channel.relative_url).to eq("/chat/channel/#{category_channel.id}/-") + end + end + + context "when the slug is not nil" do + before do + category_channel.update!(slug: "some-cool-channel") + end + + it "includes the slug for the channel" do + expect(category_channel.relative_url).to eq("/chat/channel/#{category_channel.id}/some-cool-channel") + end + end + end +end diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat_message_spec.rb new file mode 100644 index 0000000000..d1a62d7e85 --- /dev/null +++ b/plugins/chat/spec/models/chat_message_spec.rb @@ -0,0 +1,487 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessage do + fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") } + + describe ".cook" do + it "does not support HTML tags" do + cooked = ChatMessage.cook("

    test

    ") + + expect(cooked).to eq("

    <h1>test</h1>

    ") + end + + it "does not support headings" do + cooked = ChatMessage.cook("## heading 2") + + expect(cooked).to eq("

    ## heading 2

    ") + end + + it "does not support horizontal rules" do + cooked = ChatMessage.cook("---") + + expect(cooked).to eq("

    ---

    ") + end + + it "supports backticks rule" do + cooked = ChatMessage.cook("`test`") + + expect(cooked).to eq("

    test

    ") + end + + it "supports fence rule" do + cooked = ChatMessage.cook(<<~RAW) + ``` + something = test + ``` + RAW + + expect(cooked).to eq(<<~COOKED.chomp) +
    something = test
    +      
    + COOKED + end + + it "supports fence rule with language support" do + cooked = ChatMessage.cook(<<~RAW) + ```ruby + Widget.triangulate(argument: "no u") + ``` + RAW + + expect(cooked).to eq(<<~COOKED.chomp) +
    Widget.triangulate(argument: "no u")
    +      
    + COOKED + end + + it "supports code rule" do + cooked = ChatMessage.cook(" something = test") + + expect(cooked).to eq("
    something = test\n
    ") + end + + it "supports blockquote rule" do + cooked = ChatMessage.cook("> a quote") + + expect(cooked).to eq("
    \n

    a quote

    \n
    ") + end + + it "supports quote bbcode" do + topic = Fabricate(:topic, title: "Some quotable topic") + post = Fabricate(:post, topic: topic) + SiteSetting.external_system_avatars_enabled = false + avatar_src = + "//test.localhost#{User.system_avatar_template(post.user.username).gsub("{size}", "40")}" + + cooked = ChatMessage.cook(<<~RAW) + [quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"] + Mark me...this will go down in history. + [/quote] + RAW + + expect(cooked).to eq(<<~COOKED.chomp) + + COOKED + end + + it "supports chat quote bbcode" do + chat_channel = Fabricate(:category_channel, name: "testchannel") + user = Fabricate(:user, username: "chatbbcodeuser") + user2 = Fabricate(:user, username: "otherbbcodeuser") + avatar_src = + "//test.localhost#{User.system_avatar_template(user.username).gsub("{size}", "40")}" + avatar_src2 = + "//test.localhost#{User.system_avatar_template(user2.username).gsub("{size}", "40")}" + msg1 = + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "this is the first message", + user: user, + ) + msg2 = + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "and another cool one", + user: user2, + ) + other_messages_to_quote = [msg1, msg2] + cooked = + ChatMessage.cook( + ChatTranscriptService.new( + chat_channel, + Fabricate(:user), + messages_or_ids: other_messages_to_quote.map(&:id), + ).generate_markdown, + ) + + expect(cooked).to eq(<<~COOKED.chomp) +
    +
    + Originally sent in testchannel +
    +
    +
    + +
    +
    + chatbbcodeuser
    +
    + +
    +
    +
    +

    this is the first message

    +
    +
    +
    +
    +
    + +
    +
    + otherbbcodeuser
    +
    + +
    +
    +
    +

    and another cool one

    +
    +
    + COOKED + end + + it "supports strikethrough rule" do + cooked = ChatMessage.cook("~~test~~") + + expect(cooked).to eq("

    test

    ") + end + + it "supports emphasis rule" do + cooked = ChatMessage.cook("**bold**") + + expect(cooked).to eq("

    bold

    ") + end + + it "supports link markdown rule" do + chat_message = Fabricate(:chat_message, message: "[test link](https://www.example.com)") + + expect(chat_message.cooked).to eq( + "

    test link

    ", + ) + end + + it "supports table markdown plugin" do + cooked = ChatMessage.cook(<<~RAW) + | Command | Description | + | --- | --- | + | git status | List all new or modified files | + RAW + + expected = <<~COOKED +
    + + + + + + + + + + + + + +
    CommandDescription
    git statusList all new or modified files
    +
    + COOKED + + expect(cooked).to eq(expected.chomp) + end + + it "supports onebox markdown plugin" do + cooked = ChatMessage.cook("https://www.example.com") + + expect(cooked).to eq( + "

    https://www.example.com

    ", + ) + end + + it "supports emoji plugin" do + cooked = ChatMessage.cook(":grin:") + + expect(cooked).to eq( + "

    \":grin:\"

    ", + ) + end + + it "supports mentions plugin" do + cooked = ChatMessage.cook("@mention") + + expect(cooked).to eq("

    @mention

    ") + end + + it "supports category-hashtag plugin" do + category = Fabricate(:category) + + cooked = ChatMessage.cook("##{category.slug}") + + expect(cooked).to eq( + "

    ##{category.slug}

    ", + ) + end + + it "supports censored plugin" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) + + cooked = ChatMessage.cook(watched_word.word) + + expect(cooked).to eq("

    ■■■■■

    ") + end + + it "includes links in pretty text excerpt if the raw message is a single link and the PrettyText excerpt is blank" do + message = + Fabricate.build( + :chat_message, + message: "https://twitter.com/EffinBirds/status/1518743508378697729", + ) + expect(message.excerpt).to eq("https://twitter.com/EffinBirds/status/1518743508378697729") + message = + Fabricate.build( + :chat_message, + message: "https://twitter.com/EffinBirds/status/1518743508378697729", + cooked: <<~COOKED, + \n + COOKED + ) + expect(message.excerpt).to eq("https://twitter.com/EffinBirds/status/1518743508378697729") + message = + Fabricate.build( + :chat_message, + message: + "wow check out these birbs https://twitter.com/EffinBirds/status/1518743508378697729", + ) + expect(message.excerpt).to eq( + "wow check out these birbs https://twitter.com/Effi…", + ) + end + + it "returns an empty string if PrettyText.excerpt returns empty string" do + message = Fabricate(:chat_message, message: <<~MSG) + [quote="martin, post:30, topic:3179, full:true"] + This is a real **quote** topic with some *markdown* in it I can quote. + [/quote] + MSG + expect(message.excerpt).to eq("") + end + + it "excerpts upload file name if message is empty" do + gif = + Fabricate(:upload, original_filename: "cat.gif", width: 400, height: 300, extension: "gif") + message = Fabricate(:chat_message, message: "") + ChatUpload.create(chat_message: message, upload: gif) + + expect(message.excerpt).to eq "cat.gif" + end + + it "supports autolink with <>" do + cooked = ChatMessage.cook("") + + expect(cooked).to eq( + "

    https://github.com/discourse/discourse-chat/pull/468

    ", + ) + end + + it "supports lists" do + cooked = ChatMessage.cook(<<~MSG) + wow look it's a list + + * item 1 + * item 2 + MSG + + expect(cooked).to eq(<<~HTML.chomp) +

    wow look it's a list

    +
      +
    • item 1
    • +
    • item 2
    • +
    + HTML + end + + it "supports inline emoji" do + cooked = ChatMessage.cook(":D") + expect(cooked).to eq(<<~HTML.chomp) +

    :smiley:

    + HTML + end + + it "supports emoji shortcuts" do + cooked = ChatMessage.cook("this is a replace test :P :|") + expect(cooked).to eq(<<~HTML.chomp) +

    this is a replace test :stuck_out_tongue: :expressionless:

    + HTML + end + + it "supports spoilers" do + if SiteSetting.respond_to?(:spoiler_enabled) && SiteSetting.spoiler_enabled + cooked = ChatMessage.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") + + expect(cooked).to eq( + "
    \n

    the planet of the apes was earth all along

    \n
    ", + ) + end + end + + context "when unicode usernames are enabled" do + before { SiteSetting.unicode_usernames = true } + + it "cooks unicode mentions" do + user = Fabricate(:unicode_user) + cooked = ChatMessage.cook("

    @#{user.username}

    ") + + expect(cooked).to eq("

    <h1>@#{user.username}</h1>

    ") + end + end + end + + describe ".to_markdown" do + it "renders the message without uploads" do + expect(message.to_markdown).to eq("hey friend, what's up?!") + end + + it "renders the message with uploads" do + image = + Fabricate( + :upload, + original_filename: "test_image.jpg", + width: 400, + height: 300, + extension: "jpg", + ) + image2 = + Fabricate(:upload, original_filename: "meme.jpg", width: 10, height: 10, extension: "jpg") + ChatUpload.create(chat_message: message, upload: image) + ChatUpload.create(chat_message: message, upload: image2) + expect(message.to_markdown).to eq(<<~MSG.chomp) + hey friend, what's up?! + + ![test_image.jpg|400x300](#{image.short_url}) + ![meme.jpg|10x10](#{image2.short_url}) + MSG + end + end + + describe ".push_notification_excerpt" do + it "truncates to 400 characters" do + message = ChatMessage.new(message: "Hello, World!" * 40) + expect(message.push_notification_excerpt.size).to eq(400) + end + + it "encodes emojis" do + message = ChatMessage.new(message: ":grinning:") + expect(message.push_notification_excerpt).to eq("😀") + end + end + + describe "blocking duplicate messages" do + fab!(:channel) { Fabricate(:chat_channel, user_count: 10) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + before { SiteSetting.chat_duplicate_message_sensitivity = 1 } + + it "blocks duplicate messages for the message, channel user, and message age requirements" do + Fabricate(:chat_message, message: "this is duplicate", chat_channel: channel, user: user1) + message = ChatMessage.new(message: "this is duplicate", chat_channel: channel, user: user2) + message.validate_message(has_uploads: false) + expect(message.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message")) + end + end + + describe "#destroy" do + it "nullify messages with in_reply_to_id to this destroyed message" do + message_1 = Fabricate(:chat_message) + message_2 = Fabricate(:chat_message, in_reply_to_id: message_1.id) + message_3 = Fabricate(:chat_message, in_reply_to_id: message_2.id) + + expect(message_2.in_reply_to_id).to eq(message_1.id) + + message_1.destroy! + + expect(message_2.reload.in_reply_to_id).to be_nil + expect(message_3.reload.in_reply_to_id).to eq(message_2.id) + end + + it "destroys chat_message_revisions" do + message_1 = Fabricate(:chat_message) + revision_1 = Fabricate(:chat_message_revision, chat_message: message_1) + + message_1.destroy! + + expect { revision_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_message_reactions" do + message_1 = Fabricate(:chat_message) + reaction_1 = Fabricate(:chat_message_reaction, chat_message: message_1) + + message_1.destroy! + + expect { reaction_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_mention" do + message_1 = Fabricate(:chat_message) + mention_1 = Fabricate(:chat_mention, chat_message: message_1) + + message_1.destroy! + + expect { mention_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_webhook_event" do + message_1 = Fabricate(:chat_message) + webhook_1 = Fabricate(:chat_webhook_event, chat_message: message_1) + + message_1.destroy! + + expect { webhook_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_uploads" do + message_1 = Fabricate(:chat_message) + chat_upload_1 = Fabricate(:chat_upload, chat_message: message_1) + + message_1.destroy! + + expect { chat_upload_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + describe "bookmarks" do + before { Bookmark.register_bookmarkable(ChatMessageBookmarkable) } + + it "destroys bookmarks" do + message_1 = Fabricate(:chat_message) + bookmark_1 = Fabricate(:bookmark, bookmarkable: message_1) + + message_1.destroy! + + expect { bookmark_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/plugins/chat/spec/models/deleted_chat_user_spec.rb b/plugins/chat/spec/models/deleted_chat_user_spec.rb new file mode 100644 index 0000000000..92617c58f7 --- /dev/null +++ b/plugins/chat/spec/models/deleted_chat_user_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DeletedChatUser do + describe "#username" do + it "returns a default username" do + expect(subject.username).to eq(I18n.t("chat.deleted_chat_username")) + end + end + + describe "#avatar_template" do + it "returns a default path" do + expect(subject.avatar_template).to eq( + "/plugins/chat/images/deleted-chat-user-avatar.png", + ) + end + end +end diff --git a/plugins/chat/spec/models/direct_message_channel_spec.rb b/plugins/chat/spec/models/direct_message_channel_spec.rb new file mode 100644 index 0000000000..227a143d60 --- /dev/null +++ b/plugins/chat/spec/models/direct_message_channel_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe DirectMessageChannel do + subject(:channel) { Fabricate.build(:direct_message_channel) } + + it_behaves_like "a chat channel model" + + it { is_expected.to delegate_method(:allowed_user_ids).to(:direct_message).as(:user_ids) } + + describe "#category_channel?" do + it "always returns false" do + expect(channel).not_to be_a_category_channel + end + end + + describe "#public_channel?" do + it "always returns false" do + expect(channel).not_to be_a_public_channel + end + end + + describe "#chatable_has_custom_fields?" do + it "always returns false" do + expect(channel).not_to be_a_chatable_has_custom_fields + end + end + + describe "#direct_message_channel?" do + it "always returns true" do + expect(channel).to be_a_direct_message_channel + end + end + + describe "#read_restricted?" do + it "always returns true" do + expect(channel).to be_read_restricted + end + end + + describe "#allowed_group_ids" do + it "always returns nothing" do + expect(channel.allowed_group_ids).to be_nil + end + end + + describe "#chatable_url" do + it "always returns nothing" do + expect(channel.chatable_url).to be_nil + end + end + + describe "#title" do + subject(:title) { channel.title(user) } + + let(:user) { stub } + let(:direct_message) { channel.direct_message } + + it "delegates to direct_message" do + direct_message.expects(:chat_channel_title_for_user).with(channel, user).returns("something") + expect(title).to eq("something") + end + end + + describe "slug generation" do + subject(:channel) { Fabricate(:direct_message_channel) } + + it "always sets the slug to nil for direct message channels" do + channel.name = "Cool Channel" + channel.validate! + expect(channel.slug).to eq(nil) + end + end +end diff --git a/plugins/chat/spec/models/direct_message_spec.rb b/plugins/chat/spec/models/direct_message_spec.rb new file mode 100644 index 0000000000..9e44e51cd5 --- /dev/null +++ b/plugins/chat/spec/models/direct_message_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DirectMessage do + fab!(:user1) { Fabricate(:user, username: "chatdmfellow1") } + fab!(:user2) { Fabricate(:user, username: "chatdmuser") } + fab!(:chat_channel) { Fabricate(:direct_message_channel) } + + it_behaves_like "a chatable model" do + fab!(:chatable) { Fabricate(:direct_message) } + let(:channel_class) { DirectMessageChannel } + end + + describe "#chat_channel_title_for_user" do + it "returns a nicely formatted name if it's more than one user" do + user3 = Fabricate.build(:user, username: "chatdmregent") + direct_message = Fabricate(:direct_message, users: [user1, user2, user3]) + + expect(direct_message.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t( + "chat.channel.dm_title.multi_user", + users: [user3, user2].map { |u| "@#{u.username}" }.join(", "), + ), + ) + end + + it "returns a nicely formatted truncated name if it's more than 5 users" do + user3 = Fabricate.build(:user, username: "chatdmregent") + + users = [user1, user2, user3].concat( + 5.times.map.with_index { |i| Fabricate(:user, username: "chatdmuser#{i}") }, + ) + direct_message = Fabricate(:direct_message, users: users) + + expect(direct_message.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + users: users[1..5].sort_by(&:username).map { |u| "@#{u.username}" }.join(", "), + leftover: 2, + ), + ) + end + + it "returns the other user's username if it's a dm to that user" do + direct_message = Fabricate(:direct_message, users: [user1, user2]) + + expect(direct_message.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t("chat.channel.dm_title.single_user", user: "@#{user2.username}"), + ) + end + + it "returns the current user's username if it's a dm to self" do + direct_message = Fabricate(:direct_message, users: [user1]) + + expect(direct_message.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t("chat.channel.dm_title.single_user", user: "@#{user1.username}"), + ) + end + + context "when user is deleted" do + it "returns a placeholder username" do + direct_message = Fabricate(:direct_message, users: [user1, user2]) + user2.destroy! + direct_message.reload + + expect(direct_message.chat_channel_title_for_user(chat_channel, user1)).to eq( + "@#{I18n.t("chat.deleted_chat_username")}", + ) + end + end + end +end diff --git a/plugins/chat/spec/models/reviewable_chat_message_spec.rb b/plugins/chat/spec/models/reviewable_chat_message_spec.rb new file mode 100644 index 0000000000..b35b2b572b --- /dev/null +++ b/plugins/chat/spec/models/reviewable_chat_message_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ReviewableChatMessage, type: :model do + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:chat_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + fab!(:reviewable) do + Fabricate(:reviewable_chat_message, target: chat_message, created_by: moderator) + end + + it "agree_and_keep agrees with the flag and doesn't delete the message" do + reviewable.perform(moderator, :agree_and_keep_message) + + expect(reviewable).to be_approved + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "agree_and_delete agrees with the flag and deletes the message" do + chat_message_id = chat_message.id + reviewable.perform(moderator, :agree_and_delete) + + expect(reviewable).to be_approved + expect(ChatMessage.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present + end + + it "agree_and_restore agrees with the flag and restores the message" do + chat_message.trash!(user) + reviewable.perform(moderator, :agree_and_restore) + + expect(reviewable).to be_approved + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "perform_disagree disagrees with the flag and does nothing" do + reviewable.perform(moderator, :disagree) + + expect(reviewable).to be_rejected + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "perform_disagree_and_restore disagrees with the flag and does nothing" do + chat_message.trash!(user) + reviewable.perform(moderator, :disagree_and_restore) + + expect(reviewable).to be_rejected + expect(chat_message.reload.deleted_at).to be_present + end + + it "perform_ignore ignores the flag and does nothing" do + reviewable.perform(moderator, :ignore) + + expect(reviewable).to be_ignored + expect(chat_message.reload.deleted_at).not_to be_present + end +end diff --git a/plugins/chat/spec/models/user_spec.rb b/plugins/chat/spec/models/user_spec.rb new file mode 100644 index 0000000000..9d02f0701d --- /dev/null +++ b/plugins/chat/spec/models/user_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe User do + it { is_expected.to have_many(:user_chat_channel_memberships).dependent(:destroy) } + it { is_expected.to have_many(:chat_message_reactions).dependent(:destroy) } + it { is_expected.to have_many(:chat_mentions) } +end diff --git a/plugins/chat/spec/plugin_spec.rb b/plugins/chat/spec/plugin_spec.rb new file mode 100644 index 0000000000..fdf17580d0 --- /dev/null +++ b/plugins/chat/spec/plugin_spec.rb @@ -0,0 +1,424 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat do + before do + SiteSetting.clean_up_uploads = true + SiteSetting.clean_orphan_uploads_grace_period_hours = 1 + Jobs::CleanUpUploads.new.reset_last_cleanup! + SiteSetting.chat_enabled = true + end + + describe "register_upload_unused" do + fab!(:chat_channel) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:user) { Fabricate(:user) } + fab!(:upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world!", + upload_ids: [upload.id], + ) + end + + it "marks uploads with ChatUpload in use" do + unused_upload + + expect { Jobs::CleanUpUploads.new.execute({}) }.to change { Upload.count }.by(-1) + expect(Upload.exists?(id: upload.id)).to eq(true) + expect(Upload.exists?(id: unused_upload.id)).to eq(false) + end + end + + describe "register_upload_in_use" do + fab!(:chat_channel) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:user) { Fabricate(:user) } + fab!(:message_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:draft_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world! #{message_upload.sha1}", + upload_ids: [], + ) + end + + let!(:draft_message) do + ChatDraft.create!( + user: user, + chat_channel: chat_channel, + data: + "{\"value\":\"hello world \",\"uploads\":[\"#{draft_upload.sha1}\"],\"replyToMsg\":null}", + ) + end + + it "marks uploads with ChatUpload in use" do + draft_upload + unused_upload + + expect { Jobs::CleanUpUploads.new.execute({}) }.to change { Upload.count }.by(-1) + expect(Upload.exists?(id: message_upload.id)).to eq(true) + expect(Upload.exists?(id: draft_upload.id)).to eq(true) + expect(Upload.exists?(id: unused_upload.id)).to eq(false) + end + end + + describe "user card serializer extension #can_chat_user" do + fab!(:target_user) { Fabricate(:user) } + let!(:user) { Fabricate(:user) } + let!(:guardian) { Guardian.new(user) } + let(:serializer) { UserCardSerializer.new(target_user, scope: guardian) } + fab!(:group) { Fabricate(:group) } + + context "when chat enabled" do + before { SiteSetting.chat_enabled = true } + + it "returns true if the target user and the guardian user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: target_user, group: group) + GroupUser.create(user: user, group: group) + expect(serializer.can_chat_user).to eq(true) + end + + it "returns false if the target user but not the guardian user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: target_user, group: group) + expect(serializer.can_chat_user).to eq(false) + end + + it "returns false if the guardian user but not the target user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + expect(serializer.can_chat_user).to eq(false) + end + + context "when guardian user is same as target user" do + let!(:guardian) { Guardian.new(target_user) } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + + context "when guardian user is anon" do + let!(:guardian) { Guardian.new } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + end + + context "when chat not enabled" do + before { SiteSetting.chat_enabled = false } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + end + + describe "chat oneboxes" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, active: true) } + fab!(:user_2) { Fabricate(:user, active: false) } + fab!(:user_3) { Fabricate(:user, staged: true) } + fab!(:user_4) { Fabricate(:user, suspended_till: 3.weeks.from_now) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world!", + upload_ids: [], + ).chat_message + end + + let(:chat_url) { "#{Discourse.base_url}/chat/channel/#{chat_channel.id}" } + + context "when inline" do + it "renders channel" do + results = InlineOneboxer.new([chat_url], skip_cache: true).process + expect(results).to be_present + expect(results[0][:url]).to eq(chat_url) + expect(results[0][:title]).to eq("Chat ##{chat_channel.name}") + end + + it "renders messages" do + results = + InlineOneboxer.new(["#{chat_url}?messageId=#{chat_message.id}"], skip_cache: true).process + expect(results).to be_present + expect(results[0][:url]).to eq("#{chat_url}?messageId=#{chat_message.id}") + expect(results[0][:title]).to eq( + "Message ##{chat_message.id} by #{chat_message.user.username} – ##{chat_channel.name}", + ) + end + end + + context "when regular" do + it "renders channel, excluding inactive, staged, and suspended users" do + user.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + Jobs::UpdateUserCountsForChatChannels.new.execute({}) + + expect(Oneboxer.preview(chat_url)).to match_html <<~HTML + + + HTML + end + + it "renders messages" do + expect(Oneboxer.preview("#{chat_url}?messageId=#{chat_message.id}")).to match_html <<~HTML +
    + +

    Hello world!

    +
    + HTML + end + end + end + + describe "auto-joining users to a channel" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let!(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) } + + before { Jobs.run_immediately! } + + def assert_user_following_state(user, channel, following:) + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + + following ? (expect(membership.following).to eq(true)) : (expect(membership).to be_nil) + end + + describe "when a user is added to a group with access to a channel through a category" do + let!(:category) { Fabricate(:private_category, group: chatters_group) } + + it "joins the user to the channel if auto-join is enabled" do + chatters_group.add(user) + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if auto-join is disabled" do + channel.update!(auto_join_users: false) + + assert_user_following_state(user, channel, following: false) + end + end + + describe "when a user is created" do + fab!(:category) { Fabricate(:category) } + let(:user) { Fabricate(:user, last_seen_at: nil, first_seen_at: nil) } + + it "queues a job to auto-join the user the first time they log in" do + user.update_last_seen! + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if it's not the first time we see the user" do + user.update!(first_seen_at: 2.minute.ago) + user.update_last_seen! + + assert_user_following_state(user, channel, following: false) + end + + it "does nothing if auto-join is disabled" do + channel.update!(auto_join_users: false) + + user.update_last_seen! + + assert_user_following_state(user, channel, following: false) + end + end + + describe "when category permissions change" do + fab!(:category) { Fabricate(:category) } + + let(:chatters_group_permission) do + { chatters_group.name => CategoryGroup.permission_types[:full] } + end + + describe "given permissions to a new group" do + it "adds the user to the channel" do + chatters_group.add(user) + + category.update!(permissions: chatters_group_permission) + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if there is no channel for the category" do + another_category = Fabricate(:category) + + another_category.update!(permissions: chatters_group_permission) + + assert_user_following_state(user, channel, following: false) + end + end + end + end + + describe "secure media compatibility" do + it "disables chat uploads if secure media changes from disabled to enabled" do + enable_secure_uploads + expect(SiteSetting.chat_allow_uploads).to eq(false) + last_history = UserHistory.last + expect(last_history.action).to eq(UserHistory.actions[:change_site_setting]) + expect(last_history.previous_value).to eq("true") + expect(last_history.new_value).to eq("false") + expect(last_history.subject).to eq("chat_allow_uploads") + expect(last_history.context).to eq("Disabled because secure_uploads is enabled") + end + + it "does not disable chat uploads if the allow_unsecure_chat_uploads global setting is set" do + global_setting :allow_unsecure_chat_uploads, true + expect { enable_secure_uploads }.not_to change { UserHistory.count } + expect(SiteSetting.chat_allow_uploads).to eq(true) + end + end + + describe "current_user_serializer#chat_channels" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:user) { Fabricate(:user) } + + let(:serializer) { CurrentUserSerializer.new(user, scope: Guardian.new(user)) } + + it "returns the global presence channel state" do + expect(serializer.chat_channels[:global_presence_channel_state]).to be_present + end + + context "when no channels exist" do + it "returns an empty array" do + expect(serializer.chat_channels[:direct_message_channels]).to eq([]) + expect(serializer.chat_channels[:public_channels]).to eq([]) + end + end + + context "when followed public channels exist" do + fab!(:user_2) { Fabricate(:user) } + fab!(:channel) { Fabricate(:direct_message_channel, users: [user, user_2]) } + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + Fabricate(:direct_message_channel, users: [user, user_2]) + end + + it "returns them" do + expect(serializer.chat_channels[:public_channels]).to eq([]) + expect(serializer.chat_channels[:direct_message_channels].count).to eq(1) + expect(serializer.chat_channels[:direct_message_channels][0].id).to eq(channel.id) + end + end + + context "when followed direct message channels exist" do + fab!(:channel) { Fabricate(:chat_channel) } + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + Fabricate(:chat_channel) + end + + it "returns them" do + expect(serializer.chat_channels[:direct_message_channels]).to eq([]) + expect(serializer.chat_channels[:public_channels].count).to eq(1) + expect(serializer.chat_channels[:public_channels][0].id).to eq(channel.id) + end + end + end + + describe "current_user_serializer#has_joinable_public_channels" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:user) { Fabricate(:user) } + let(:serializer) { CurrentUserSerializer.new(user, scope: Guardian.new(user)) } + + context "when no channels exist" do + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when no joinable channel exist" do + fab!(:channel) { Fabricate(:chat_channel) } + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + end + + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when no public channel exist" do + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) } + + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when a joinable channel exists" do + fab!(:channel) { Fabricate(:chat_channel) } + + it "returns true" do + expect(serializer.has_joinable_public_channels).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb new file mode 100644 index 0000000000..73cfa74ce9 --- /dev/null +++ b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatChannelMembershipsQuery do + fab!(:user_1) { Fabricate(:user, username: "Aline", name: "Boetie") } + fab!(:user_2) { Fabricate(:user, username: "Bertrand", name: "Arlan") } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + context "when chatable exists" do + context "when chatable is public" do + fab!(:channel_1) { Fabricate(:category_channel) } + + context "when no memberships exists" do + it "returns an empty array" do + expect(described_class.call(channel_1)).to eq([]) + end + end + + context "when memberships exist" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + it "returns the memberships" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) + end + end + end + + context "when chatable is restricted" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: chatters_group) } + fab!(:channel_1) { Fabricate(:category_channel, chatable: private_category) } + + context "when user is in group" do + before { chatters_group.add(user_1) } + + context "when membership exists" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + end + + it "lists the user" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to include(user_1.id) + end + + it "returns only one membership if user is in multiple allowed groups" do + another_group = Fabricate(:group) + another_group.add(user_1) + private_category.category_groups.create!( + group_id: another_group.id, + permission_type: CategoryGroup.permission_types[:full], + ) + + expect(described_class.call(channel_1).pluck(:user_id)).to contain_exactly(user_1.id) + end + + it "returns the membership if the user still has access through a staff group" do + chatters_group.remove(user_1) + Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(user_1) + + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to include(user_1.id) + end + end + + context "when membership doesn’t exist" do + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to be_empty + end + end + end + + context "when user is not in group" do + context "when membership exists" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + end + + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships).to be_empty + end + end + + context "when membership doesn’t exist" do + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships).to be_empty + end + end + end + end + + context "when chatable is direct channel" do + fab!(:channel_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } + + context "when no memberships exists" do + it "returns an empty array" do + expect(described_class.call(channel_1)).to eq([]) + end + end + + context "when memberships exist" do + before do + UserChatChannelMembership.create!( + user: user_1, + chat_channel: channel_1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + UserChatChannelMembership.create!( + user: user_2, + chat_channel: channel_1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "returns the memberships" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) + end + end + end + + describe "pagination" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + describe "offset param" do + it "offsets the results" do + memberships = described_class.call(channel_1, offset: 1) + + expect(memberships.length).to eq(1) + end + end + + describe "limit param" do + it "limits the results" do + memberships = described_class.call(channel_1, limit: 1) + + expect(memberships.length).to eq(1) + end + end + end + + describe "username param" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + it "filters the results" do + memberships = described_class.call(channel_1, username: user_1.username) + + expect(memberships.length).to eq(1) + expect(memberships[0].user).to eq(user_1) + end + end + + describe "memberships order" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + context "when prioritizes username in ux is enabled" do + before { SiteSetting.prioritize_username_in_ux = true } + + it "is using ascending order on username" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_1) + expect(memberships[1].user).to eq(user_2) + end + end + + context "when prioritize username in ux is disabled" do + before { SiteSetting.prioritize_username_in_ux = false } + + it "is using ascending order on name" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_2) + expect(memberships[1].user).to eq(user_1) + end + + context "when enable names is disabled" do + before { SiteSetting.enable_names = false } + + it "is using ascending order on username" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_1) + expect(memberships[1].user).to eq(user_2) + end + end + end + end + end + + context "when user is staged" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:staged_user) { Fabricate(:staged) } + + before do + UserChatChannelMembership.create(user: staged_user, chat_channel: channel_1, following: true) + end + + it "doesn’t list staged users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end + + context "when user is suspended" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:suspended_user) do + Fabricate(:user, suspended_at: Time.now, suspended_till: 5.days.from_now) + end + + before do + UserChatChannelMembership.create( + user: suspended_user, + chat_channel: channel_1, + following: true, + ) + end + + it "doesn’t list suspended users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end + + context "when user is inactive" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:inactive_user) { Fabricate(:inactive_user) } + + before do + UserChatChannelMembership.create( + user: inactive_user, + chat_channel: channel_1, + following: true, + ) + end + + it "doesn’t list inactive users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end +end diff --git a/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb b/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb new file mode 100644 index 0000000000..8d9a870140 --- /dev/null +++ b/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::AdminIncomingChatWebhooksController do + fab!(:admin) { Fabricate(:admin) } + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel1) { Fabricate(:category_channel) } + fab!(:chat_channel2) { Fabricate(:category_channel) } + + before { SiteSetting.chat_enabled = true } + + describe "#index" do + fab!(:existing1) { Fabricate(:incoming_chat_webhook) } + fab!(:existing2) { Fabricate(:incoming_chat_webhook) } + + it "blocks non-admin" do + sign_in(user) + get "/admin/plugins/chat.json" + expect(response.status).to eq(404) + end + + it "Returns chat_channels and incoming_chat_webhooks for admin" do + sign_in(admin) + get "/admin/plugins/chat.json" + expect(response.status).to eq(200) + expect( + response.parsed_body["incoming_chat_webhooks"].map { |webhook| webhook["id"] }, + ).to match_array([existing1.id, existing2.id]) + end + end + + describe "#create" do + let(:attrs) { { name: "Test1", chat_channel_id: chat_channel1.id } } + + it "blocks non-admin" do + sign_in(user) + post "/admin/plugins/chat/hooks.json", params: attrs + expect(response.status).to eq(404) + end + + it "errors when name isn't present" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", params: { chat_channel_id: chat_channel1.id } + expect(response.status).to eq(400) + end + + it "errors when chat_channel ID isn't present" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", params: { name: "test1a" } + expect(response.status).to eq(400) + end + + it "errors when chat_channel isn't valid" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", + params: { + name: "test1a", + chat_channel_id: ChatChannel.last.id + 1, + } + expect(response.status).to eq(404) + end + + it "creates a new incoming_chat_webhook record" do + sign_in(admin) + expect { post "/admin/plugins/chat/hooks.json", params: attrs }.to change { + IncomingChatWebhook.count + }.by(1) + expect(response.parsed_body["name"]).to eq(attrs[:name]) + expect(response.parsed_body["chat_channel"]["id"]).to eq(attrs[:chat_channel_id]) + expect(response.parsed_body["url"]).not_to be_nil + end + end + + describe "#update" do + fab!(:existing) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel1) } + let(:attrs) do + { + name: "update test", + chat_channel_id: chat_channel2.id, + emoji: ":slight_smile:", + description: "It does stuff!", + username: "beep boop", + } + end + + it "errors for non-admin" do + sign_in(user) + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: attrs + expect(response.status).to eq(404) + end + + it "errors when name or chat_channel_id aren't present" do + sign_in(admin) + invalid_attrs = attrs + + invalid_attrs[:name] = nil + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: invalid_attrs + expect(response.status).to eq(400) + + invalid_attrs[:name] = "woopsers" + invalid_attrs[:chat_channel_id] = nil + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: invalid_attrs + expect(response.status).to eq(400) + end + + it "updates existing incoming_chat_webhook records" do + sign_in(admin) + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: attrs + expect(response.status).to eq(200) + existing.reload + expect(existing.name).to eq(attrs[:name]) + expect(existing.description).to eq(attrs[:description]) + expect(existing.emoji).to eq(attrs[:emoji]) + expect(existing.chat_channel_id).to eq(attrs[:chat_channel_id]) + expect(existing.username).to eq(attrs[:username]) + end + end + + describe "#update" do + fab!(:existing) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel1) } + + it "errors for non-staff" do + sign_in(user) + delete "/admin/plugins/chat/hooks/#{existing.id}.json" + expect(response.status).to eq(404) + end + + it "destroys incoming_chat_webhook records" do + sign_in(admin) + expect { delete "/admin/plugins/chat/hooks/#{existing.id}.json" }.to change { + IncomingChatWebhook.count + }.by(-1) + end + end +end diff --git a/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb b/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb new file mode 100644 index 0000000000..2c8b0090b4 --- /dev/null +++ b/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::CategoryChatablesController do + describe "#access_by_category" do + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + + context "when signed in as an admin" do + fab!(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + it "returns a list with the group names that could access a chat channel" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group.name}") + expect(response.parsed_body["members_count"]).to eq(0) + expect(response.parsed_body["private"]).to eq(true) + end + + it "doesn't return group names from other categories" do + a_member = Fabricate(:user) + group_2 = Fabricate(:group) + group_2.add(a_member) + category_2 = Fabricate(:private_category, group: group_2) + + get "/chat/api/category-chatables/#{category_2.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group_2.name}") + expect(response.parsed_body["members_count"]).to eq(1) + expect(response.parsed_body["private"]).to eq(true) + end + + it "returns the everyone group when a category is public" do + Fabricate(:user) + category_2 = Fabricate(:category) + everyone_group = Group.find(Group::AUTO_GROUPS[:everyone]) + + get "/chat/api/category-chatables/#{category_2.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{everyone_group.name}") + expect(response.parsed_body["members_count"]).to be_nil + expect(response.parsed_body["private"]).to eq(false) + end + + it "includes the number of users with access" do + number_of_users = 3 + number_of_users.times { group.add(Fabricate(:user)) } + + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group.name}") + expect(response.parsed_body["members_count"]).to eq(number_of_users) + expect(response.parsed_body["private"]).to eq(true) + end + + it "returns a 404 when passed an invalid category" do + get "/chat/api/category-chatables/-99/permissions.json" + + expect(response.status).to eq(404) + end + end + + context "as anon" do + it "returns a 404" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.status).to eq(404) + end + end + + context "when signed in as a regular user" do + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "returns a 404" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.status).to eq(404) + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb b/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb new file mode 100644 index 0000000000..5c76532124 --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::ChatChannelMembershipsController do + fab!(:user_1) { Fabricate(:user, username: "bob") } + fab!(:user_2) { Fabricate(:user, username: "clark") } + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#index" do + include_examples "channel access example", :get, "/memberships.json" + + context "when memberships exist" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create( + user: Fabricate(:user), + chat_channel: channel_1, + following: false, + ) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + sign_in(user_1) + end + + it "lists followed memberships" do + get "/chat/api/chat_channels/#{channel_1.id}/memberships.json" + + expect(response.parsed_body.length).to eq(2) + expect(response.parsed_body[0]["user"]["id"]).to eq(user_1.id) + expect(response.parsed_body[1]["user"]["id"]).to eq(user_2.id) + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb new file mode 100644 index 0000000000..cf27b5bcde --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +RSpec.describe Chat::Api::ChatChannelNotificationsSettingsController do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#update" do + include_examples "channel access example", :put, "/notifications_settings.json" + + context "when category channel has invalid params" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "doesn’t use invalid params" do + UserChatChannelMembership.any_instance.expects(:update!).with({ "muted" => "true" }).once + + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + foo: 1, + } + + expect(response.status).to eq(200) + end + end + + context "when category channel has valid params" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate( + :user_chat_channel_membership, + muted: false, + user: user, + chat_channel: chat_channel, + ) + end + + before { sign_in(user) } + + it "updates the notifications settings" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + desktop_notification_level: "always", + mobile_notification_level: "never", + } + + expect(response.status).to eq(200) + expect(response.parsed_body).to match_response_schema("user_chat_channel_membership") + + membership.reload + + expect(membership.muted).to eq(true) + expect(membership.desktop_notification_level).to eq("always") + expect(membership.mobile_notification_level).to eq("never") + end + end + + context "when membership doesn’t exist" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "raises a 404" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json" + + expect(response.status).to eq(404) + end + end + + context "when direct message channel has invalid params" do + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:direct_message_channel, users: [user, Fabricate(:user)]) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "doesn’t use invalid params" do + UserChatChannelMembership.any_instance.expects(:update!).with({ "muted" => "true" }).once + + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + foo: 1, + } + + expect(response.status).to eq(200) + end + end + + context "when direct message channel has valid params" do + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:direct_message_channel, users: [user, Fabricate(:user)]) } + fab!(:membership) do + Fabricate( + :user_chat_channel_membership, + muted: false, + user: user, + chat_channel: chat_channel, + ) + end + + before { sign_in(user) } + + it "updates the notifications settings" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + desktop_notification_level: "always", + mobile_notification_level: "never", + } + + expect(response.status).to eq(200) + expect(response.parsed_body).to match_response_schema("user_chat_channel_membership") + + membership.reload + + expect(membership.muted).to eq(true) + expect(membership.desktop_notification_level).to eq("always") + expect(membership.mobile_notification_level).to eq("never") + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb new file mode 100644 index 0000000000..4083ab0c6b --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::ChatChannelsController do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#index" do + context "as anonymous user" do + it "returns a 403" do + get "/chat/api/chat_channels.json" + expect(response.status).to eq(403) + end + end + + describe "params" do + fab!(:opened_channel) { Fabricate(:category_channel, name: "foo") } + fab!(:closed_channel) { Fabricate(:category_channel, name: "bar", status: :closed) } + + before { sign_in(Fabricate(:user)) } + + it "returns all channels by default" do + get "/chat/api/chat_channels.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.length).to eq(2) + end + + it "returns serialized channels " do + get "/chat/api/chat_channels.json" + + expect(response.status).to eq(200) + response.parsed_body.each do |channel| + expect(channel).to match_response_schema("category_chat_channel") + end + end + + describe "filter" do + it "returns channels filtered by name" do + get "/chat/api/chat_channels.json?filter=foo" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + expect(results[0]["title"]).to eq("foo") + end + end + + describe "status" do + it "returns channels with the status" do + get "/chat/api/chat_channels.json?status=closed" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + expect(results[0]["status"]).to eq("closed") + end + end + + describe "limit" do + it "returns a number of channel equal to the limit" do + get "/chat/api/chat_channels.json?limit=1" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + end + end + describe "offset" do + it "returns channels from the offset" do + get "/chat/api/chat_channels.json?offset=2" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(0) + end + end + end + end + + describe "#create" do + fab!(:admin) { Fabricate(:admin) } + fab!(:category) { Fabricate(:category) } + + let(:params) do + { + type: category.class.name, + id: category.id, + name: "channel name", + description: "My new channel", + } + end + + before { sign_in(admin) } + + it "creates a channel associated to a category" do + put "/chat/chat_channels.json", params: params + + new_channel = ChatChannel.last + + expect(new_channel.name).to eq(params[:name]) + expect(new_channel.description).to eq(params[:description]) + expect(new_channel.chatable_type).to eq(category.class.name) + expect(new_channel.chatable_id).to eq(category.id) + end + + it "creates a channel sets auto_join_users to false by default" do + put "/chat/chat_channels.json", params: params + + new_channel = ChatChannel.last + + expect(new_channel.auto_join_users).to eq(false) + end + + it "creates a channel with auto_join_users set to true" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: true) + + new_channel = ChatChannel.last + + expect(new_channel.auto_join_users).to eq(true) + end + + describe "triggers the auto-join process" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, last_seen_at: 15.minute.ago) } + + before do + Jobs.run_immediately! + Fabricate(:category_group, category: category, group: chatters_group) + chatters_group.add(user) + end + + it "joins the user when auto_join_users is true" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: true) + + created_channel_id = response.parsed_body.dig("chat_channel", "id") + membership_exists = + UserChatChannelMembership.find_by( + user: user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_present + end + + it "doesn't join the user when auto_join_users is false" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: false) + + created_channel_id = response.parsed_body.dig("chat_channel", "id") + membership_exists = + UserChatChannelMembership.find_by( + user: user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_nil + end + end + end + + describe "#update" do + include_examples "channel access example", :put + + context "when user can’t edit channel" do + fab!(:chat_channel) { Fabricate(:category_channel) } + + before { sign_in(Fabricate(:user)) } + + it "returns a 403" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.status).to eq(403) + end + end + + context "when user provided invalid params" do + fab!(:chat_channel) { Fabricate(:category_channel, user_count: 10) } + + before { sign_in(Fabricate(:admin)) } + + it "doesn’t change invalid properties" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { user_count: 40 } + + expect(chat_channel.reload.user_count).to eq(10) + end + end + + context "when user provided an empty name" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) do + Fabricate(:category_channel, name: "something", description: "something else") + end + + before { sign_in(user) } + + it "nullifies the field and doesn’t store an empty string" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { name: " " } + + expect(chat_channel.reload.name).to be_nil + end + + it "doesn’t nullify the description" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { name: " " } + + expect(chat_channel.reload.description).to eq("something else") + end + end + + context "when user provides an empty description" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) do + Fabricate(:category_channel, name: "something else", description: "something") + end + + before { sign_in(user) } + + it "nullifies the field and doesn’t store an empty string" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { description: " " } + + expect(chat_channel.reload.description).to be_nil + end + + it "doesn’t nullify the name" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { description: " " } + + expect(chat_channel.reload.name).to eq("something else") + end + end + + context "when channel is a direct message channel" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:direct_message_channel) } + + before { sign_in(user) } + + it "raises a 403" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.status).to eq(403) + end + end + + context "when user provides valid params" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:category_channel) } + + before { sign_in(user) } + + it "sets properties" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", + params: { + name: "joffrey", + description: "cat owner", + } + + expect(chat_channel.reload.name).to eq("joffrey") + expect(chat_channel.reload.description).to eq("cat owner") + end + + it "publishes an update" do + messages = + MessageBus.track_publish("/chat/channel-edits") do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + end + + expect(messages[0].data[:chat_channel_id]).to eq(chat_channel.id) + end + + it "returns a valid chat channel" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.parsed_body).to match_response_schema("category_chat_channel") + end + + describe "Updating a channel to add users automatically" do + it "sets the channel to auto-update users automatically" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + expect(response.parsed_body["auto_join_users"]).to eq(true) + end + + it "tells staff members to slow down when toggling auto-update multiple times" do + RateLimiter.enable + + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: false } + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + expect(response.status).to eq(429) + end + + describe "triggers the auto-join process" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:another_user) { Fabricate(:user, last_seen_at: 15.minute.ago) } + + before do + Jobs.run_immediately! + Fabricate(:category_group, category: chat_channel.chatable, group: chatters_group) + chatters_group.add(another_user) + end + + it "joins the user when auto_join_users is true" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + created_channel_id = response.parsed_body["id"] + membership_exists = + UserChatChannelMembership.find_by( + user: another_user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_present + end + + it "doesn't join the user when auto_join_users is false" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", + params: { + auto_join_users: false, + } + + created_channel_id = response.parsed_body["id"] + membership_exists = + UserChatChannelMembership.find_by( + user: another_user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_nil + end + end + end + end + end +end diff --git a/plugins/chat/spec/requests/categories_controller_spec.rb b/plugins/chat/spec/requests/categories_controller_spec.rb new file mode 100644 index 0000000000..798396d640 --- /dev/null +++ b/plugins/chat/spec/requests/categories_controller_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe CategoriesController do + describe '#destroy' do + subject(:destroy_category) { delete "/categories/#{category.slug}.json" } + + fab!(:admin) { Fabricate(:admin) } + fab!(:category) { Fabricate(:category, user: admin) } + fab!(:user) { Fabricate(:user) } + + context "when user is staff" do + before { sign_in(admin) } + + context "when category has no channel" do + it "deletes the category" do + expect { destroy_category }.to change { Category.count }.by(-1) + end + end + + context "when category has a channel" do + let!(:channel) { Fabricate(:category_channel, chatable: category) } + + context "when channel has no messages" do + it "deletes the category" do + expect { destroy_category }.to change { Category.count }.by(-1) + end + end + + context "when channel has messages" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + it "does not delete the category" do + expect { destroy_category }.not_to change { Category.count } + expect(response).to be_forbidden + end + end + end + end + + context "when user is not staff" do + before { sign_in(user) } + + context "when category has no channel" do + it "does not delete the category" do + expect { destroy_category }.not_to change { Category.count } + expect(response).to be_forbidden + end + end + + context "when category has a channel" do + let!(:channel) { Fabricate(:category_channel, chatable: category) } + + context "when channel has no messages" do + it "does not delete the category" do + expect { destroy_category }.not_to change { Category.count } + expect(response).to be_forbidden + end + end + + context "when channel has messages" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + it "does not delete the category" do + expect { destroy_category }.not_to change { Category.count } + expect(response).to be_forbidden + end + end + end + end + end +end diff --git a/plugins/chat/spec/requests/chat_channel_controller_spec.rb b/plugins/chat/spec/requests/chat_channel_controller_spec.rb new file mode 100644 index 0000000000..9e88a47f72 --- /dev/null +++ b/plugins/chat/spec/requests/chat_channel_controller_spec.rb @@ -0,0 +1,834 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ChatChannelsController do + fab!(:user) { Fabricate(:user, username: "johndoe", name: "John Doe") } + fab!(:other_user) { Fabricate(:user, username: "janemay", name: "Jane May") } + fab!(:admin) { Fabricate(:admin, username: "andyjones", name: "Andy Jones") } + fab!(:category) { Fabricate(:category) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_chat_channel) { Fabricate(:direct_message_channel, users: [user, admin]) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + end + + describe "#index" do + fab!(:private_group) { Fabricate(:group) } + fab!(:user_with_private_access) { Fabricate(:user, group_ids: [private_group.id]) } + + fab!(:private_category) { Fabricate(:private_category, group: private_group) } + fab!(:private_category_cc) { Fabricate(:category_channel, chatable: private_category) } + + describe "with memberships for all channels" do + before do + ChatChannel.all.each do |cc| + model = + ( + if cc.direct_message_channel? + :user_chat_channel_membership_for_dm + else + :user_chat_channel_membership + end + ) + + Fabricate(model, chat_channel: cc, user: user) + Fabricate(model, chat_channel: cc, user: user_with_private_access) + Fabricate(model, chat_channel: cc, user: admin) + end + end + + it "errors for user that is not allowed to chat" do + sign_in(user) + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + + get "/chat/chat_channels.json" + + expect(response.status).to eq(403) + end + + it "returns public channels to only-public user" do + sign_in(user) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id]) + end + + it "returns channels visible to user with private access" do + sign_in(user_with_private_access) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id, private_category_cc.id]) + end + + it "returns all channels for admin" do + sign_in(admin) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id, private_category_cc.id]) + end + + it "doesn't error when a chat channel's chatable is destroyed" do + sign_in(user_with_private_access) + private_category.destroy! + + get "/chat/chat_channels.json" + expect(response.status).to eq(200) + end + + it "serializes unread_mentions properly" do + sign_in(admin) + Jobs.run_immediately! + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + content: "Hi @#{admin.username}", + ) + get "/chat/chat_channels.json" + cc = response.parsed_body["public_channels"].detect { |c| c["id"] == chat_channel.id } + expect(cc["current_user_membership"]["unread_mentions"]).to eq(1) + end + + describe "direct messages" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + + before do + Group.refresh_automatic_groups! + @dm1 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user2], + ) + @dm2 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user3], + ) + @dm3 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user2, user3], + ) + @dm4 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user2, user3], + ) + end + + it "returns correct DMs for creator" do + sign_in(user1) + + get "/chat/chat_channels.json" + expect( + response.parsed_body["direct_message_channels"].map { |c| c["id"] }, + ).to match_array([@dm1.id, @dm2.id, @dm3.id]) + end + + it "returns correct DMs when not following" do + sign_in(user2) + + get "/chat/chat_channels.json" + expect( + response.parsed_body["direct_message_channels"].map { |c| c["id"] }, + ).to match_array([]) + end + + it "returns correct DMs when following" do + user3 + .user_chat_channel_memberships + .where(chat_channel_id: @dm3.id) + .update!(following: true) + + sign_in(user3) + + get "/chat/chat_channels.json" + dm3_response = response.parsed_body + expect(dm3_response["direct_message_channels"].map { |c| c["id"] }).to match_array( + [@dm3.id], + ) + end + + it "correctly set unread_count for DMs for creator" do + sign_in(user1) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm2_response = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm2_response["current_user_membership"]["unread_count"]).to eq(0) + end + + it "correctly set membership for DMs when user is not following" do + sign_in(user2) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm2_channel = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm2_channel).to be_nil + end + + it "correctly set unread_count for DMs when user is following" do + user3 + .user_chat_channel_memberships + .where(chat_channel_id: @dm2.id) + .update!(following: true) + + sign_in(user3) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm3_response = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm3_response["current_user_membership"]["unread_count"]).to eq(1) + end + end + end + end + + describe "#follow" do + it "creates a user_chat_channel_membership record if one doesn't exist" do + sign_in(user) + expect { post "/chat/chat_channels/#{chat_channel.id}/follow.json" }.to change { + UserChatChannelMembership.where(user_id: user.id, following: true).count + }.by(1) + expect(response.status).to eq(200) + end + + it "updates 'following' to true for existing record" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: chat_channel.id, + user_id: user.id, + following: false, + ) + + expect { post "/chat/chat_channels/#{chat_channel.id}/follow.json" }.to change { + membership_record.reload.following + }.to(true).from(false) + expect(response.status).to eq(200) + expect(response.parsed_body["current_user_membership"]["following"]).to eq(true) + expect(response.parsed_body["current_user_membership"]["chat_channel_id"]).to eq( + chat_channel.id, + ) + end + end + + describe "#unfollow" do + it "updates 'following' to false for existing record" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: chat_channel.id, + user_id: user.id, + following: true, + ) + + expect { post "/chat/chat_channels/#{chat_channel.id}/unfollow.json" }.to change { + membership_record.reload.following + }.to(false).from(true) + expect(response.status).to eq(200) + expect(response.parsed_body["current_user_membership"]["following"]).to eq(false) + expect(response.parsed_body["current_user_membership"]["chat_channel_id"]).to eq( + chat_channel.id, + ) + end + + it "allows to unfollow a direct_message_channel" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: dm_chat_channel.id, + user_id: user.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + + post "/chat/chat_channels/#{dm_chat_channel.id}/unfollow.json" + expect(response.status).to eq(200) + expect(membership_record.reload.following).to eq(false) + end + end + + describe "#create" do + fab!(:category2) { Fabricate(:category) } + + it "errors for non-staff" do + sign_in(user) + put "/chat/chat_channels.json", params: { id: category2.id, name: "hi" } + expect(response.status).to eq(403) + end + + it "errors when chatable doesn't exist" do + sign_in(admin) + put "/chat/chat_channels.json", params: { id: Category.last.id + 1, name: "hi" } + expect(response.status).to eq(404) + end + + it "errors when the name is over SiteSetting.max_topic_title_length" do + sign_in(admin) + SiteSetting.max_topic_title_length = 10 + put "/chat/chat_channels.json", + params: { + id: category2.id, + name: "Hi, this is over 10 characters", + } + expect(response.status).to eq(400) + end + + it "errors when channel for category and same name already exists" do + sign_in(admin) + name = "beep boop hi" + category2.create_chat_channel!(name: name) + + put "/chat/chat_channels.json", params: { id: category2.id, name: name } + expect(response.status).to eq(400) + end + + it "creates a channel for category and if name is unique" do + sign_in(admin) + category2.create_chat_channel!(name: "this is a name") + + expect { + put "/chat/chat_channels.json", params: { id: category2.id, name: "Different name!" } + }.to change { ChatChannel.where(chatable: category2).count }.by(1) + expect(response.status).to eq(200) + end + + it "creates a user_chat_channel_membership when the channel is created" do + sign_in(admin) + expect { + put "/chat/chat_channels.json", params: { id: category2.id, name: "hi hi" } + }.to change { UserChatChannelMembership.where(user: admin).count }.by(1) + expect(response.status).to eq(200) + end + end + + describe "#edit" do + it "errors for non-staff" do + sign_in(user) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: "hello" } + expect(response.status).to eq(403) + end + + it "returns a 404 when chat_channel doesn't exist" do + sign_in(admin) + chat_channel.destroy! + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: "hello" } + expect(response.status).to eq(404) + end + + it "updates name correctly and leaves description alone" do + sign_in(admin) + new_name = "newwwwwwwww name" + description = "this is something" + chat_channel.update(description: description) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: new_name } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(new_name) + expect(chat_channel.description).to eq(description) + end + + it "updates name correctly and leaves description alone" do + sign_in(admin) + name = "beep boop" + new_description = "this is something" + chat_channel.update(name: name) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { description: new_description } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(name) + expect(chat_channel.description).to eq(new_description) + end + + it "updates name and description together" do + sign_in(admin) + new_name = "beep boop" + new_description = "this is something" + post "/chat/chat_channels/#{chat_channel.id}.json", + params: { + name: new_name, + description: new_description, + } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(new_name) + expect(chat_channel.description).to eq(new_description) + end + end + + describe "#search" do + describe "without chat permissions" do + it "errors errors for anon" do + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(403) + end + + it "errors when user cannot chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + sign_in(user) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(403) + end + end + + describe "with chat permissions" do + before do + sign_in(user) + chat_channel.update(name: "something") + end + + it "returns the correct channels with filter 'so'" do + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the correct channels with filter 'something'" do + get "/chat/chat_channels/search.json", params: { filter: "something" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the correct channels with filter 'andyjones'" do + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the current user inside the users array if their username matches the filter too" do + user.update(username: "andysmith") + get "/chat/chat_channels/search.json", params: { filter: "andy" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + expect(response.parsed_body["users"].map { |u| u["id"] }).to match_array([user.id]) + end + + it "returns no channels with a whacky filter" do + get "/chat/chat_channels/search.json", params: { filter: "hello good sir" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "only returns open channels" do + chat_channel.update(status: ChatChannel.statuses[:closed]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + chat_channel.update(status: ChatChannel.statuses[:read_only]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + chat_channel.update(status: ChatChannel.statuses[:archived]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + # Now set status to open and the channel is there! + chat_channel.update(status: ChatChannel.statuses[:open]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + + it "only finds users by username_lower if not enable_names" do + SiteSetting.enable_names = false + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "only finds users by username if prioritize_username_in_ux" do + SiteSetting.prioritize_username_in_ux = true + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "can find users by name or username if not prioritize_username_in_ux and enable_names" do + SiteSetting.prioritize_username_in_ux = false + SiteSetting.enable_names = true + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "does not return DM channels for users who do not have chat enabled" do + admin.user_option.update!(chat_enabled: false) + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + end + + it "does not return DM channels for users who are not in the chat allowed group" do + group = Fabricate(:group, name: "chatpeeps") + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + dm_chat_channel_2 = Fabricate(:direct_message_channel, users: [user, other_user]) + + get "/chat/chat_channels/search.json", params: { filter: "janemay" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + GroupUser.create(user: other_user, group: group) + get "/chat/chat_channels/search.json", params: { filter: "janemay" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel_2.id) + end + + it "returns DM channels for staff users even if they are not in chat_allowed_groups" do + group = Fabricate(:group, name: "chatpeeps") + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "returns followed channels" do + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: chat_channel, + following: true, + ) + + get "/chat/chat_channels/search.json", params: { filter: chat_channel.name } + + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + + it "returns not followed channels" do + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: chat_channel, + following: false, + ) + + get "/chat/chat_channels/search.json", params: { filter: chat_channel.name } + + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + end + end + + describe "#show" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "My Great Channel & Stuff") + end + + it "can find channel by id" do + sign_in(user) + get "/chat/chat_channels/#{channel.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "can find channel by name" do + sign_in(user) + get "/chat/chat_channels/#{UrlHelper.encode_component("My Great Channel & Stuff")}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "can find channel by chatable title/name" do + sign_in(user) + + channel.update!(chatable: Fabricate(:category, name: "Support Chat")) + get "/chat/chat_channels/#{UrlHelper.encode_component("Support Chat")}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "gives a not found error if the channel cannot be found by name or id" do + channel.destroy + sign_in(user) + get "/chat/chat_channels/#{channel.id}.json" + expect(response.status).to eq(404) + get "/chat/chat_channels/#{UrlHelper.encode_component(channel.name)}.json" + expect(response.status).to eq(404) + end + end + + describe "#archive" do + fab!(:channel) { Fabricate(:category_channel, chatable: category, name: "The English Channel") } + let(:new_topic_params) do + { type: "newTopic", title: "This is a test archive topic", category_id: category.id } + end + let(:existing_topic_params) { { type: "existingTopic", topic_id: Fabricate(:topic).id } } + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(403) + end + + it "returns error if type or chat_channel_id is not provided" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: {} + expect(response.status).to eq(400) + end + + it "returns error if title is not provided for new topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: { type: "newTopic" } + expect(response.status).to eq(400) + end + + it "returns error if topic_id is not provided for existing topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: { type: "existingTopic" } + expect(response.status).to eq(400) + end + + it "returns error if the channel cannot be archived" do + channel.update!(status: :archived) + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(403) + end + + it "starts the archive process using a new topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + expect(channel.reload.status).to eq("read_only") + end + + it "starts the archive process using an existing topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: existing_topic_params + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + expect(channel.reload.status).to eq("read_only") + end + + it "does nothing if the chat channel archive already exists" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(200) + expect { + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + }.not_to change { ChatChannelArchive.count } + end + end + + describe "#retry_archive" do + fab!(:channel) do + Fabricate( + :category_channel, + chatable: category, + name: "The English Channel", + status: :read_only, + ) + end + fab!(:archive) do + ChatChannelArchive.create!( + chat_channel: channel, + destination_topic_title: "test archive topic title", + archived_by: admin, + total_messages: 10, + ) + end + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "returns a 404 if the archive has not been started" do + archive.destroy + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(404) + end + + it "returns a 403 error if the archive is not currently failed" do + sign_in(admin) + archive.update!(archive_error: nil) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "returns a 403 error if the channel is not read_only" do + sign_in(admin) + archive.update!(archive_error: "bad stuff", archived_messages: 1) + channel.update!(status: "open") + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "re-enqueues the archive job" do + sign_in(admin) + archive.update!(archive_error: "bad stuff", archived_messages: 1) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(200) + expect( + job_enqueued?(job: :chat_channel_archive, args: { chat_channel_archive_id: archive.id }), + ).to eq(true) + end + end + + describe "#change_status" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "Channel Orange", status: :open) + end + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(403) + end + + it "returns a 404 if the channel does not exist" do + channel.destroy! + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(404) + end + + it "returns a 400 if the channel status is not closed or open" do + channel.update!(status: "read_only") + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(403) + end + + it "changes the channel to closed if it is open" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(200) + expect(channel.reload.status).to eq("closed") + end + + it "changes the channel to open if it is closed" do + channel.update!(status: "closed") + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "open" } + expect(response.status).to eq(200) + expect(channel.reload.status).to eq("open") + end + end + + describe "#delete" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "Ambrose Channel", status: :open) + end + + it "returns error if user is not staff" do + sign_in(user) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(403) + end + + it "returns a 404 if the channel does not exist" do + channel.destroy! + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(404) + end + + it "returns a 400 if the channel_name_confirmation does not match the channel name" do + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "some Other channel", + } + expect(response.status).to eq(400) + end + + it "deletes the channel right away and enqueues the background job to delete all its chat messages and related content" do + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(200) + expect(channel.reload.trashed?).to eq(true) + expect(job_enqueued?(job: :chat_channel_delete, args: { chat_channel_id: channel.id })).to eq( + true, + ) + expect( + UserHistory.exists?( + acting_user_id: admin.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_delete", + ), + ).to eq(true) + end + end +end diff --git a/plugins/chat/spec/requests/chat_controller_spec.rb b/plugins/chat/spec/requests/chat_controller_spec.rb new file mode 100644 index 0000000000..ad42097db2 --- /dev/null +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -0,0 +1,1473 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ChatController do + fab!(:user) { Fabricate(:user) } + fab!(:other_user) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + fab!(:category) { Fabricate(:category) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_chat_channel) { Fabricate(:direct_message_channel, users: [user, other_user, admin]) } + fab!(:tag) { Fabricate(:tag) } + + MESSAGE_COUNT = 70 + MESSAGE_COUNT.times do |n| + fab!("message_#{n}") do + Fabricate( + :chat_message, + chat_channel: chat_channel, + user: other_user, + message: "message #{n}", + ) + end + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + def flag_message(message, flagger, flag_type: ReviewableScore.types[:off_topic]) + Chat::ChatReviewQueue.new.flag_message(message, Guardian.new(flagger), flag_type)[:reviewable] + end + + describe "#messages" do + let(:page_size) { 30 } + + before do + sign_in(user) + Group.refresh_automatic_groups! + end + + it "errors for user when they are not allowed to chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.status).to eq(403) + end + + it "errors when page size is over 50" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: 51 } + expect(response.status).to eq(400) + end + + it "errors when page size is nil" do + get "/chat/#{chat_channel.id}/messages.json" + expect(response.status).to eq(400) + end + + it "returns the latest messages in created_at, id order" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to be < messages.last["created_at"].to_time + end + + it "returns `can_flag=true` for public channels" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_flag"]).to be true + end + + it "returns `can_flag=true` for DM channels" do + get "/chat/#{dm_chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_flag"]).to be true + end + + it "returns `can_moderate=true` based on whether the user can moderate the chatable" do + 1.upto(4) do |n| + user.update!(trust_level: n) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be false + end + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be false + + user.update!(admin: true) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be true + user.update!(admin: false) + + SiteSetting.enable_category_group_moderation = true + group = Fabricate(:group) + group.add(user) + category.update!(reviewable_by_group: group) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be true + end + + it "serializes `user_flag_status` for user who has a pending flag" do + chat_message = chat_channel.chat_messages.last + reviewable = flag_message(chat_message, user) + score = reviewable.reviewable_scores.last + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["chat_messages"].last["user_flag_status"]).to eq( + score.status_for_database, + ) + end + + it "doesn't serialize `reviewable_ids` for non-staff" do + reviewable = flag_message(chat_channel.chat_messages.last, admin) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["chat_messages"].last["reviewable_id"]).to be_nil + end + + it "serializes `reviewable_ids` correctly for staff" do + sign_in(admin) + reviewable = flag_message(chat_channel.chat_messages.last, admin) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["chat_messages"].last["reviewable_id"]).to eq(reviewable.id) + end + + it "correctly marks reactions as 'reacted' for the current_user" do + heart_emoji = ":heart:" + smile_emoji = ":smile" + + last_message = chat_channel.chat_messages.last + last_message.reactions.create(user: user, emoji: heart_emoji) + last_message.reactions.create(user: admin, emoji: smile_emoji) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + reactions = response.parsed_body["chat_messages"].last["reactions"] + expect(reactions[heart_emoji]["reacted"]).to be true + expect(reactions[smile_emoji]["reacted"]).to be false + end + + describe "scrolling to the past" do + it "returns the correct messages in created_at, id order" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_40.id, + direction: described_class::PAST, + page_size: page_size, + } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to eq_time(message_10.created_at) + expect(messages.last["created_at"].to_time).to eq_time(message_39.created_at) + end + + it "returns 'can_load...' properly when there are more past messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_40.id, + direction: described_class::PAST, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be true + expect(response.parsed_body["meta"]["can_load_more_future"]).to be_nil + end + + it "returns 'can_load...' properly when there are no past messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_3.id, + direction: described_class::PAST, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be false + expect(response.parsed_body["meta"]["can_load_more_future"]).to be_nil + end + end + + describe "scrolling to the future" do + it "returns the correct messages in created_at, id order when there are many after" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_10.id, + direction: described_class::FUTURE, + page_size: page_size, + } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to eq_time(message_11.created_at) + expect(messages.last["created_at"].to_time).to eq_time(message_40.created_at) + end + + it "return 'can_load..' properly when there are future messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_10.id, + direction: described_class::FUTURE, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be_nil + expect(response.parsed_body["meta"]["can_load_more_future"]).to be true + end + + it "returns 'can_load..' properly when there are no future messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_60.id, + direction: described_class::FUTURE, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be_nil + expect(response.parsed_body["meta"]["can_load_more_future"]).to be false + end + end + + describe "without direction (latest messages)" do + it "signals there are no future messages" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["meta"]["can_load_more_future"]).to eq(false) + end + + it "signals there are more messages in the past" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["meta"]["can_load_more_past"]).to eq(true) + end + + it "signals there are no more messages" do + new_channel = Fabricate(:category_channel) + Fabricate(:chat_message, chat_channel: new_channel, user: other_user, message: "message") + chat_messages_qty = 1 + + get "/chat/#{new_channel.id}/messages.json", params: { page_size: chat_messages_qty + 1 } + + expect(response.parsed_body["meta"]["can_load_more_past"]).to eq(false) + end + end + end + + describe "#enable_chat" do + context "with category as chatable" do + let!(:category) { Fabricate(:category) } + let(:channel) { Fabricate(:category_channel, chatable: category) } + + it "ensures created channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(admin) + post "/chat/enable.json", params: { chatable_type: "category", chatable_id: category.id } + end + + # TODO: rewrite specs to ensure no exception is raised + it "ensures existing channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?) + + sign_in(admin) + post "/chat/enable.json", params: { chatable_type: "category", chatable_id: category.id } + end + end + end + + describe "#disable_chat" do + context "with category as chatable" do + it "ensures category can be seen" do + category = Fabricate(:category) + channel = Fabricate(:category_channel, chatable: category) + message = Fabricate(:chat_message, chat_channel: channel) + + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(admin) + post "/chat/disable.json", params: { chatable_type: "category", chatable_id: category.id } + end + end + end + + describe "#create_message" do + let(:message) { "This is a message" } + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + context "when current user is silenced" do + before do + UserChatChannelMembership.create(user: user, chat_channel: chat_channel, following: true) + sign_in(user) + UserSilencer.new(user).silence + end + + it "raises invalid acces" do + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + end + + it "errors for regular user when chat is staff-only" do + sign_in(user) + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + + it "errors when the user isn't following the channel" do + sign_in(user) + + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + + it "errors when the user is not staff and the channel is not open" do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + sign_in(user) + + chat_channel.update(status: :closed) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "errors when the user is staff and the channel is not open or closed" do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: admin) + sign_in(admin) + + chat_channel.update(status: :closed) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(200) + + chat_channel.update(status: :read_only) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "sends a message for regular user when staff-only is disabled and they are following channel" do + sign_in(user) + UserChatChannelMembership.create(user: user, chat_channel: chat_channel, following: true) + + expect { post "/chat/#{chat_channel.id}.json", params: { message: message } }.to change { + ChatMessage.count + }.by(1) + expect(response.status).to eq(200) + expect(ChatMessage.last.message).to eq(message) + end + end + + describe "for direct message" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:chatable) { Fabricate(:direct_message, users: [user1, user2]) } + fab!(:direct_message_channel) { Fabricate(:direct_message_channel, chatable: chatable) } + + def create_memberships + UserChatChannelMembership.create!( + user: user1, + chat_channel: direct_message_channel, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + UserChatChannelMembership.create!( + user: user2, + chat_channel: direct_message_channel, + following: false, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + Group.refresh_automatic_groups! + end + + it "forces users to follow the channel" do + create_memberships + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + + ChatPublisher.expects(:publish_new_channel).once + + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be true + end + + it "errors when the user is not part of the direct message channel" do + create_memberships + + DirectMessageUser.find_by(user: user1, direct_message: chatable).destroy! + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + + UserChatChannelMembership.find_by(user_id: user2.id).update!(following: true) + sign_in(user2) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(200) + end + + context "when current user is silenced" do + before do + create_memberships + sign_in(user1) + UserSilencer.new(user1).silence + end + + it "raises invalid acces" do + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + end + + context "if any of the direct message users is ignoring the acting user" do + before do + IgnoredUser.create!(user: user2, ignored_user: user1, expiring_at: 1.day.from_now) + end + + it "does not force them to follow the channel or send a publish_new_channel message" do + create_memberships + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + + ChatPublisher.expects(:publish_new_channel).never + + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + end + end + end + end + + describe "#rebake" do + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + + context "as staff" do + it "rebakes the post" do + sign_in(Fabricate(:admin)) + + expect_enqueued_with( + job: :process_chat_message, + args: { + chat_message_id: chat_message.id, + }, + ) do + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + + expect(response.status).to eq(200) + end + end + + it "does not interfere with core's guardian can_rebake? for posts" do + sign_in(Fabricate(:admin)) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(200) + post = Fabricate(:post) + put "/posts/#{post.id}/rebake.json" + expect(response.status).to eq(200) + end + + it "does not rebake the post when channel is read_only" do + chat_message.chat_channel.update!(status: :read_only) + sign_in(Fabricate(:admin)) + + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + + context "when cooked has changed" do + it "marks the message as dirty" do + sign_in(Fabricate(:admin)) + chat_message.update!(message: "new content") + + expect_enqueued_with( + job: :process_chat_message, + args: { + chat_message_id: chat_message.id, + is_dirty: true, + }, + ) do + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + + expect(response.status).to eq(200) + end + end + end + end + + context "when not staff" do + it "forbids non staff to rebake" do + sign_in(Fabricate(:user)) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + + context "as TL3 user" do + it "forbids less then TL4 user tries to rebake" do + sign_in(Fabricate(:user, trust_level: TrustLevel[3])) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + end + + context "as TL4 user" do + it "allows TL4 users to rebake" do + sign_in(Fabricate(:user, trust_level: TrustLevel[4])) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(200) + end + + it "does not rebake the post when channel is read_only" do + chat_message.chat_channel.update!(status: :read_only) + sign_in(Fabricate(:user, trust_level: TrustLevel[4])) + + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + end + end + end + + describe "#edit_message" do + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + + context "when current user is silenced" do + before do + UserSilencer.new(user).silence + sign_in(user) + end + + it "raises an invalid request" do + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", params: { new_message: "Hi" } + expect(response.status).to eq(422) + end + end + + it "errors when a user tries to edit another user's message" do + sign_in(Fabricate(:user)) + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", params: { new_message: "edit!" } + expect(response.status).to eq(422) + end + + it "errors when staff tries to edit another user's message" do + sign_in(admin) + new_message = "Vrroooom cars go fast" + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message, + } + expect(response.status).to eq(422) + end + + it "allows a user to edit their own messages" do + sign_in(user) + new_message = "Wow markvanlan must be a good programmer" + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message, + } + expect(response.status).to eq(200) + expect(chat_message.reload.message).to eq(new_message) + end + end + + RSpec.shared_examples "chat_message_deletion" do + it "doesn't allow a user to delete another user's message" do + sign_in(other_user) + + delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" + expect(response.status).to eq(403) + end + + it "doesn't allow a silenced user to delete their message" do + sign_in(other_user) + UserSilencer.new(other_user).silence + + delete "/chat/#{other_user_message.chat_channel.id}/#{other_user_message.id}.json" + expect(response.status).to eq(403) + end + + it "Allows admin to delete others' messages" do + sign_in(admin) + + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + + it "does not allow message delete when chat channel is read_only" do + sign_in(ChatMessage.last.user) + + chat_channel.update!(status: :read_only) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.not_to change { + ChatMessage.count + } + expect(response.status).to eq(403) + + sign_in(admin) + delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" + expect(response.status).to eq(403) + end + + it "only allows admin to delete when chat channel is closed" do + sign_in(admin) + + chat_channel.update!(status: :read_only) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.not_to change { + ChatMessage.count + } + expect(response.status).to eq(403) + + chat_channel.update!(status: :closed) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + end + + describe "#delete" do + fab!(:second_user) { Fabricate(:user) } + fab!(:second_user_message) do + Fabricate(:chat_message, user: second_user, chat_channel: chat_channel) + end + + before do + ChatMessage.create(user: user, message: "this is a message", chat_channel: chat_channel) + end + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + it_behaves_like "chat_message_deletion" do + let(:other_user) { second_user } + let(:other_user_message) { second_user_message } + end + + it "Allows users to delete their own messages" do + sign_in(user) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + end + end + + RSpec.shared_examples "chat_message_restoration" do + it "doesn't allow a user to restore another user's message" do + sign_in(other_user) + + put "/chat/#{chat_channel.id}/restore/#{ChatMessage.unscoped.last.id}.json" + expect(response.status).to eq(403) + end + + it "allows a user to restore their own posts" do + sign_in(user) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + + it "allows admin to restore others' posts" do + sign_in(admin) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + + it "does not allow message restore when chat channel is read_only" do + sign_in(ChatMessage.last.user) + + chat_channel.update!(status: :read_only) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + expect(deleted_message.reload.deleted_at).not_to be_nil + + sign_in(admin) + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + end + + it "only allows admin to restore when chat channel is closed" do + sign_in(admin) + + chat_channel.update!(status: :read_only) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + expect(deleted_message.reload.deleted_at).not_to be_nil + + chat_channel.update!(status: :closed) + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + end + + describe "#restore" do + fab!(:second_user) { Fabricate(:user) } + + before do + message = + ChatMessage.create(user: user, message: "this is a message", chat_channel: chat_channel) + message.trash! + end + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + it_behaves_like "chat_message_restoration" do + let(:other_user) { second_user } + end + end + end + + describe "#update_user_last_read" do + before { sign_in(user) } + + fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) } + fab!(:message_2) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) } + + it "returns a 404 when the user is not a channel member" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(404) + end + + it "returns a 404 when the user is not following the channel" do + Fabricate( + :user_chat_channel_membership, + chat_channel: chat_channel, + user: user, + following: false, + ) + + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(404) + end + + describe "when the user is a channel member" do + fab!(:membership) do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + end + + context "when message_id param doesn't link to a message of the channel" do + it "raises a not found" do + put "/chat/#{chat_channel.id}/read/-999.json" + + expect(response.status).to eq(404) + end + end + + context "when message_id param is inferior to existing last read" do + before { membership.update!(last_read_message_id: message_2.id) } + + it "raises an invalid request" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(400) + expect(response.parsed_body["errors"][0]).to match(/message_id/) + end + end + + context "when message_id refers to deleted message" do + before { message_1.trash!(Discourse.system_user) } + + it "works" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(200) + end + end + + it "updates timing records" do + expect { put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" }.not_to change { + UserChatChannelMembership.count + } + + membership.reload + expect(membership.chat_channel_id).to eq(chat_channel.id) + expect(membership.last_read_message_id).to eq(message_1.id) + expect(membership.user_id).to eq(user.id) + end + + def create_notification_and_mention_for(user, sender, msg) + Notification + .create!( + notification_type: Notification.types[:chat_mention], + user: user, + high_priority: true, + read: false, + data: { + message: "chat.mention_notification", + chat_message_id: msg.id, + chat_channel_id: msg.chat_channel_id, + chat_channel_title: msg.chat_channel.title(user), + chat_channel_slug: msg.chat_channel.slug, + mentioned_by_username: sender.username, + }.to_json, + ) + .tap do |notification| + ChatMention.create!(user: user, chat_message: msg, notification: notification) + end + end + + it "marks all mention notifications as read for the channel" do + notification = create_notification_and_mention_for(user, other_user, message_1) + + put "/chat/#{chat_channel.id}/read/#{message_2.id}.json" + expect(response.status).to eq(200) + expect(notification.reload.read).to eq(true) + end + + it "doesn't mark notifications of messages that weren't read yet" do + message_3 = Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) + notification = create_notification_and_mention_for(user, other_user, message_3) + + put "/chat/#{chat_channel.id}/read/#{message_2.id}.json" + + expect(response.status).to eq(200) + expect(notification.reload.read).to eq(false) + end + end + end + + describe "react" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + fab!(:user_membership) do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + end + + fab!(:private_chat_channel) do + Fabricate(:category_channel, chatable: Fabricate(:private_category, group: Fabricate(:group))) + end + fab!(:private_chat_message) do + Fabricate(:chat_message, chat_channel: private_chat_channel, user: admin) + end + fab!(:private_user_membership) do + Fabricate(:user_chat_channel_membership, chat_channel: private_chat_channel, user: user) + end + + fab!(:chat_channel_no_memberships) { Fabricate(:category_channel) } + fab!(:chat_message_no_memberships) do + Fabricate(:chat_message, chat_channel: chat_channel_no_memberships, user: user) + end + + it "errors with invalid emoji" do + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: 12, + react_action: "add", + } + expect(response.status).to eq(400) + end + + it "errors with invalid action" do + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "sdf", + } + expect(response.status).to eq(400) + end + + it "creates a membership when reacting to channel without a membership record" do + sign_in(user) + + expect { + put "/chat/#{chat_channel_no_memberships.id}/react/#{chat_message_no_memberships.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + }.to change { UserChatChannelMembership.count }.by(1) + expect(response.status).to eq(200) + end + + it "errors when user tries to react to private channel they can't access" do + sign_in(user) + put "/chat/#{private_chat_channel.id}/react/#{private_chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + expect(response.status).to eq(403) + end + + it "errors when the user tries to react to a read_only channel" do + chat_channel.update(status: :read_only) + sign_in(user) + emoji = ":heart:" + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + }.not_to change { chat_message.reactions.where(user: user, emoji: emoji).count } + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_modify_message_disallowed", status: chat_channel.status_name), + ) + end + + it "errors when user is silenced" do + UserSilencer.new(user).silence + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + expect(response.status).to eq(403) + end + + it "errors when max unique reactions limit is reached" do + Emoji + .all + .map(&:name) + .take(29) + .each { |emoji| chat_message.reactions.create(user: user, emoji: emoji) } + + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":wink:", + react_action: "add", + } + expect(response.status).to eq(200) + + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":wave:", + react_action: "add", + } + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.max_reactions_limit_reached"), + ) + end + + it "does not error on new duplicate reactions" do + another_user = Fabricate(:user) + Emoji + .all + .map(&:name) + .take(29) + .each { |emoji| chat_message.reactions.create(user: another_user, emoji: emoji) } + emoji = ":wink:" + chat_message.reactions.create(user: another_user, emoji: emoji) + + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + expect(response.status).to eq(200) + end + + it "adds a reaction record correctly" do + sign_in(user) + emoji = ":heart:" + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + }.to change { chat_message.reactions.where(user: user, emoji: emoji).count }.by(1) + expect(response.status).to eq(200) + end + + it "removes a reaction record correctly" do + sign_in(user) + emoji = ":heart:" + chat_message.reactions.create(user: user, emoji: emoji) + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "remove", + } + }.to change { chat_message.reactions.where(user: user, emoji: emoji).count }.by(-1) + expect(response.status).to eq(200) + end + end + + describe "invite_users" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: admin) } + fab!(:user2) { Fabricate(:user) } + + before do + sign_in(admin) + + [user, user2].each { |u| u.user_option.update(chat_enabled: true) } + end + + it "doesn't invite users who cannot chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:admin] + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id] } + }.not_to change { + user.notifications.where(notification_type: Notification.types[:chat_invitation]).count + } + end + + it "creates an invitation notification for users who can chat" do + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id] } + }.to change { + user.notifications.where(notification_type: Notification.types[:chat_invitation]).count + }.by(1) + notification = user.notifications.where(notification_type: Notification.types[:chat_invitation]).last + parsed_data = JSON.parse(notification[:data]) + expect(parsed_data["chat_channel_title"]).to eq(chat_channel.title(user)) + expect(parsed_data["chat_channel_slug"]).to eq(chat_channel.slug) + end + + it "creates multiple invitations" do + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id, user2.id] } + }.to change { + Notification.where( + notification_type: Notification.types[:chat_invitation], + user_id: [user.id, user2.id], + ).count + }.by(2) + end + + it "adds chat_message_id when param is present" do + put "/chat/#{chat_channel.id}/invite.json", + params: { + user_ids: [user.id], + chat_message_id: chat_message.id, + } + expect(JSON.parse(Notification.last.data)["chat_message_id"]).to eq(chat_message.id.to_s) + end + end + + describe "#dismiss_retention_reminder" do + it "errors for anon" do + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + expect(response.status).to eq(403) + end + + it "errors when chatable_type isn't present" do + sign_in(user) + post "/chat/dismiss-retention-reminder.json", params: {} + expect(response.status).to eq(400) + end + + it "errors when chatable_type isn't a valid option" do + sign_in(user) + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "hi" } + expect(response.status).to eq(400) + end + + it "sets `dismissed_channel_retention_reminder` to true" do + sign_in(user) + expect { + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + }.to change { user.user_option.reload.dismissed_channel_retention_reminder }.to (true) + end + + it "sets `dismissed_dm_retention_reminder` to true" do + sign_in(user) + expect { + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "DirectMessage" } + }.to change { user.user_option.reload.dismissed_dm_retention_reminder }.to (true) + end + + it "doesn't error if the fields are already true" do + sign_in(user) + user.user_option.update( + dismissed_channel_retention_reminder: true, + dismissed_dm_retention_reminder: true, + ) + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + expect(response.status).to eq(200) + + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "DirectMessage" } + expect(response.status).to eq(200) + end + end + + describe "#quote_messages" do + fab!(:channel) { Fabricate(:category_channel, chatable: category, name: "Cool Chat") } + let(:user2) { Fabricate(:user) } + let(:message1) do + Fabricate( + :chat_message, + user: user, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + end + let(:message2) do + Fabricate(:chat_message, user: user2, chat_channel: channel, message: "says you!") + end + let(:message3) { Fabricate(:chat_message, user: user, chat_channel: channel, message: "aw :(") } + + it "returns a 403 if the user can't chat" do + SiteSetting.chat_allowed_groups = nil + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(403) + end + + it "returns a 403 if the user can't see the channel" do + category.update!(read_restricted: true) + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(403) + end + + it "returns a 404 for a not found channel" do + channel.destroy + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(404) + end + + it "quotes the message ids provided" do + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(200) + markdown = response.parsed_body["markdown"] + expect(markdown).to eq(<<~EXPECTED) + [chat quote="#{user.username};#{message1.id};#{message1.created_at.iso8601}" channel="Cool Chat" channelId="#{channel.id}" multiQuote="true" chained="true"] + an extremely insightful response :) + [/chat] + + [chat quote="#{user2.username};#{message2.id};#{message2.created_at.iso8601}" chained="true"] + says you! + [/chat] + + [chat quote="#{user.username};#{message3.id};#{message3.created_at.iso8601}" chained="true"] + aw :( + [/chat] + EXPECTED + end + end + + describe "#flag" do + fab!(:admin_chat_message) { Fabricate(:chat_message, user: admin, chat_channel: chat_channel) } + fab!(:user_chat_message) { Fabricate(:chat_message, user: user, chat_channel: chat_channel) } + + fab!(:admin_dm_message) { Fabricate(:chat_message, user: admin, chat_channel: dm_chat_channel) } + + before do + sign_in(user) + Group.refresh_automatic_groups! + end + + it "creates reviewable" do + expect { + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + }.to change { ReviewableChatMessage.where(target: admin_chat_message).count }.by(1) + expect(response.status).to eq(200) + end + + it "errors for silenced users" do + UserSilencer.new(user).silence + + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "doesn't allow flagging your own message" do + put "/chat/flag.json", + params: { + chat_message_id: user_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "doesn't allow flagging messages in a read_only channel" do + user_chat_message.chat_channel.update(status: :read_only) + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + + expect(response.status).to eq(403) + end + + it "doesn't allow flagging staff if SiteSetting.allow_flagging_staff is false" do + SiteSetting.allow_flagging_staff = false + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "returns a 429 when the user attempts to flag more than 4 messages in 1 minute" do + RateLimiter.enable + + [message_1, message_2, message_3, message_4].each do |message| + put "/chat/flag.json", + params: { + chat_message_id: message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(200) + end + + put "/chat/flag.json", + params: { + chat_message_id: message_5.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + + expect(response.status).to eq(429) + end + end + + describe "#set_draft" do + fab!(:chat_channel) { Fabricate(:category_channel) } + let(:dm_channel) { Fabricate(:direct_message_channel) } + + before { sign_in(user) } + + it "can create and destroy chat drafts" do + expect { + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + + expect { post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id } }.to change { + ChatDraft.count + }.by(-1) + end + + it "cannot create chat drafts for a category channel the user cannot access" do + group = Fabricate(:group) + private_category = Fabricate(:private_category, group: group) + chat_channel.update!(chatable: private_category) + + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + expect(response.status).to eq(403) + + GroupUser.create!(user: user, group: group) + expect { + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + end + + it "cannot create chat drafts for a direct message channel the user cannot access" do + post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } + expect(response.status).to eq(403) + + DirectMessageUser.create(user: user, direct_message: dm_channel.chatable) + expect { + post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + end + end + + describe "#message_link" do + it "ensures message's channel can be seen" do + channel = Fabricate(:category_channel, chatable: Fabricate(:category)) + message = Fabricate(:chat_message, chat_channel: channel) + + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(Fabricate(:user)) + get "/chat/message/#{message.id}.json" + end + end + + describe "#lookup_message" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + let(:channel) { Fabricate(:direct_message_channel) } + let(:chatable) { channel.chatable } + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "ensures message's channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + end + + context "when the message doesn’t belong to the channel" do + let!(:message) { Fabricate(:chat_message) } + + it "returns a 404" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + + expect(response.status).to eq(404) + end + end + + context "when the chat channel is for a category" do + let(:channel) { Fabricate(:category_channel) } + + it "ensures the user can access that category" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + + group = Fabricate(:group) + chatable.update!(read_restricted: true) + Fabricate(:category_group, group: group, category: chatable) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(403) + + GroupUser.create!(user: user, group: group) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + end + end + + context "when the chat channel is for a direct message channel" do + let(:channel) { Fabricate(:direct_message_channel) } + + it "ensures the user can access that direct message channel" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(403) + + DirectMessageUser.create!(user: user, direct_message: chatable) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + end + end + end + + describe "#move_messages_to_channel" do + fab!(:message_to_move1) do + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "some cool message", + created_at: 2.minutes.ago, + ) + end + fab!(:message_to_move2) do + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "and another thing", + created_at: 1.minute.ago, + ) + end + fab!(:destination_channel) { Fabricate(:category_channel) } + let(:message_ids) { [message_to_move1.id, message_to_move2.id] } + let(:invalid_destination_channel) do + Fabricate(:direct_message_channel, users: [admin, Fabricate(:user)]) + end + + context "when the user is not admin" do + it "returns an access denied error" do + sign_in(user) + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(403) + end + end + + context "when the user is admin" do + before { sign_in(admin) } + + it "shows an error if the source channel is not found" do + chat_channel.trash! + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(404) + end + + it "shows an error if the destination channel is not found" do + destination_channel.trash! + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(404) + end + + it "successfully moves the messages to the new channel" do + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(200) + latest_destination_messages = destination_channel.chat_messages.last(2) + expect(latest_destination_messages.first.message).to eq("some cool message") + expect(latest_destination_messages.second.message).to eq("and another thing") + expect(message_to_move1.reload.deleted_at).not_to eq(nil) + expect(message_to_move2.reload.deleted_at).not_to eq(nil) + end + + it "shows an error message when the destination channel is invalid" do + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: invalid_destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.message_move_invalid_channel"), + ) + end + + it "shows an error when none of the messages can be found" do + destroyed_message = Fabricate(:chat_message, chat_channel: chat_channel) + destroyed_message.trash! + + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: [destroyed_message], + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.message_move_no_messages_found"), + ) + end + end + end +end diff --git a/plugins/chat/spec/requests/direct_messages_controller_spec.rb b/plugins/chat/spec/requests/direct_messages_controller_spec.rb new file mode 100644 index 0000000000..5a327efe33 --- /dev/null +++ b/plugins/chat/spec/requests/direct_messages_controller_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::DirectMessagesController do + fab!(:user) { Fabricate(:user) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + sign_in(user) + end + + def create_dm_channel(user_ids) + direct_messages_channel = DirectMessage.create! + user_ids.each do |user_id| + direct_messages_channel.direct_message_users.create!(user_id: user_id) + end + DirectMessageChannel.create!(chatable: direct_messages_channel) + end + + describe "#index" do + context "when user is not allowed to chat" do + before { SiteSetting.chat_allowed_groups = nil } + + it "returns a forbidden error" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(403) + end + end + + context "when channel doesn’t exists" do + it "returns a not found error" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(404) + end + end + + context "when channel exists" do + let!(:channel) do + direct_messages_channel = DirectMessage.create! + direct_messages_channel.direct_message_users.create!(user_id: user.id) + direct_messages_channel.direct_message_users.create!(user_id: user1.id) + DirectMessageChannel.create!(chatable: direct_messages_channel) + end + + it "returns the channel" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_channel"]["id"]).to eq(channel.id) + end + + context "with more than two users" do + fab!(:user3) { Fabricate(:user) } + before { channel.chatable.direct_message_users.create!(user_id: user3.id) } + + it "returns the channel" do + get "/chat/direct_messages.json", + params: { + usernames: [user1.username, user.username, user3.username].join(","), + } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_channel"]["id"]).to eq(channel.id) + end + end + end + end + + describe "#create" do + before { Group.refresh_automatic_groups! } + + shared_examples "creating dms" do + it "creates a new dm channel with username(s) provided" do + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.to change { DirectMessage.count }.by(1) + expect(DirectMessage.last.direct_message_users.map(&:user_id)).to match_array( + direct_message_user_ids, + ) + end + + it "returns existing dm channel if one exists for username(s)" do + create_dm_channel(direct_message_user_ids) + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.not_to change { DirectMessage.count } + end + end + + describe "dm with one other user" do + let(:usernames) { user1.username } + let(:direct_message_user_ids) { [user.id, user1.id] } + + include_examples "creating dms" + end + + describe "dm with myself" do + let(:usernames) { [user.username] } + let(:direct_message_user_ids) { [user.id] } + + include_examples "creating dms" + end + + describe "dm with two other users" do + let(:usernames) { [user1, user2, user3].map(&:username) } + let(:direct_message_user_ids) { [user.id, user1.id, user2.id, user3.id] } + + include_examples "creating dms" + end + + it "creates UserChatChannelMembership records" do + users = [user2, user3] + usernames = users.map(&:username) + expect { + post "/chat/direct_messages/create.json", params: { usernames: usernames } + }.to change { UserChatChannelMembership.count }.by(3) + end + + context "when one of the users I am messaging has ignored, muted, or prevented DMs from the acting user creating the channel" do + let(:usernames) { [user1, user2, user3].map(&:username) } + let(:direct_message_user_ids) { [user.id, user1.id, user2.id, user3.id] } + + shared_examples "creating dms with communication error" do + it "responds with a friendly error" do + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.not_to change { DirectMessage.count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq( + [I18n.t("chat.errors.not_accepting_dms", username: user1.username)], + ) + end + end + + describe "user ignoring the actor" do + before do + Fabricate(:ignored_user, user: user1, ignored_user: user, expiring_at: 1.day.from_now) + end + + include_examples "creating dms with communication error" + end + + describe "user muting the actor" do + before { Fabricate(:muted_user, user: user1, muted_user: user) } + + include_examples "creating dms with communication error" + end + + describe "user preventing all DMs" do + before { user1.user_option.update(allow_private_messages: false) } + + include_examples "creating dms with communication error" + end + + describe "user only allowing DMs from certain users" do + before { user1.user_option.update(enable_allowed_pm_users: true) } + + include_examples "creating dms with communication error" + end + end + end +end diff --git a/plugins/chat/spec/requests/email_controller_spec.rb b/plugins/chat/spec/requests/email_controller_spec.rb new file mode 100644 index 0000000000..5454634703 --- /dev/null +++ b/plugins/chat/spec/requests/email_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe EmailController do + describe "unsubscribing from chat email settings" do + fab!(:user) { Fabricate(:user) } + + it "updates an user chat summary frequency" do + SiteSetting.chat_enabled = true + never_freq = "never" + key = UnsubscribeKey.create_key_for(user, "chat_summary") + user.user_option.send_chat_email_when_away! + + post "/email/unsubscribe/#{key}.json", params: { chat_email_frequency: never_freq } + + expect(response.status).to eq(302) + + get response.redirect_url + + expect(body).to include(user.email) + expect(user.user_option.reload.chat_email_frequency).to eq(never_freq) + end + end +end diff --git a/plugins/chat/spec/requests/emojis_controller_spec.rb b/plugins/chat/spec/requests/emojis_controller_spec.rb new file mode 100644 index 0000000000..e193411f7c --- /dev/null +++ b/plugins/chat/spec/requests/emojis_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::EmojisController do + fab!(:user_1) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + sign_in(user_1) + end + + describe "#index" do + before do + CustomEmoji.destroy_all + CustomEmoji.create!(name: "cat", upload: Fabricate(:upload)) + Emoji.clear_cache + end + + after do + CustomEmoji.destroy_all + Emoji.clear_cache + end + + it "returns the emojis list" do + get "/chat/emojis.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.keys).to eq( + %w[ + smileys_&_emotion + people_&_body + objects + travel_&_places + animals_&_nature + food_&_drink + activities + flags + symbols + default + ], + ) + end + end +end diff --git a/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb b/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb new file mode 100644 index 0000000000..448230c2b8 --- /dev/null +++ b/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::IncomingChatWebhooksController do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:webhook) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) } + + before { SiteSetting.chat_debug_webhook_payloads = true } + + describe "#create_message" do + it "errors with invalid key" do + post "/chat/hooks/null.json" + expect(response.status).to eq(400) + end + + it "errors when no body is present" do + post "/chat/hooks/#{webhook.key}.json" + expect(response.status).to eq(400) + end + + it "errors when the body is over WEBHOOK_MAX_MESSAGE_LENGTH characters" do + post "/chat/hooks/#{webhook.key}.json", + params: { + text: "$" * (Chat::IncomingChatWebhooksController::WEBHOOK_MAX_MESSAGE_LENGTH + 1), + } + expect(response.status).to eq(400) + end + + it "creates a new chat message" do + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + chat_webhook_event = ChatWebhookEvent.last + expect(chat_webhook_event.chat_message_id).to eq(ChatMessage.last.id) + end + + it "handles create message failures gracefully and does not create the chat message" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "hey #{watched_word.word}" } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + "Sorry, you can't post the word '#{watched_word.word}'; it's not allowed.", + ) + end + + it "handles create message failures gracefully if the channel is read only" do + chat_channel.update!(status: :read_only) + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "hey this is a message" } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "rate limits" do + RateLimiter.enable + RateLimiter.clear_all! + 10.times { post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } } + expect(response.status).to eq(200) + + post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } + expect(response.status).to eq(429) + end + end + + describe "#create_message_slack_compatible" do + it "processes the text param with SlackCompatibility" do + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { text: "A new signup woo !" } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + expect(ChatMessage.last.message).to eq("A new signup woo @here!") + end + + it "processes the attachments param with SlackCompatibility, using the fallback" do + payload_data = { + attachments: [ + { + color: "#F4511E", + title: "New+alert:+#46353", + text: + "\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"", + fallback: + "New+alert:+\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"+\nTags:+", + title_link: "https://eu.opsg.in/a/i/test/blahguid", + }, + ], + } + expect { post "/chat/hooks/#{webhook.key}/slack.json", params: payload_data }.to change { + ChatMessage.where(chat_channel: chat_channel).count + }.by(1) + expect(ChatMessage.last.message).to eq( + "New alert: \"[StatusCake] https://www.test_notification.com (StatusCake Test Alert): Down,\" [46353](https://eu.opsg.in/a/i/test/blahguid)\nTags: ", + ) + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { payload: payload_data } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + end + + it "can process the payload when it's a JSON string" do + payload_data = { + attachments: [ + { + color: "#F4511E", + title: "New+alert:+#46353", + text: + "\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"", + fallback: + "New+alert:+\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"+\nTags:+", + title_link: "https://eu.opsg.in/a/i/test/blahguid", + }, + ], + } + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { payload: payload_data.to_json } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(ChatMessage.last.message).to eq( + "New alert: \"[StatusCake] https://www.test_notification.com (StatusCake Test Alert): Down,\" [46353](https://eu.opsg.in/a/i/test/blahguid)\nTags: ", + ) + end + end +end diff --git a/plugins/chat/spec/requests/users_controller_spec.rb b/plugins/chat/spec/requests/users_controller_spec.rb new file mode 100644 index 0000000000..4b518fda75 --- /dev/null +++ b/plugins/chat/spec/requests/users_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +describe UsersController do + describe "#perform_account_activation" do + let!(:channel) { Fabricate(:category_channel, auto_join_users: true) } + + before do + Jobs.run_immediately! + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) + SiteSetting.send_welcome_message = false + SiteSetting.chat_enabled = true + end + + it "triggers the auto-join process" do + user = Fabricate(:user, last_seen_at: 1.minute.ago, active: false) + email_token = Fabricate(:email_token, user: user) + + put "/u/activate-account/#{email_token.token}" + + expect(response.status).to eq(200) + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership.following).to eq(true) + end + end +end diff --git a/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb b/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb new file mode 100644 index 0000000000..4a164ae872 --- /dev/null +++ b/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatChannelSerializer do + fab!(:user) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:chat_channel) } + let(:guardian_user) { user } + let(:guardian) { Guardian.new(guardian_user) } + subject { described_class.new(chat_channel, scope: guardian, root: nil) } + + describe "archive status" do + context "when user is not staff" do + let(:guardian_user) { user } + + it "does not return any sort of archive status" do + expect(subject.as_json.key?(:archive_completed)).to eq(false) + end + end + + context "when user is staff" do + let(:guardian_user) { admin } + + it "includes the archive status if the channel is archived and the archive record exists" do + expect(subject.as_json.key?(:archive_completed)).to eq(false) + + chat_channel.update!(status: ChatChannel.statuses[:archived]) + expect(subject.as_json.key?(:archive_completed)).to eq(false) + + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: admin, + destination_topic_title: "This will be the archive topic", + total_messages: 10, + ) + chat_channel.reload + expect(subject.as_json.key?(:archive_completed)).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb b/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb new file mode 100644 index 0000000000..bf873d9071 --- /dev/null +++ b/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChatInReplyToSerializer do + subject(:serializer) { described_class.new(message, scope: guardian, root: nil) } + + fab!(:chat_channel) { Fabricate(:chat_channel) } + let(:guardian) { Guardian.new(Fabricate(:user)) } + + describe "#user" do + let(:message) { Fabricate(:chat_message, user: Fabricate(:user), chat_channel: chat_channel) } + + context "when user has been destroyed" do + before do + message.user.destroy! + message.reload + end + + it "returns a placeholder user" do + expect(serializer.as_json[:user][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end + + describe "#excerpt" do + let(:watched_word) { Fabricate(:watched_word, action: WatchedWord.actions[:censor]) } + let(:message) { Fabricate(:chat_message, message: "ok #{watched_word.word}") } + + it "censors words" do + expect(serializer.as_json[:excerpt]).to eq("ok ■■■■■") + end + end +end diff --git a/plugins/chat/spec/serializer/chat_message_serializer_spec.rb b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb new file mode 100644 index 0000000000..ea97d0310d --- /dev/null +++ b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessageSerializer do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:message_poster) { Fabricate(:user) } + fab!(:message_1) { Fabricate(:chat_message, user: message_poster, chat_channel: chat_channel) } + fab!(:guardian_user) { Fabricate(:user) } + let(:guardian) { Guardian.new(guardian_user) } + + subject { described_class.new(message_1, scope: guardian, root: nil) } + + describe "#reactions" do + fab!(:custom_emoji) { CustomEmoji.create!(name: "trout", upload: Fabricate(:upload)) } + fab!(:reaction_1) do + Fabricate(:chat_message_reaction, chat_message: message_1, emoji: custom_emoji.name) + end + + context "when an emoji used in a reaction has been destroyed" do + it "doesn’t return the reaction" do + Emoji.clear_cache + + expect(subject.as_json[:reactions]["trout"]).to be_present + + custom_emoji.destroy! + Emoji.clear_cache + + expect(subject.as_json[:reactions]["trout"]).to_not be_present + end + end + end + + describe "#excerpt" do + it "censors words" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) + message = Fabricate(:chat_message, message: "ok #{watched_word.word}") + serializer = described_class.new(message, scope: guardian, root: nil) + + expect(serializer.as_json[:excerpt]).to eq("ok ■■■■■") + end + end + + describe "#user" do + context "when user has been destroyed" do + it "returns a placeholder user" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:user][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end + + describe "#deleted_at" do + context "when user has been destroyed" do + it "has a deleted at date" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:deleted_at]).to(be_within(1.second).of(Time.zone.now)) + end + + it "is marked as deleted by system user" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:deleted_by_id]).to eq(Discourse.system_user.id) + end + end + end + + describe "#available_flags" do + before { Group.refresh_automatic_groups! } + + context "when flagging on a regular channel" do + let(:options) { { scope: guardian, root: nil, chat_channel: message_1.chat_channel } } + + it "returns an empty list if the user already flagged the message" do + reviewable = Fabricate(:reviewable_chat_message, target: message_1) + + serialized = + described_class.new( + message_1, + options.merge( + reviewable_ids: { + message_1.id => reviewable.id, + }, + user_flag_statuses: { + message_1.id => ReviewableScore.statuses[:pending], + }, + ), + ).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "return available flags if staff already reviewed the previous flag" do + reviewable = Fabricate(:reviewable_chat_message, target: message_1) + + serialized = + described_class.new( + message_1, + options.merge( + reviewable_ids: { + message_1.id => reviewable.id, + }, + user_flag_statuses: { + message_1.id => ReviewableScore.statuses[:ignored], + }, + ), + ).as_json + + expect(serialized[:available_flags]).to be_present + end + + it "doesn't include notify_user for self-flags" do + guardian_1 = Guardian.new(message_1.user) + + serialized = + described_class.new(message_1, options.merge(scope: Guardian.new(message_poster))).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "doesn't include the notify_user flag for bot messages" do + message_1.update!(user: Discourse.system_user) + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "returns an empty list for anons" do + serialized = described_class.new(message_1, options.merge(scope: Guardian.new)).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "returns an empty list for silenced users" do + guardian.user.update!(silenced_till: 1.month.from_now) + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "returns an empty list if the message was deleted" do + message_1.trash! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "doesn't include notify_user if they are not in a PM allowed group" do + SiteSetting.personal_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] + Group.refresh_automatic_groups! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "returns an empty list if the user needs a higher TL to flag" do + guardian.user.update!(trust_level: TrustLevel[2]) + SiteSetting.chat_message_flag_allowed_groups = Group::AUTO_GROUPS[:trust_level_3] + Group.refresh_automatic_groups! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + end + + context "when flagging DMs" do + fab!(:dm_channel) do + Fabricate(:direct_message_channel, users: [guardian_user, message_poster]) + end + fab!(:dm_message) { Fabricate(:chat_message, user: message_poster, chat_channel: dm_channel) } + + let(:options) { { scope: guardian, root: nil, chat_channel: dm_channel } } + + it "doesn't include the notify_user flag type" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "doesn't include the notify_moderators flag type" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_moderators) + end + + it "includes other flags" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).to include(:spam) + end + + it "fallbacks to the object association when the chat_channel option is nil" do + serialized = described_class.new(dm_message, options.except(:chat_channel)).as_json + + expect(serialized[:available_flags]).not_to include(:notify_moderators) + end + end + end +end diff --git a/plugins/chat/spec/serializer/direct_message_serializer_spec.rb b/plugins/chat/spec/serializer/direct_message_serializer_spec.rb new file mode 100644 index 0000000000..0747661ce6 --- /dev/null +++ b/plugins/chat/spec/serializer/direct_message_serializer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DirectMessageSerializer do + describe "#user" do + it "returns you when there are two of us" do + me = Fabricate.build(:user) + you = Fabricate.build(:user) + direct_message = Fabricate.build(:direct_message, users: [me, you]) + + serializer = described_class.new(direct_message, scope: Guardian.new(me), root: false) + + expect(serializer.users).to eq([you]) + end + + it "returns you both if there are three of us" do + me = Fabricate.build(:user) + you = Fabricate.build(:user) + other_you = Fabricate.build(:user) + direct_message = Fabricate.build(:direct_message, users: [me, you, other_you]) + + serializer = described_class.new(direct_message, scope: Guardian.new(me), root: false) + + expect(serializer.users).to match_array([you, other_you]) + end + + it "returns me if there is only me" do + me = Fabricate.build(:user) + direct_message = Fabricate.build(:direct_message, users: [me]) + + serializer = described_class.new(direct_message, scope: Guardian.new(me), root: false) + + expect(serializer.users).to eq([me]) + end + + context "when a user is destroyed" do + it "returns a placeholder user" do + me = Fabricate(:user) + you = Fabricate(:user) + direct_message = Fabricate(:direct_message, users: [me, you]) + + you.destroy! + + serializer = + described_class.new(direct_message.reload, scope: Guardian.new(me), root: false).as_json + + expect(serializer[:users][0][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end +end diff --git a/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb b/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb new file mode 100644 index 0000000000..503760588e --- /dev/null +++ b/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.describe StructuredChannelSerializer do + fab!(:user1) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user1) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:channel1) { Fabricate(:category_channel) } + fab!(:channel2) { Fabricate(:category_channel) } + fab!(:channel3) { Fabricate(:direct_message_channel, users: [user1, user2]) } + fab!(:channel4) { Fabricate(:direct_message_channel, users: [user1, user3]) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel2) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership_for_dm, user: user1, chat_channel: channel3) + end + fab!(:membership4) do + Fabricate(:user_chat_channel_membership_for_dm, user: user1, chat_channel: channel4) + end + fab!(:membership5) do + Fabricate(:user_chat_channel_membership_for_dm, user: user2, chat_channel: channel3) + end + fab!(:membership6) do + Fabricate(:user_chat_channel_membership_for_dm, user: user3, chat_channel: channel4) + end + + def fetch_data + Chat::ChatChannelFetcher.structured(guardian) + end + + it "serializes a public channel correctly with membership embedded" do + expect( + described_class + .new(fetch_data, scope: guardian) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to include( + "chat_channel_id" => channel1.id, + "desktop_notification_level" => "mention", + "following" => true, + "last_read_message_id" => nil, + "mobile_notification_level" => "mention", + "muted" => false, + "unread_count" => 0, + "unread_mentions" => 0, + ) + end + + it "serializes a direct message channel correctly with membership embedded" do + expect( + described_class + .new(fetch_data, scope: guardian) + .direct_message_channels + .find { |channel| channel.id == channel3.id } + .current_user_membership + .as_json, + ).to include( + "chat_channel_id" => channel3.id, + "desktop_notification_level" => "always", + "following" => true, + "last_read_message_id" => nil, + "mobile_notification_level" => "always", + "muted" => false, + "unread_count" => 0, + "unread_mentions" => 0, + ) + end + + it "does not include membership details for an anonymous user" do + expect( + described_class + .new(fetch_data, scope: Guardian.new) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to eq(nil) + end + + it "does not include membership if somehow the data is missing" do + data = fetch_data + data[:memberships] = data[:memberships].reject do |membership| + membership.chat_channel_id == channel1.id + end + expect( + described_class + .new(data, scope: guardian) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to eq(nil) + end +end diff --git a/plugins/chat/spec/services/chat_publisher_spec.rb b/plugins/chat/spec/services/chat_publisher_spec.rb new file mode 100644 index 0000000000..3416170a32 --- /dev/null +++ b/plugins/chat/spec/services/chat_publisher_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatPublisher do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + describe ".publish_refresh!" do + it "publishes the message" do + data = MessageBus.track_publish { ChatPublisher.publish_refresh!(channel, message) }[0].data + + expect(data["chat_message"]["id"]).to eq(message.id) + expect(data["type"]).to eq("refresh") + end + end +end diff --git a/plugins/chat/spec/support/api/schemas/category_chat_channel.json b/plugins/chat/spec/support/api/schemas/category_chat_channel.json new file mode 100644 index 0000000000..a0905eab33 --- /dev/null +++ b/plugins/chat/spec/support/api/schemas/category_chat_channel.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "additionalProperties": { + "auto_join_users": { "type": "boolean" } + }, + "properties": { + "id": { "type": "number" }, + "chatable_type": { "type": "string" }, + "chatable_url": { "type": "string" }, + "title": { "type": "string" }, + "chatable_id": { "type": "number" }, + "last_message_sent_at": { "type": "string" }, + "status": { "type": "string" }, + "chatable": { + "type": "object", + "required": ["id", "name", "slug", "color"] + }, + "current_user_membership": { + "type": ["object", "null"], + "properties": { + "last_read_message_id": { "type": ["number", "null"] }, + "muted": { "type": "boolean" }, + "unread_count": { "type": "number" }, + "unread_mentions": { "type": "number" }, + "desktop_notification_level": { "type": "string" }, + "mobile_notification_level": { "type": "string" }, + "following": { "type": "boolean" } + } + } + } +} diff --git a/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json new file mode 100644 index 0000000000..30725b7d44 --- /dev/null +++ b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": [ + "chat_channel_id", + "last_read_message_id", + "muted", + "desktop_notification_level", + "mobile_notification_level", + "following" + ], + "properties": { + "chat_channel_id": { "type": "number" }, + "last_read_message_id": { "type": ["number", "null"] }, + "muted": { "type": "boolean" }, + "desktop_notification_level": { "type": "string" }, + "mobile_notification_level": { "type": "string" }, + "following": { "type": "boolean" }, + "unread_count": { "type": "number" }, + "unread_mentions": { "type": "number" }, + "user": { + "type": ["object", "null"], + "required": ["id", "name", "avatar_template", "username"], + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "avatar_template": { "type": "string" }, + "username": { "type": "string" } + } + } + } +} diff --git a/plugins/chat/spec/support/api_schema_matcher.rb b/plugins/chat/spec/support/api_schema_matcher.rb new file mode 100644 index 0000000000..e78e158d79 --- /dev/null +++ b/plugins/chat/spec/support/api_schema_matcher.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :match_response_schema do |schema| + match do |object| + schema_directory = "#{Dir.pwd}/plugins/chat/spec/support/api/schemas" + schema_path = "#{schema_directory}/#{schema}.json" + + begin + JSON::Validator.validate!(schema_path, object, strict: true) + rescue JSON::Schema::ValidationError => e + puts "-- Printing response body after validation error\n" + pp object + raise e + end + end +end diff --git a/plugins/chat/spec/support/chat_helper.rb b/plugins/chat/spec/support/chat_helper.rb new file mode 100644 index 0000000000..9a093be436 --- /dev/null +++ b/plugins/chat/spec/support/chat_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ChatHelper + def self.make_messages!(chatable, users, count) + users = [users] unless Array === users + raise ArgumentError if users.length <= 0 + + chatable = Fabricate(:category) unless chatable + chat_channel = Fabricate(:chat_channel, chatable: chatable) + + count.times do |n| + ChatMessage.new( + chat_channel: chat_channel, + user: users[n % users.length], + message: "Chat message for test #{n}", + ).save! + end + end +end diff --git a/plugins/chat/spec/support/examples/channel_access_example.rb b/plugins/chat/spec/support/examples/channel_access_example.rb new file mode 100644 index 0000000000..4a242bcd8c --- /dev/null +++ b/plugins/chat/spec/support/examples/channel_access_example.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples "channel access example" do |verb, endpoint| + endpoint ||= ".json" + + context "when channel is not found" do + before { sign_in(Fabricate(:admin)) } + + it "returns a 404" do + public_send(verb, "/chat/api/chat_channels/-999#{endpoint}") + expect(response.status).to eq(404) + end + end + + context "with anonymous user" do + fab!(:chat_channel) { Fabricate(:category_channel) } + + it "returns a 403" do + public_send(verb, "/chat/api/chat_channels/#{chat_channel.id}#{endpoint}") + expect(response.status).to eq(403) + end + end + + context "when channel can’t be seen by current user" do + fab!(:chatable) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: chatable) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "returns a 403" do + public_send(verb, "/chat/api/chat_channels/#{chat_channel.id}#{endpoint}") + expect(response.status).to eq(403) + end + end +end diff --git a/plugins/chat/spec/support/examples/chat_channel_model.rb b/plugins/chat/spec/support/examples/chat_channel_model.rb new file mode 100644 index 0000000000..58ae52b29c --- /dev/null +++ b/plugins/chat/spec/support/examples/chat_channel_model.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a chat channel model" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:staff) { Fabricate(:user, admin: true) } + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + fab!(:private_category_channel) { Fabricate(:category_channel, chatable: private_category) } + fab!(:direct_message_channel) { Fabricate(:direct_message_channel, users: [user1, user2]) } + + it { is_expected.to belong_to(:chatable) } + it { is_expected.to belong_to(:direct_message).with_foreign_key(:chatable_id) } + it { is_expected.to have_many(:chat_messages) } + it { is_expected.to have_many(:user_chat_channel_memberships) } + it { is_expected.to have_one(:chat_channel_archive) } + it { is_expected.to delegate_method(:empty?).to(:chat_messages).with_prefix } + it do + is_expected.to define_enum_for(:status).with_values( + open: 0, + read_only: 1, + closed: 2, + archived: 3, + ).without_scopes + end + + describe "Validations" do + it { is_expected.to validate_presence_of(:name).allow_nil } + it do + is_expected.to validate_length_of(:name).is_at_most( + SiteSetting.max_topic_title_length, + ).allow_nil + end + end + + describe ".public_channels" do + context "when a category used as chatable is destroyed" do + fab!(:category_channel_1) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:category_channel_2) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + + before { category_channel_1.chatable.destroy! } + + it "doesn’t list the channel" do + ids = ChatChannel.public_channels.pluck(:chatable_id) + expect(ids).to_not include(category_channel_1.chatable_id) + expect(ids).to include(category_channel_2.chatable_id) + end + end + end + + describe "#closed!" do + before { private_category_channel.update!(status: :open) } + + it "does nothing if user is not staff" do + private_category_channel.closed!(user1) + expect(private_category_channel.reload.open?).to eq(true) + end + + it "closes the channel, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.closed!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [{ channel: private_category_channel, old_status: "open", new_status: "closed" }], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "closed" }, + ) + expect(private_category_channel.reload.closed?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :closed, + previous_value: :open, + ), + ).to eq(true) + end + end + + describe "#open!" do + before { private_category_channel.update!(status: :closed) } + + it "does nothing if user is not staff" do + private_category_channel.open!(user1) + expect(private_category_channel.reload.closed?).to eq(true) + end + + it "does nothing if the channel is archived" do + private_category_channel.update!(status: :archived) + private_category_channel.open!(staff) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "opens the channel, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.open!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [{ channel: private_category_channel, old_status: "closed", new_status: "open" }], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "open" }, + ) + expect(private_category_channel.reload.open?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :open, + previous_value: :closed, + ), + ).to eq(true) + end + end + + describe "#read_only!" do + before { private_category_channel.update!(status: :open) } + + it "does nothing if user is not staff" do + private_category_channel.read_only!(user1) + expect(private_category_channel.reload.open?).to eq(true) + end + + it "marks the channel read_only, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.read_only!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [ + { channel: private_category_channel, old_status: "open", new_status: "read_only" }, + ], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "read_only" }, + ) + expect(private_category_channel.reload.read_only?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :read_only, + previous_value: :open, + ), + ).to eq(true) + end + end + + describe "#archived!" do + before { private_category_channel.update!(status: :read_only) } + + it "does nothing if user is not staff" do + private_category_channel.archived!(user1) + expect(private_category_channel.reload.read_only?).to eq(true) + end + + it "does nothing if already archived" do + private_category_channel.update!(status: :archived) + private_category_channel.archived!(user1) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "does nothing if the channel is not already readonly" do + private_category_channel.update!(status: :open) + private_category_channel.archived!(staff) + expect(private_category_channel.reload.open?).to eq(true) + private_category_channel.update!(status: :read_only) + private_category_channel.archived!(staff) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "marks the channel archived, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.archived!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [ + { channel: private_category_channel, old_status: "read_only", new_status: "archived" }, + ], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "archived" }, + ) + expect(private_category_channel.reload.archived?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :archived, + previous_value: :read_only, + ), + ).to eq(true) + end + end + + describe "#add" do + before { group.add(user1) } + + it "creates a membership for the user and enqueues a job to update the count" do + initial_count = private_category_channel.user_count + + membership = private_category_channel.add(user1) + private_category_channel.reload + + expect(membership.following).to eq(true) + expect(membership.user).to eq(user1) + expect(membership.chat_channel).to eq(private_category_channel) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "updates an existing membership for the user and enqueues a job to update the count" do + membership = + UserChatChannelMembership.create!( + chat_channel: private_category_channel, + user: user1, + following: false, + ) + + private_category_channel.add(user1) + private_category_channel.reload + + expect(membership.reload.following).to eq(true) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "does nothing if the user is already a member" do + membership = + UserChatChannelMembership.create!( + chat_channel: private_category_channel, + user: user1, + following: true, + ) + + expect(private_category_channel.user_count_stale).to eq(false) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.add(user1) } + end + + it "does not recalculate user count if it's already been marked as stale" do + private_category_channel.update!(user_count_stale: true) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.add(user1) } + end + end + + describe "#remove" do + before do + group.add(user1) + @membership = private_category_channel.add(user1) + private_category_channel.reload + private_category_channel.update!(user_count_stale: false) + end + + it "updates the membership for the user and decreases the count" do + membership = private_category_channel.remove(user1) + private_category_channel.reload + + expect(@membership.reload.following).to eq(false) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "returns nil if the user doesn't have a membership" do + expect(private_category_channel.remove(user2)).to eq(nil) + end + + it "does nothing if the user is not following the channel" do + @membership.update!(following: false) + + private_category_channel.remove(user1) + private_category_channel.reload + + expect(private_category_channel.user_count_stale).to eq(false) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "does not recalculate user count if it's already been marked as stale" do + private_category_channel.update!(user_count_stale: true) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.remove(user1) } + end + end +end diff --git a/plugins/chat/spec/support/examples/chatable_model.rb b/plugins/chat/spec/support/examples/chatable_model.rb new file mode 100644 index 0000000000..78237d7937 --- /dev/null +++ b/plugins/chat/spec/support/examples/chatable_model.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a chatable model" do + describe "#chat_channel" do + subject(:chat_channel) { chatable.chat_channel } + + it "returns a new chat channel model" do + expect(chat_channel).to have_attributes persisted?: false, + class: channel_class, + chatable: chatable + end + end + + describe "#create_chat_channel!" do + subject(:create_chat_channel) { chatable.create_chat_channel!(name: name) } + + let(:name) { "a custom name" } + + it "creates a proper chat channel" do + expect { create_chat_channel }.to change { channel_class.count }.by(1) + expect(channel_class.last).to have_attributes chatable: chatable, name: name + end + end +end diff --git a/plugins/chat/spec/system/navigation_spec.rb b/plugins/chat/spec/system/navigation_spec.rb new file mode 100644 index 0000000000..59f239cac6 --- /dev/null +++ b/plugins/chat/spec/system/navigation_spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +RSpec.describe "Navigation", type: :system, js: true do + fab!(:category) { Fabricate(:category) } + fab!(:topic) { Fabricate(:topic) } + fab!(:post) { Fabricate(:post, topic: topic) } + fab!(:user) { Fabricate(:admin) } + fab!(:category_channel) { Fabricate(:category_channel) } + fab!(:category_channel_2) { Fabricate(:category_channel) } + fab!(:message) { Fabricate(:chat_message, chat_channel: category_channel) } + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:sidebar_page) { PageObjects::Pages::Sidebar.new } + let(:chat_drawer_page) { PageObjects::Pages::ChatDrawer.new } + + before do + # ensures we have one valid registered admin + user.activate + + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + category_channel.add(user) + category_channel_2.add(user) + + sign_in(user) + end + + context "when visiting /chat" do + it "opens full page" do + chat_page.open + + expect(page).to have_current_path( + chat.channel_path(category_channel.id, category_channel.slug), + ) + expect(page).to have_css("html.has-full-page-chat") + expect(page).to have_css(".chat-message-container[data-id='#{message.id}']") + end + end + + context "when opening chat" do + it "opens the drawer by default" do + visit("/") + chat_page.open_from_header + + expect(page).to have_css(".topic-chat-container.expanded.visible") + end + end + + context "when opening chat with full page as preferred mode" do + it "opens the full page" do + visit("/") + chat_page.open_from_header + chat_drawer_page.maximize + + expect(page).to have_current_path( + chat.channel_path(category_channel.id, category_channel.slug), + ) + + visit("/") + chat_page.open_from_header + + expect(page).to have_current_path( + chat.channel_path(category_channel.id, category_channel.slug), + ) + end + end + + context "when opening chat with drawer as preferred mode" do + it "opens the full page" do + chat_page.open + chat_page.minimize_full_page + + expect(page).to have_css(".topic-chat-container.expanded.visible") + + visit("/") + chat_page.open_from_header + + expect(page).to have_css(".topic-chat-container.expanded.visible") + end + end + + context "when collapsing full page with no previous state" do + it "redirects to home page" do + chat_page.open + chat_page.minimize_full_page + + expect(page).to have_current_path(latest_path) + end + end + + context "when collapsing full page with previous state" do + it "redirects to previous state" do + visit("/t/-/#{topic.id}") + chat_page.open_from_header + chat_drawer_page.maximize + chat_page.minimize_full_page + + expect(page).to have_current_path("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".chat-message-container[data-id='#{message.id}']") + end + end + + context "when sidebar is enabled" do + before do + SiteSetting.enable_experimental_sidebar_hamburger = true + SiteSetting.enable_sidebar = true + end + + context "when opening channel from sidebar with drawer preferred" do + it "opens channel in drawer" do + visit("/t/-/#{topic.id}") + chat_page.open_from_header + chat_drawer_page.close + find("a[title='#{category_channel.title}']").click + + expect(page).to have_css(".chat-message-container[data-id='#{message.id}']") + end + end + + context "when opening channel from sidebar with full page preferred" do + it "opens channel in full page" do + visit("/") + chat_page.open_from_header + chat_drawer_page.maximize + visit("/") + find("a[title='#{category_channel.title}']").click + + expect(page).to have_current_path( + chat.channel_path(category_channel.id, category_channel.slug), + ) + end + end + + context "when starting draft from sidebar with drawer preferred" do + it "opens draft in drawer" do + visit("/") + sidebar_page.open_draft_channel + + expect(page).to have_current_path("/") + expect(page).to have_css(".topic-chat-container.expanded.visible .direct-message-creator") + end + end + + context "when starting draft from drawer with drawer preferred" do + it "opens draft in drawer" do + visit("/") + chat_page.open_from_header + chat_drawer_page.open_draft_channel + + expect(page).to have_current_path("/") + expect(page).to have_css(".topic-chat-container.expanded.visible .direct-message-creator") + end + end + + context "when starting draft from sidebar with full page preferred" do + it "opens draft in full page" do + visit("/") + chat_page.open_from_header + chat_drawer_page.maximize + visit("/") + sidebar_page.open_draft_channel + + expect(page).to have_current_path("/chat/draft-channel") + expect(page).not_to have_css(".topic-chat-container.expanded.visible") + end + end + + context "when opening browse page from drawer in drawer mode" do + it "opens browser page in full page" do + visit("/") + chat_page.open_from_header + chat_drawer_page.open_browse + + expect(page).to have_current_path("/chat/browse/open") + expect(page).not_to have_css(".topic-chat-container.expanded.visible") + end + end + + context "when opening browse page from sidebar in drawer mode" do + it "opens browser page in full page" do + visit("/") + chat_page.open_from_header + sidebar_page.open_browse + + expect(page).to have_current_path("/chat/browse/open") + expect(page).not_to have_css(".topic-chat-container.expanded.visible") + end + end + + context "when re-opening drawer after navigating to a channel" do + it "opens drawer on correct channel" do + visit("/") + chat_page.open_from_header + chat_drawer_page.open_channel(category_channel_2) + chat_drawer_page.open_index + chat_drawer_page.close + chat_page.open_from_header + + expect(page).to have_current_path("/") + expect(page).to have_css(".topic-chat-container.expanded.visible") + expect(page).to have_content(category_channel_2.title) + end + end + + context "when re-opening full page chat after navigating to a channel" do + it "opens full page chat on correct channel" do + visit("/") + chat_page.open_from_header + chat_drawer_page.maximize + sidebar_page.open_channel(category_channel_2) + find("#site-logo").click + chat_page.open_from_header + + expect(page).to have_current_path( + chat.channel_path(category_channel_2.id, category_channel_2.slug), + ) + expect(page).to have_content(category_channel_2.title) + end + end + + context "when opening a channel in full page" do + it "activates the channel in the sidebar" do + visit("/chat/channel/#{category_channel.id}/#{category_channel.slug}") + expect(page).to have_css( + ".sidebar-section-link-#{category_channel.slug}.sidebar-section-link--active", + ) + end + end + + context "when clicking logo from a channel in full page" do + it "deactivates the channel in the sidebar" do + visit("/chat/channel/#{category_channel.id}/#{category_channel.slug}") + find("#site-logo").click + + expect(page).not_to have_css( + ".sidebar-section-link-#{category_channel.slug}.sidebar-section-link--active", + ) + end + end + + context "when opening a channel in drawer" do + it "activates the channel in the sidebar" do + visit("/") + chat_page.open_from_header + find("a[title='#{category_channel.title}']").click + + expect(page).to have_css( + ".sidebar-section-link-#{category_channel.slug}.sidebar-section-link--active", + ) + end + end + + context "when closing drawer in a channel" do + it "deactivates the channel in the sidebar" do + visit("/") + chat_page.open_from_header + find("a[title='#{category_channel.title}']").click + chat_drawer_page.close + + expect(page).not_to have_css( + ".sidebar-section-link-#{category_channel.slug}.sidebar-section-link--active", + ) + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/chat/chat.rb b/plugins/chat/spec/system/page_objects/chat/chat.rb new file mode 100644 index 0000000000..43c1529cd8 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat/chat.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class Chat < PageObjects::Pages::Base + def open_from_header + find(".open-chat").click + end + + def open + visit("/chat") + end + + def minimize_full_page + find(".open-drawer-btn").click + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb new file mode 100644 index 0000000000..5e2cae079d --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class ChatDrawer < PageObjects::Pages::Base + VISIBLE_DRAWER = ".topic-chat-container.expanded.visible" + def open_browse + find("#{VISIBLE_DRAWER} .open-browse-page-btn").click + end + + def open_draft_channel + find("#{VISIBLE_DRAWER} .open-draft-channel-page-btn").click + end + + def close + find("#{VISIBLE_DRAWER} .topic-chat-drawer-header__close-btn").click + end + + def open_index + find("#{VISIBLE_DRAWER} .topic-chat-drawer-header__return-to-channels-btn").click + end + + def open_channel(channel) + find( + "#{VISIBLE_DRAWER} .channels-list .chat-channel-row[data-chat-channel-id='#{channel.id}']", + ).click + end + + def maximize + find("#{VISIBLE_DRAWER} .topic-chat-drawer-header__full-screen-btn").click + end + end + end +end diff --git a/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb new file mode 100644 index 0000000000..e7786fafc9 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class Sidebar < PageObjects::Pages::Base + def open_draft_channel + find(".sidebar-section-chat-dms .sidebar-section-header-button", visible: false).click + end + + def open_browse + find(".sidebar-section-chat-channels .sidebar-section-header-button", visible: false).click + end + + def open_channel(channel) + find(".sidebar-section-link[href='/chat/channel/#{channel.id}/#{channel.slug}']").click + end + end + end +end diff --git a/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb b/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb new file mode 100644 index 0000000000..9a2531120d --- /dev/null +++ b/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe ChatAllowUploadsValidator do + it "always returns true if setting the value to false" do + validator = described_class.new + expect(validator.valid_value?("f")).to eq(true) + end + + context "when secure media is enabled" do + before do + SiteSetting.chat_allow_uploads = false + enable_secure_uploads + end + + it "does not allow chat uploads to be enabled" do + validator = described_class.new + expect(validator.valid_value?("t")).to eq(false) + expect(validator.error_message).to eq( + I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads"), + ) + end + + it "allows chat uploads to be enabled if allow_unsecure_chat_uploads global setting is enabled" do + global_setting :allow_unsecure_chat_uploads, true + validator = described_class.new + expect(validator.valid_value?("t")).to eq(true) + expect(validator.error_message).to eq(nil) + end + end +end diff --git a/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb b/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb new file mode 100644 index 0000000000..762c3fce78 --- /dev/null +++ b/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatDefaultChannelValidator do + fab!(:channel) { Fabricate(:category_channel) } + + it "provides an error message" do + validator = described_class.new + expect(validator.error_message).to eq(I18n.t("site_settings.errors.chat_default_channel")) + end + + it "returns true if public channel id" do + validator = described_class.new + expect(validator.valid_value?(channel.id)).to eq(true) + end + + it "returns true if empty string" do + validator = described_class.new + expect(validator.valid_value?("")).to eq(true) + end + + it "returns false if not a public channel" do + validator = described_class.new + channel.destroy! + expect(validator.valid_value?(channel.id)).to eq(false) + end +end diff --git a/plugins/chat/test/javascripts/acceptance/chat-browse-test.js b/plugins/chat/test/javascripts/acceptance/chat-browse-test.js new file mode 100644 index 0000000000..6f8916a2de --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-browse-test.js @@ -0,0 +1,111 @@ +import { + acceptance, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import I18n from "I18n"; +import fabricators from "../helpers/fabricators"; +import { isEmpty } from "@ember/utils"; + +acceptance("Discourse Chat - browse channels", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + // we don't need anything in the sidebar for this test + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + + server.get("/chat/api/chat_channels.json", (request) => { + const params = request.queryParams; + + if (!isEmpty(params.filter)) { + if (params.filter === "foo") { + return helper.response([fabricators.chatChannel()]); + } else { + return helper.response([]); + } + } + + const channels = []; + if (isEmpty(params.status) || params.status === "open") { + channels.push(fabricators.chatChannel()); + channels.push(fabricators.chatChannel()); + } + + if (params.status === "closed" || isEmpty(params.status)) { + channels.push(fabricators.chatChannel({ status: "closed" })); + } + + if (params.status === "archived" || isEmpty(params.status)) { + channels.push(fabricators.chatChannel({ status: "archived" })); + } + + return helper.response(channels); + }); + }); + + test("Defaults to open filter", async function (assert) { + await visit("/chat/browse"); + assert.equal(currentURL(), "/chat/browse/open"); + }); + + test("All filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-all"); + + assert.equal(currentURL(), "/chat/browse/all"); + assert.equal(queryAll(".chat-channel-card").length, 4); + }); + + test("Open filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-open"); + + assert.equal(currentURL(), "/chat/browse/open"); + assert.equal(queryAll(".chat-channel-card").length, 2); + }); + + test("Closed filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-closed"); + + assert.equal(currentURL(), "/chat/browse/closed"); + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("Archived filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-archived"); + + assert.equal(currentURL(), "/chat/browse/archived"); + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("Filtering results", async function (assert) { + await visit("/chat/browse"); + + assert.equal(queryAll(".chat-channel-card").length, 2); + + await fillIn(".dc-filter-input", "foo"); + + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("No results", async function (assert) { + await visit("/chat/browse"); + await fillIn(".dc-filter-input", "bar"); + + assert.equal( + query(".empty-state-title").innerText.trim(), + I18n.t("chat.empty_state.title") + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js b/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js new file mode 100644 index 0000000000..98c9380129 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js @@ -0,0 +1,64 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; +import { getOwner } from "discourse-common/lib/get-owner"; +import fabricators from "../helpers/fabricators"; + +acceptance("Discourse Chat - chat channel info", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + const channel = fabricators.chatChannel(); + server.get("/chat/chat_channels.json", () => { + return helper.response({ + publicMessageChannels: [channel], + directMessageChannels: [], + }); + }); + server.get("/chat/chat_channels/:id.json", () => { + return helper.response(channel); + }); + server.get("/chat/api/chat_channels.json", () => + helper.response([channel]) + ); + server.get("/chat/api/chat_channels/:id/memberships.json", () => + helper.response([]) + ); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + }); + + needs.hooks.beforeEach(function () { + this.manager = getOwner(this).lookup( + "service:chat-channel-info-route-origin-manager" + ); + }); + + needs.hooks.afterEach(function () { + this.manager.origin = null; + }); + + test("Direct visit sets origin as channel", async function (assert) { + await visit("/chat/channel/1/my-category-title/info"); + + assert.strictEqual(this.manager.origin, ORIGINS.channel); + }); + + test("Visit from browse sets origin as browse", async function (assert) { + await visit("/chat/browse/open"); + await click(".chat-channel-card__setting"); + + assert.strictEqual(this.manager.origin, ORIGINS.browse); + }); + + test("Visit from channel sets origin as channel", async function (assert) { + await visit("/chat/channel/1/my-category-title"); + await visit("/chat/channel/1/my-category-title/info"); + + assert.strictEqual(this.manager.origin, ORIGINS.channel); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js b/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js new file mode 100644 index 0000000000..a171b0e903 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js @@ -0,0 +1,24 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { currentURL, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { chatChannels } from "discourse/plugins/chat/chat-fixtures"; + +acceptance("Discourse Chat - chat channel slug", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + }); + + test("Replacing title param", async function (assert) { + await visit("/chat"); + await visit("/chat/channel/11/-"); + + assert.equal(currentURL(), "/chat/channel/11/another-category"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js b/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js new file mode 100644 index 0000000000..ec7ed3a1b3 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js @@ -0,0 +1,54 @@ +import { + acceptance, + exists, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; + +acceptance( + "Discourse Chat - Chat Channels list - no joinable public channels", + function (needs) { + needs.user({ has_chat_enabled: true, has_joinable_public_channels: false }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: cloneJSON(directMessageChannels).mapBy( + "chat_channel" + ), + }); + }); + + server.get("/chat/:id/messages.json", () => { + return helper.response({ + chat_messages: [], + meta: { can_chat: true }, + }); + }); + }); + + test("Public chat channels section visibility", async function (assert) { + await visit("/chat"); + + assert.ok( + exists(".public-channels-section"), + "it shows the section for staff" + ); + + updateCurrentUser({ admin: false, moderator: false }); + + assert.notOk( + exists(".public-channels-section"), + "it doesn’t show the section for regular user" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js new file mode 100644 index 0000000000..4fd59c3334 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js @@ -0,0 +1,185 @@ +import { + acceptance, + exists, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + fillIn, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { test } from "qunit"; +import { + baseChatPretenders, + chatChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance("Discourse Chat - Composer", function (needs) { + needs.user({ id: 1, has_chat_enabled: true }); + needs.settings({ chat_enabled: true, enable_rich_text_paste: true }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + server.post("/chat/drafts", () => { + return helper.response([]); + }); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("when pasting html in composer", async function (assert) { + await visit("/chat/channel/11/another-category"); + + const clipboardEvent = new Event("paste", { bubbles: true }); + clipboardEvent.clipboardData = { + types: ["text/html"], + getData: (type) => { + if (type === "text/html") { + return "Foo"; + } + }, + }; + + document + .querySelector(".chat-composer-input") + .dispatchEvent(clipboardEvent); + + await settled(); + + assert.equal(document.querySelector(".chat-composer-input").value, "Foo"); + }); + + test("when selecting an emoji from the picker", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual( + emojiReactionStore.favorites, + this.siteSettings.default_emoji_reactions.split("|") + ); + + await visit("/chat/channel/11/-"); + await click(".chat-composer-dropdown__trigger-btn"); + await click(".chat-composer-dropdown__action-btn.emoji"); + await click(`[data-emoji="grinning"]`); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"].concat(this.siteSettings.default_emoji_reactions.split("|")), + "it tracks the emoji" + ); + }); + + test("when selecting an emoji from the autocomplete", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual( + emojiReactionStore.favorites, + this.siteSettings.default_emoji_reactions.split("|") + ); + + await visit("/chat/channel/11/-"); + await fillIn(".chat-composer-input", "test :grinni"); + await triggerKeyEvent(".chat-composer-input", "keyup", "ArrowDown"); // necessary to show the menu + await click(".autocomplete.ac-emoji ul li:first-child a"); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"].concat(this.siteSettings.default_emoji_reactions.split("|")), + "it tracks the emoji" + ); + }); +}); + +let sendAttempt = 0; +acceptance("Discourse Chat - Composer - unreliable network", function (needs) { + needs.user({ id: 1, has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => { + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.post("/chat/drafts", () => helper.response(500, {})); + server.post("/chat/:id.json", () => { + sendAttempt += 1; + return sendAttempt === 1 + ? helper.response(500, {}) + : helper.response({ success: true }); + }); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + needs.hooks.afterEach(function () { + sendAttempt = 0; + }); + + test("Sending a message with unreliable network", async function (assert) { + await visit("/chat/channel/11/-"); + await fillIn(".chat-composer-input", "network-error-message"); + await click(".send-btn"); + + assert.ok( + exists(".chat-message-container[data-id='1'] .retry-staged-message-btn"), + "it adds a retry button" + ); + + await fillIn(".chat-composer-input", "network-error-message"); + await click(".send-btn"); + await publishToMessageBus(`/chat/11`, { + type: "sent", + stagedId: 1, + chat_message: { + cooked: "network-error-message", + id: 175, + user: { id: 1 }, + }, + }); + + assert.notOk( + exists(".chat-message-container[data-id='1'] .retry-staged-message-btn"), + "it removes the staged message" + ); + assert.ok( + exists(".chat-message-container[data-id='175']"), + "it sends the message" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "it clears the input" + ); + }); + + test("Draft with unreliable network", async function (assert) { + await visit("/chat/channel/11/-"); + this.chatService.set("isNetworkUnreliable", true); + await settled(); + + assert.ok( + exists(".chat-composer__unreliable-network"), + "it displays a network error icon" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js b/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js new file mode 100644 index 0000000000..63cbd5bf33 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js @@ -0,0 +1,96 @@ +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; +import { click, triggerEvent, visit } from "@ember/test-helpers"; + +acceptance("Discourse Chat - Flagging test", function (needs) { + let defaultChatView; + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 100, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/9/messages.json", () => { + return helper.response( + generateChatView(loggedInUser(), { + can_flag: false, + }) + ); + }); + server.get("/chat/75/messages.json", () => { + defaultChatView = generateChatView(loggedInUser()); + return helper.response(defaultChatView); + }); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.put("/chat/flag", () => { + return helper.response({ success: true }); + }); + }); + needs.settings({ + chat_enabled: true, + }); + + test("Flagging in public channel works", async function (assert) { + await visit("/chat/channel/75/site"); + + assert.notOk(exists(".chat-live-pane .chat-message .chat-message-flagged")); + await triggerEvent(".chat-message-container", "mouseenter"); + + const moreButtons = selectKit( + ".chat-message-actions-container .more-buttons" + ); + await moreButtons.expand(); + + const content = moreButtons.displayedContent(); + assert.ok(content.find((row) => row.id === "flag")); + + await moreButtons.selectRowByValue("flag"); + + await click(".controls.spam input"); + await click(".modal-footer button"); + + await publishToMessageBus("/chat/75", { + type: "self_flagged", + chat_message_id: defaultChatView.chat_messages[0].id, + user_flag_status: 0, + }); + await publishToMessageBus("/chat/75", { + type: "flag", + chat_message_id: defaultChatView.chat_messages[0].id, + reviewable_id: 1, + }); + + const reviewableLink = query( + `.chat-message-container[data-id='${defaultChatView.chat_messages[0].id}'] .chat-message-info__flag a` + ); + assert.ok(reviewableLink.href.endsWith("/review/1")); + }); + + test("Flag button isn't present for DM channel", async function (assert) { + await visit("/chat/channel/9/@hawk"); + await triggerEvent(".chat-message-container", "mouseenter"); + + const moreButtons = selectKit(".chat-message-actions .more-buttons"); + await moreButtons.expand(); + + const content = moreButtons.displayedContent(); + assert.notOk(content.find((row) => row.id === "flag")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js b/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js new file mode 100644 index 0000000000..cde51fdd3b --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js @@ -0,0 +1,321 @@ +import showModal from "discourse/lib/show-modal"; +import { + acceptance, + exists, + loggedInUser, + query, + queryAll, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + currentURL, + fillIn, + focus, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { KEY_MODIFIER } from "discourse/plugins/chat/discourse/initializers/chat-keyboard-shortcuts"; +import { test } from "qunit"; + +const MODIFIER_OPTIONS = + KEY_MODIFIER === "meta" ? { metaKey: true } : { ctrlKey: true }; + +acceptance("Discourse Chat - Keyboard shortcuts", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.pretender((server, helper) => { + // allows to create a staged message + server.post("/chat/:id.json", () => + helper.response({ + errors: [""], + }) + ); + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.get("/chat/chat_channels/search", () => { + return helper.response({ + public_channels: [ChatChannel.create({ id: 3, title: "seventeen" })], + direct_message_channels: [ + ChatChannel.create({ + id: 4, + users: [{ id: 10, username: "someone" }], + }), + ], + users: [ + { id: 11, username: "smoothies" }, + { id: 12, username: "server" }, + ], + }); + }); + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("channel selector opens channel in float", async function (assert) { + await visit("/latest"); + + await showModal("chat-channel-selector-modal"); + await settled(); + assert.ok(exists("#chat-channel-selector-modal-inner")); + + // All channels should show because the input is blank + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 9 + ); + + // Freaking keyup event isn't triggered by fillIn... + // Next line manually keyup's "r" to make the keyup event run. + // fillIn is needed for `this.filter` but triggerKeyEvent is needed to fire the JS event. + await fillIn("#chat-channel-selector-input", "s"); + await triggerKeyEvent("#chat-channel-selector-input", "keyup", "R"); + + // Only 4 channels match this filter now! + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 4 + ); + + await triggerKeyEvent(document.body, "keyup", "Enter"); + assert.ok(exists(".topic-chat-container.visible")); + assert.notOk(exists("#chat-channel-selector-modal-inner")); + assert.equal(currentURL(), "/latest"); + }); + + test("the current chat channel does not show in the channel selector list", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await showModal("chat-channel-selector-modal"); + await settled(); + + // All channels minus 1 + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 8 + ); + assert.notOk( + exists( + "#chat-channel-selector-modal-inner .chat-channel-selection-row.chat-channel-9" + ) + ); + }); + + test("switching channel with alt+arrow keys in full page chat", async function (assert) { + this.container.lookup("service:chat").set("chatWindowFullPage", true); + await visit("/chat/channel/75/@hawk"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/76/eviltrout-markvanlan"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/11/another-category"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/7/bug"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/11/another-category"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/76/eviltrout-markvanlan"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/75/hawk"); + }); + + test("switching channel with alt+arrow keys in float", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + + await click(".header-dropdown-toggle.open-chat"); + await click("#chat-channel-row-4"); + + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-4")); + + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + + assert.ok(query(".topic-chat-container").classList.contains("channel-10")); + + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.ok(query(".topic-chat-container").classList.contains("channel-4")); + }); + + test("simple composer formatting shortcuts", async function (assert) { + this.chatService.set("sidebarActive", false); + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "B", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "**test text**", + "selection should get the bold markdown" + ); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "I", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "_test text_", + "selection should get the italic markdown" + ); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "E", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "`test text`", + "selection should get the code markdown" + ); + }); + + test("editing last non staged message", async function (assert) { + const stagedMessageText = "This is a test"; + await visit("/latest"); + + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + await fillIn(".chat-composer-input", stagedMessageText); + await click(".chat-composer-inline-button"); + await triggerKeyEvent(".chat-composer-input", "keydown", "ArrowUp"); + + assert.notEqual( + query(".chat-composer-input").value.trim(), + stagedMessageText + ); + }); + + test("insert link shortcut", async function (assert) { + await visit("/latest"); + + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + + await focus(".chat-composer-input"); + await fillIn(".chat-composer-input", "This is a link to "); + await triggerKeyEvent( + ".chat-composer-input", + "keydown", + "L", + MODIFIER_OPTIONS + ); + assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible"); + + await fillIn(".modal-body .link-url", "google.com"); + await fillIn(".modal-body .link-text", "Google"); + await click(".modal-footer button.btn-primary"); + + assert.strictEqual( + query(".chat-composer-input").value, + "This is a link to [Google](https://google.com)", + "adds link with url and text, prepends 'https://'" + ); + + assert.ok( + !exists(".insert-link.modal-body"), + "modal dismissed after submitting link" + ); + }); + + test("Dash key (-) opens chat float", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + + await triggerKeyEvent(document.body, "keydown", "-"); + assert.ok(exists(".topic-chat-drawer-content"), "chat float is open"); + }); + + test("Pressing Escape when drawer is opened", async function (assert) { + this.chatService.set("sidebarActive", false); + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + + const composerInput = query(".chat-composer-input"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Escape"); + + assert.ok( + exists(".topic-chat-float-container.hidden"), + "it closes the drawer" + ); + }); + + test("Pressing Escape when full page is opened", async function (assert) { + this.chatService.set("sidebarActive", false); + await visit("/chat/channel/75/@hawk"); + const composerInput = query(".chat-composer-input"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Escape"); + + assert.equal( + currentURL(), + "/chat/channel/75/hawk", + "it doesn’t close full page chat" + ); + + assert.ok( + exists(".chat-message-container[data-id='177']"), + "it doesn’t remove channel content" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js new file mode 100644 index 0000000000..ad95b49b57 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js @@ -0,0 +1,156 @@ +import { click, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane collapse", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_chat: true, + user_silenced: false, + }, + chat_messages: [ + { + id: 1, + message: "https://www.youtube.com/watch?v=aOWkVdU4NH0", + cooked: + '', + excerpt: + '[Picnic with my cat (shaved ice & lemonade…', + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + { + id: 2, + message: "", + cooked: "", + excerpt: "", + uploads: [ + { + id: 4, + url: "/images/avatar.png", + original_filename: "tomtom.jpeg", + filesize: 93815, + width: 480, + height: 640, + thumbnail_width: 375, + thumbnail_height: 500, + extension: "jpeg", + retain_hours: null, + human_filesize: "91.6 KB", + }, + ], + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/uploads/lookup-urls", () => + helper.response([ + 200, + { "Content-Type": "application/json" }, + [ + { + url: "/images/avatar.png", + }, + ], + ]) + ); + }); + + test("can collapse and expand youtube chat", async function (assert) { + const youtubeContainer = ".chat-message-container[data-id='1'] .lazyYT"; + const expandImage = + ".chat-message-container[data-id='1'] .chat-message-collapser-closed"; + const collapseImage = + ".chat-message-container[data-id='1'] .chat-message-collapser-opened"; + + await visit("/chat/channel/1/cat"); + + assert.ok(visible(youtubeContainer)); + assert.ok(visible(collapseImage), "the open arrow is shown"); + assert.notOk(exists(expandImage), "the close arrow is hidden"); + + await click(collapseImage); + + assert.notOk(visible(youtubeContainer)); + assert.ok(visible(expandImage), "the close arrow is shown"); + assert.notOk(exists(collapseImage), "the open arrow is hidden"); + + await click(expandImage); + + assert.ok(visible(youtubeContainer)); + assert.ok(visible(collapseImage), "the open arrow is shown again"); + assert.notOk(exists(expandImage), "the close arrow is hidden again"); + }); + + test("lightbox shows up before and after expand and collapse", async function (assert) { + const lightboxImage = ".mfp-img"; + const image = ".chat-message-container[data-id='2'] .chat-img-upload"; + const expandImage = + ".chat-message-container[data-id='2'] .chat-message-collapser-closed"; + const collapseImage = + ".chat-message-container[data-id='2'] .chat-message-collapser-opened"; + + await visit("/chat/channel/1/cat"); + + await click(image); + + assert.ok( + exists(document.querySelector(lightboxImage)), + "can see lightbox" + ); + await click(document.querySelector(".mfp-container")); + + await click(collapseImage); + await click(expandImage); + + await click(image); + assert.ok( + exists(document.querySelector(lightboxImage)), + "can see lightbox after collapse expand" + ); + await click(document.querySelector(".mfp-container")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js new file mode 100644 index 0000000000..a7fb328d45 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js @@ -0,0 +1,86 @@ +import { click, visit } from "@ember/test-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane mobile", function (needs) { + needs.mobileView(); + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_flag: true, + user_silenced: true, + }, + chat_messages: [ + { + id: 1, + message: "hi", + cooked: "

    hi

    ", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + { + id: 2, + message: "hi", + cooked: "

    hi

    ", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + }); + + test("touching message", async function (assert) { + await visit("/chat/channel/1/cat"); + + const messageExists = (id) => { + return exists( + `.chat-message-container[data-id='${id}'] .chat-message-selected` + ); + }; + + assert.notOk(messageExists(1)); + assert.notOk(messageExists(2)); + + await click(".chat-message-container[data-id='1']"); + + assert.notOk(messageExists(1), "it doesn’t select the touched message"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js new file mode 100644 index 0000000000..1316017f3d --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js @@ -0,0 +1,81 @@ +import { visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_flag: true, + user_silenced: true, + }, + chat_messages: [ + { + id: 1, + message: "hi", + cooked: "

    hi

    ", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + reactions: { + heart: { + count: 1, + reacted: false, + users: [{ id: 99, username: "im-penar" }], + }, + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ + id: 1, + title: "something", + current_user_membership: { following: true }, + }) + ); + }); + + test("Textarea and message interactions are disabled when user is silenced", async function (assert) { + await visit("/chat/channel/1/cat"); + assert.equal(query(".chat-composer-input").disabled, true); + assert.notOk(exists(".chat-message-actions-container")); + assert.notOk(exists(".chat-message-react-btn")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js new file mode 100644 index 0000000000..c348161d78 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js @@ -0,0 +1,315 @@ +import { click, fillIn, tap, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { generateChatView } from "discourse/plugins/chat/chat-fixtures"; + +function buildMessage(messageId) { + return { + id: messageId, + message: "hi", + cooked: "

    hi

    ", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }; +} + +acceptance( + "Discourse Chat - Chat live pane - viewing old messages", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + + let loadAllMessages = false; + + needs.hooks.beforeEach(() => { + loadAllMessages = false; + }); + + needs.pretender((server, helper) => { + const firstPageMessages = []; + + for (let i = 0; i < 50; i++) { + firstPageMessages.push(buildMessage(i + 1)); + } + + server.get("/chat/:chatChannelId/messages.json", () => { + if (loadAllMessages) { + const updatedPage = [...firstPageMessages]; + updatedPage.shift(); + updatedPage.shift(); + updatedPage.push(buildMessage(51)); + updatedPage.push(buildMessage(52)); + + return helper.response({ + meta: { + can_load_more_future: false, + }, + chat_messages: updatedPage, + }); + } else { + return helper.response({ + meta: { + can_flag: true, + user_silenced: false, + can_load_more_future: true, + }, + chat_messages: firstPageMessages, + }); + } + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + test("doesn't create a gap in history by adding new messages", async function (assert) { + await visit("/chat/channel/1/cat"); + + await publishToMessageBus("/chat/1", { + type: "sent", + chat_message: { + id: 51, + cooked: "

    hello!

    ", + user: { + id: 2, + }, + }, + }); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + }); + + test("It continues to handle other message types", async function (assert) { + await visit("/chat/channel/1/cat"); + + await publishToMessageBus("/chat/1", { + action: "add", + user: { id: 77, username: "notTomtom" }, + emoji: "cat", + type: "reaction", + chat_message_id: 1, + }); + + assert.ok(exists(`.chat-message-reaction[data-emoji-name="cat"]`)); + }); + + test("Sending a new message when there are still unloaded ones will fetch them", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + + loadAllMessages = true; + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "test text"); + await click(".send-btn"); + + assert.ok(exists(`.chat-message-container[data-id='${51}']`)); + assert.ok(exists(`.chat-message-container[data-id='${52}']`)); + }); + + test("Clicking the arrow button jumps to the bottom of the channel", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + const scrollerEl = document.querySelector(".chat-messages-scroll"); + scrollerEl.scrollTop = -500; // Scroll up a bit + const initialPosition = scrollerEl.scrollTop; + await triggerEvent(".chat-messages-scroll", "scroll", { + forceShowScrollToBottom: true, + }); + + loadAllMessages = true; + await click(".chat-scroll-to-bottom"); + + assert.ok(exists(`.chat-message-container[data-id='${51}']`)); + assert.ok(exists(`.chat-message-container[data-id='${52}']`)); + + assert.ok( + scrollerEl.scrollTop > initialPosition, + "Scrolled to the bottom" + ); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane - handling 429 errors", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => { + return helper.response(429); + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + test("Handles 429 errors by displaying an alert", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.ok(exists(".dialog-content"), "We displayed a 429 error"); + await click(".dialog-footer .btn-primary"); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane - handling 404 errors", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + has_chat_enabled: true, + }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => { + return helper.response(404); + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.get("/chat/lookup/:messageId.json", () => { + return helper.response(404); + }); + }); + + test("Handles 404 errors by displaying an alert", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.ok(exists(".dialog-content"), "it displays a 404 error"); + await click(".dialog-footer .btn-primary"); + }); + + test("Handles 404 errors with unexisting messageId", async function (assert) { + await visit("/chat/channel/1/cat?messageId=2"); + + assert.ok(exists(".dialog-content"), "it displays a 404 error"); + await click(".dialog-footer .btn-primary"); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane (mobile) - actions menu", + function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ chat_enabled: true }); + + needs.mobileView(); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + }); + + test("when expanding and collapsing the actions menu", async function (assert) { + await visit("/chat/channel/1/cat"); + const message = query(".chat-message-container"); + await tap(message); + + assert.ok(exists(".chat-message-actions-backdrop")); + + await tap(".collapse-area"); + + assert.notOk(exists(".chat-message-actions-backdrop")); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js b/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js new file mode 100644 index 0000000000..eb4a251091 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js @@ -0,0 +1,165 @@ +import { test } from "qunit"; +import { click, fillIn, tap, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); +} + +acceptance("Discourse Chat | bookmarking | desktop", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.post("/bookmarks", () => helper.response({ id: 1, success: "OK" })); + }); + + test("can bookmark a message with reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await triggerEvent(message, "mouseenter"); + await click(".chat-message-actions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_next_month"); + assert.ok( + message.querySelector( + ".chat-message-info__bookmark .d-icon-discourse-bookmark-clock" + ), + "the message should be bookmarked and show the icon on the message info" + ); + assert.ok( + ".chat-message-actions .bookmark-btn .d-icon-discourse-bookmark-clock", + "the message actions icon shows the reminder icon" + ); + }); + + test("can bookmark a message without reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await triggerEvent(message, "mouseenter"); + await click(".chat-message-actions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_none"); + assert.ok( + exists(".chat-message-info__bookmark .d-icon-bookmark"), + "the message should be bookmarked and show the icon on the message info" + ); + assert.ok( + exists(".chat-message-actions .bookmark-btn .d-icon-bookmark"), + "the message actions icon shows the bookmark icon" + ); + }); +}); + +acceptance("Discourse Chat | bookmarking | mobile", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.post("/bookmarks", () => helper.response({ id: 1, success: "OK" })); + }); + + needs.mobileView(); + + test("can bookmark a message with reminder from the mobile long press menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await tap(message); + await click(".main-actions .bookmark-btn"); + + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_next_month"); + assert.ok( + message.querySelector( + ".chat-message-info__bookmark .d-icon-discourse-bookmark-clock" + ), + "the message should be bookmarked and show the icon on the message info" + ); + + await tap(message); + assert.ok( + exists(".main-actions .bookmark-btn .d-icon-discourse-bookmark-clock"), + "the message actions icon shows the reminder icon" + ); + }); + + test("can bookmark a message without reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await tap(message); + await click(".main-actions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_none"); + assert.ok( + message.querySelector(".chat-message-info__bookmark .d-icon-bookmark"), + "the message should be bookmarked and show the icon on the message info" + ); + + await tap(message); + assert.ok( + exists(".main-actions .bookmark-btn .d-icon-bookmark"), + "the message actions icon shows the bookmark icon" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-message-test.js b/plugins/chat/test/javascripts/acceptance/chat-message-test.js new file mode 100644 index 0000000000..776fd607f9 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-message-test.js @@ -0,0 +1,99 @@ +import { + acceptance, + loggedInUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, triggerEvent, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + server.put("/chat/:id/react/:message_id.json", helper.response); +} + +acceptance("Discourse Chat - Chat Message", function (needs) { + needs.user({ has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => setupPretenders(server, helper)); + + test("when reacting to a message using inline reaction", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual( + emojiReactionStore.favorites, + this.siteSettings.default_emoji_reactions.split("|") + ); + + await visit("/chat/channel/4/public-category"); + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="heart"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["heart"].concat( + this.siteSettings.default_emoji_reactions + .split("|") + .filter((r) => r !== "heart") + ), + "it tracks the emoji" + ); + + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="heart"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["heart"].concat( + this.siteSettings.default_emoji_reactions + .split("|") + .filter((r) => r !== "heart") + ), + "it doesn’t untrack when removing the reaction" + ); + }); + + test("when reacting to a message using emoji picker reaction", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual( + emojiReactionStore.favorites, + this.siteSettings.default_emoji_reactions.split("|") + ); + + await visit("/chat/channel/4/public-category"); + await triggerEvent(".chat-message-container[data-id='176']", "mouseenter"); + await click(".chat-message-actions-container .react-btn"); + await click(`[data-emoji="grinning"]`); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"].concat(this.siteSettings.default_emoji_reactions.split("|")), + "it tracks the emoji" + ); + + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="grinning"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"].concat(this.siteSettings.default_emoji_reactions.split("|")), + "it doesn’t untrack when removing the reaction" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js b/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js new file mode 100644 index 0000000000..1d0db0ad4f --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js @@ -0,0 +1,183 @@ +import { test } from "qunit"; +import { click, currentURL, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.put("/chat/4/move_messages_to_channel.json", () => { + return helper.response({ + destination_channel_id: 11, + destination_channel_title: "Coolest thing you have seen today", + first_moved_message_id: 174, + }); + }); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(loggedInUser())) + ); +} + +acceptance( + "Discourse Chat | moving messages to a channel | staff user", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + test("opens a modal for destination channel selection then redirects to the moved messages when done", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + const moveToChannelBtn = query( + ".chat-live-pane #chat-move-to-channel-btn" + ); + assert.equal( + moveToChannelBtn.disabled, + false, + "button is enabled as a message is selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + assert.equal( + moveToChannelBtn.disabled, + true, + "button is disabled when no messages are selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + await click("#chat-move-to-channel-btn"); + const modalConfirmMoveButton = query( + "#chat-confirm-move-messages-to-channel" + ); + assert.ok( + modalConfirmMoveButton.disabled, + "cannot confirm move until channel is selected" + ); + const channelChooser = selectKit(".chat-move-message-channel-chooser"); + await channelChooser.expand(); + assert.notOk( + channelChooser.rowByValue("4").exists(), + "the source channel is not in the destination channel selector" + ); + + await channelChooser.selectRowByValue("11"); + await click(modalConfirmMoveButton); + + assert.strictEqual( + currentURL(), + "/chat/channel/11/another-category", + "it goes to the destination channel after the move" + ); + }); + + test("does not allow moving messages from a direct message channel", async function (assert) { + await visit("/chat/channel/75/@hawk"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.notOk( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "the move to channel button is not shown in direct message channels" + ); + }); + } +); + +acceptance( + "Discourse Chat | moving messages to a channel | non-staff user", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.get("/chat/11/messages.json", () => { + return helper.response( + generateChatView(loggedInUser(), { can_moderate: true }) + ); + }); + }); + + test("non-staff users cannot see the move to channel button", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.notOk( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "non-staff users cannot see the move to channel button" + ); + }); + + test("non-staff users can see the move to channel button if they can_moderate the channel", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-message-actions-container[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.ok( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "non-staff users can see the move to channel button if can_moderate" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js b/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js new file mode 100644 index 0000000000..1a9fe0c342 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js @@ -0,0 +1,183 @@ +import { skip, test } from "qunit"; +import { + click, + currentURL, + tap, + triggerEvent, + visit, +} from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +const quoteResponse = { + markdown: `[chat quote="martin-chat;3875498;2022-02-04T01:12:15Z" channel="The Beam Discussions" channelId="1234"] + an extremely insightful response :) + [/chat]`, +}; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.post(`/chat/4/quote.json`, () => helper.response(quoteResponse)); + server.post(`/chat/7/quote.json`, () => helper.response(quoteResponse)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); +} + +acceptance("Discourse Chat | Copying messages", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + test("it copies the quote and shows a message", async function (assert) { + await visit("/chat/channel/7/Bug"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-message-actions-container[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + const copyButton = query(".chat-live-pane #chat-copy-btn"); + assert.equal( + copyButton.disabled, + false, + "button is enabled as a message is selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + assert.equal( + copyButton.disabled, + true, + "button is disabled when no messages are selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + await click("#chat-copy-btn"); + assert.ok(exists(".chat-selection-message"), "shows the message"); + }); +}); + +acceptance("Discourse Chat | Quoting in composer", async function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + skip("it opens the composer for the topic and pastes in the quote", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click(".header-dropdown-toggle.open-chat"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-message-container .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + await click("#chat-quote-btn"); + assert.ok(exists("#reply-control.composer-action-reply")); + assert.strictEqual( + query(".composer-action-title .action-title").innerText, + "Internationalization / localization" + ); + assert.strictEqual( + query("textarea.d-editor-input").value, + quoteResponse.markdown + ); + }); +}); + +acceptance("Discourse Chat | Quoting on mobile", async function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + needs.mobileView(); + + skip("it opens the chatable, opens the composer, and pastes the markdown in", async function (assert) { + await visit("/chat/channel/7/Bug"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await tap(firstMessage); + await click(".chat-message-action-item[data-id='selectMessage'] button"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + await click("#chat-quote-btn"); + + assert.equal(currentURL(), "/c/bug/1", "navigates to the chatable url"); + assert.ok( + exists("#reply-control.composer-action-createTopic"), + "the composer opens" + ); + assert.strictEqual( + query("textarea.d-editor-input").value, + quoteResponse.markdown, + "the composer has the markdown" + ); + assert.strictEqual( + selectKit(".category-chooser").header().value(), + "1", + "it fills category selector with the right category" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js b/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js new file mode 100644 index 0000000000..0cb4d62fdc --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js @@ -0,0 +1,117 @@ +import { + acceptance, + exists, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; + +acceptance("Discourse Chat - Sidebar - User Status", function (needs) { + const directMessageUserId = 1; + const status = { description: "off to dentist", emoji: "tooth" }; + + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + const directMessageChannel = { + chatable: { + users: [ + { + id: directMessageUserId, + username: "user1", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + status, + }, + ], + }, + chatable_type: "DirectMessage", + title: "@user1", + }; + + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [directMessageChannel], + }); + }); + }); + + test("Shows user status", async function (assert) { + await visit("/"); + + const statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "status is shown"); + assert.ok( + statusEmoji.src.includes(status.emoji), + "status emoji is correct" + ); + assert.equal( + statusEmoji.title, + status.description, + "status description is correct" + ); + }); + + test("Status gets updated after receiving a message bus update", async function (assert) { + await visit("/"); + + let statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "old status is shown"); + assert.ok( + statusEmoji.src.includes(status.emoji), + "old status emoji is correct" + ); + assert.equal( + statusEmoji.title, + status.description, + "old status description is correct" + ); + + const newStatus = { description: "surfing", emoji: "surfer" }; + await publishToMessageBus(`/user-status`, { + [directMessageUserId]: newStatus, + }); + + statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "new status is shown"); + assert.ok( + statusEmoji.src.includes(newStatus.emoji), + "new status emoji is correct" + ); + assert.equal( + statusEmoji.title, + newStatus.description, + "new status description is correct" + ); + }); + + test("Status disappears after receiving a message bus update", async function (assert) { + await visit("/"); + + let statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "old status is shown"); + + await publishToMessageBus(`/user-status`, { [directMessageUserId]: null }); + + assert.notOk( + exists(".sidebar-sections .sidebar-section-link-content-text .emoji"), + "status has disappeared" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-status-test.js b/plugins/chat/test/javascripts/acceptance/chat-status-test.js new file mode 100644 index 0000000000..3b3e13274f --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-status-test.js @@ -0,0 +1,106 @@ +import { visit } from "@ember/test-helpers"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { test } from "qunit"; + +const baseChatPretenders = (server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.get("/chat/chat_channels.json", () => { + let copy = cloneJSON(chatChannels); + let modifiedChannel = copy.public_channels.find((pc) => pc.id === 4); + modifiedChannel.current_user_membership.unread_count = 2; + return helper.response(copy); + }); + + // this is only fetched on channel-status change; when expanding on + // this test we may want to introduce some counter to track when + // this is fetched if we want to return different statuses + server.get("/chat/chat_channels/4", () => { + let channel = cloneJSON( + chatChannels.public_channels.find((pc) => pc.id === 4) + ); + channel.status = "archived"; + return helper.response(channel); + }); +}; + +acceptance( + "Discourse Chat - Respond to /chat/channel-status archive message", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "tomtom", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + chat_allow_archiving_channels: true, + }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + }); + + test("it clears any unread messages in the sidebar for the archived channel", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok( + exists("#chat-channel-row-4 .chat-channel-unread-indicator"), + "unread indicator shows for channel" + ); + + await publishToMessageBus("/chat/channel-status", { + chat_channel_id: 4, + status: "archived", + }); + assert.notOk( + exists("#chat-channel-row-4 .chat-channel-unread-indicator"), + "unread indicator should not show after archive status change" + ); + }); + + test("it changes the channel status in the header to archived", async function (assert) { + await visit("/chat/channel/4/Topic"); + + assert.notOk( + exists(".chat-channel-title-with-status .chat-channel-status"), + "channel status does not show if the channel is open" + ); + + await publishToMessageBus("/chat/channel-status", { + chat_channel_id: 4, + status: "archived", + }); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.archived_header"), + "channel status changes to archived" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-test.js b/plugins/chat/test/javascripts/acceptance/chat-test.js new file mode 100644 index 0000000000..d1f6e5346f --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-test.js @@ -0,0 +1,1849 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, + queryAll, + updateCurrentUser, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + currentURL, + fillIn, + focus, + settled, + triggerEvent, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { skip, test } from "qunit"; +import { + chatChannels, + messageContents, +} from "discourse/plugins/chat/chat-fixtures"; +import Session from "discourse/models/session"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + joinChannel, + leaveChannel, + presentUserIds, +} from "discourse/tests/helpers/presence-pretender"; +import User from "discourse/models/user"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import sinon from "sinon"; +import * as ajaxModule from "discourse/lib/ajax"; +import I18n from "I18n"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import fabricators from "../helpers/fabricators"; +import { + baseChatPretenders, + chatChannelPretender, + directMessageChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance("Discourse Chat - anonymouse 🐭 user", function (needs) { + needs.settings({ + chat_enabled: true, + }); + + test("doesn't error for anonymous users", async function (assert) { + await visit(""); + assert.ok(true, "no errors on homepage"); + }); +}); + +acceptance("Discourse Chat - without unread", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + const hawkAsJson = { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: "/letter_avatar_proxy/v4/letter/t/41988e/{size}.png", + }; + server.get("/u/search/users", () => { + return helper.response({ + users: [hawkAsJson], + }); + }); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + + server.put("/chat/:chat_channel_id/react/:messageId.json", helper.response); + + server.put("/chat/:chat_channel_id/invite", helper.response); + server.post("/chat/direct_messages/create.json", () => { + return helper.response({ + chat_channel: { + chat_channels: [], + chatable: { users: [hawkAsJson] }, + chatable_id: 16, + chatable_type: "DirectMessage", + chatable_url: null, + id: 75, + title: "@hawk", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }, + }); + }); + server.post("/chat/chat_channels/:chatChannelId/unfollow.json", () => { + return helper.response({ current_user_membership: { following: false } }); + }); + server.get("/chat/direct_messages.json", () => { + return helper.response({ + chat_channel: { + id: 75, + title: "hawk", + chatable_type: "DirectMessage", + last_message_sent_at: "2021-07-20T08:14:16.950Z", + chatable: { + users: [{ username: "hawk" }], + }, + }, + }); + }); + server.get("/u/hawk/card.json", () => { + return helper.response({}); + }); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "appEvents", { + get: () => this.container.lookup("service:appEvents"), + }); + Session.current().highlightJsPath = + "/assets/highlightjs/highlight-test-bundle.min.js"; + }); + + // TODO: needs a future change to how we handle URLS to be possible + skip("Clicking mention notification from outside chat opens the float", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".header-dropdown-toggle.current-user"); + await click("#quick-access-notifications .chat-mention"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-9")); + }); + + test("notifications for current user and here/all are highlighted", async function (assert) { + updateCurrentUser({ username: "osama" }); + await visit("/chat/channel/11/another-category"); + // 177 is message id from fixture + const highlighted = []; + const notHighlighted = []; + query(".chat-message-container[data-id='177']") + .querySelectorAll(".mention.highlighted") + .forEach((node) => { + highlighted.push(node.textContent.trim()); + }); + query(".chat-message-container[data-id='177']") + .querySelectorAll(".mention:not(.highlighted)") + .forEach((node) => { + notHighlighted.push(node.textContent.trim()); + }); + assert.equal(highlighted.length, 2, "2 mentions are highlighted"); + assert.equal(notHighlighted.length, 1, "1 mention is regular mention"); + assert.ok(highlighted.includes("@here"), "@here mention is highlighted"); + assert.ok(highlighted.includes("@osama"), "@osama mention is highlighted"); + assert.ok( + notHighlighted.includes("@mark"), + "@mark mention is not highlighted" + ); + }); + + test("Chat messages are populated when a channel is entered and images are rendered", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message .chat-message-text"); + assert.equal(messages[0].innerText.trim(), messageContents[0]); + + assert.ok(messages[1].querySelector("a.chat-other-upload")); + + assert.equal( + messages[2].innerText.trim().split("\n")[0], + messageContents[2] + ); + assert.ok(messages[2].querySelector("img.chat-img-upload")); + }); + + test("Reply-to line is hidden when reply-to message is directly above", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + assert.notOk(messages[1].querySelector(".chat-reply__excerpt")); + }); + + test("Reply-to line is present when reply-to message is not directly above", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + const replyTo = messages[2].querySelector(".chat-reply__excerpt"); + assert.ok(replyTo); + assert.equal(replyTo.innerText.trim(), messageContents[0]); + }); + + test("Unfollowing a direct message channel transitions to another channel", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await click( + ".chat-channel-row.chat-channel-75 .toggle-channel-membership-button.-leave" + ); + + assert.ok(/^\/chat\/channel\/4/.test(currentURL())); + }); + + test("Admin only controls are present", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const currentUserDropdown = selectKit( + ".chat-message-actions-container[data-id='174'] .more-buttons" + ); + await currentUserDropdown.expand(); + + assert.notOk( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it doesn’t show the rebake button for non staff" + ); + + updateCurrentUser({ admin: true, moderator: true }); + await visit("/chat"); + + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await currentUserDropdown.expand(); + + assert.ok( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it shows the rebake button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("silence").exists(), + "it hides the silence button" + ); + + const notCurrentUserDropdown = selectKit( + ".chat-message-actions-container[data-id='175'] .more-buttons" + ); + await triggerEvent(".chat-message-container[data-id='175']", "mouseenter"); + await notCurrentUserDropdown.expand(); + assert.ok( + notCurrentUserDropdown.rowByValue("silence").exists(), + "it shows the silence button" + ); + }); + + test("Message controls are present and correct for permissions", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + // User created this message + assert.ok( + ".chat-message-actions-container[data-id='174'] .reply-btn", + "it shows the reply button" + ); + + const currentUserDropdown = selectKit( + ".chat-message-actions-container[data-id='174'] .more-buttons" + ); + await currentUserDropdown.expand(); + + assert.ok( + currentUserDropdown.rowByValue("copyLinkToMessage").exists(), + "it shows the link to button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it doesn’t show the rebake button to a regular user" + ); + + assert.ok( + currentUserDropdown.rowByValue("edit").exists(), + "it shows the edit button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("flag").exists(), + "it hides the flag button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("silence").exists(), + "it hides the silence button" + ); + + assert.ok( + currentUserDropdown.rowByValue("deleteMessage").exists(), + "it shows the delete button" + ); + + // User _didn't_ create this message + await triggerEvent(".chat-message-container[data-id='175']", "mouseenter"); + assert.ok( + ".chat-message-actions-container[data-id='175'] .reply-btn", + "it shows the reply button" + ); + const notCurrentUserDropdown = selectKit( + ".chat-message-actions-container[data-id='175'] .more-buttons" + ); + await notCurrentUserDropdown.expand(); + + assert.ok( + notCurrentUserDropdown.rowByValue("copyLinkToMessage").exists(), + "it shows the link to button" + ); + + assert.notOk( + notCurrentUserDropdown.rowByValue("edit").exists(), + "it hides the edit button" + ); + + assert.notOk( + notCurrentUserDropdown.rowByValue("deleteMessage").exists(), + "it hides the delete button" + ); + }); + + test("pressing the reply button adds the indicator to the composer", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".reply-btn"); + assert.ok( + exists(".chat-composer-message-details .d-icon-reply"), + "Reply icon is present" + ); + assert.equal( + query( + ".chat-composer-message-details .chat-reply__username" + ).innerText.trim(), + "markvanlan" + ); + }); + + test("pressing the edit button fills the composer and indicates edit", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const dropdown = selectKit(".more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("edit"); + + assert.ok( + exists(".chat-composer-message-details .d-icon-pencil-alt"), + "Edit icon is present" + ); + assert.equal( + query( + ".chat-composer-message-details .chat-reply__username" + ).innerText.trim(), + "markvanlan" + ); + + assert.equal( + query(".chat-composer-input").value.trim(), + messageContents[0] + ); + }); + + test("Reply-to is stored in draft", async function (assert) { + this.chatService.set("sidebarActive", false); + + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + await settled(); + + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-9"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".chat-message-actions-container[data-id='174'] .reply-btn"); + // Reply-to line is present + assert.ok(exists(".chat-composer-message-details .chat-reply")); + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-11"); + // Reply-to line is gone since switching channels + assert.notOk(exists(".chat-composer-message-details .chat-reply")); + // Now click on reply btn and cancel it on channel 7 + + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".chat-message-actions-container[data-id='174'] .reply-btn"); + await click(".cancel-message-action"); + + // Go back to channel 9 and check that reply-to is present + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-9"); + // Now reply-to should be back and loaded from draft + assert.ok(exists(".chat-composer-message-details .chat-reply")); + + // Go back one for time to channel 7 and make sure reply-to is gone + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-11"); + assert.notOk(exists(".chat-composer-message-details .chat-reply")); + }); + + test("Sending a message", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messageContent = "Here's a message"; + const composerInput = query(".chat-composer-input"); + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [], + "is not present before typing" + ); + await fillIn(composerInput, messageContent); + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [User.current().id], + "is present after typing" + ); + await focus(composerInput); + + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + assert.equal(document.activeElement, composerInput); + + assert.equal(composerInput.innerText.trim(), "", "composer input cleared"); + + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [], + "stops being present after sending message" + ); + + let messages = queryAll(".chat-message"); + let lastMessage = messages[messages.length - 1]; + + // Message is staged, without an ID + assert.ok(lastMessage.classList.contains("chat-message-staged")); + + // Last message was from a different user; full meta data is shown + assert.ok( + lastMessage.querySelector(".chat-user-avatar"), + "Avatar is present" + ); + assert.ok( + lastMessage.querySelector(".chat-message-info__username__name"), + "Username is present" + ); + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + this.siteSettings.enable_markdown_typographer + ? "Here’s a message" + : messageContent + ); + + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + user: { + id: 1, + }, + cooked: messageContent + " some extra cooked stuff", + }, + }); + + assert.equal( + lastMessage.closest(".chat-message-container").dataset.id, + 202 + ); + assert.notOk(lastMessage.classList.contains("chat-message-staged")); + + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + messageContent + " some extra cooked stuff", + "last message is updated with the cooked content of the message" + ); + + const nextMessageContent = "What up what up!"; + await fillIn(composerInput, nextMessageContent); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + messages = queryAll(".chat-message"); + lastMessage = messages[messages.length - 1]; + + // We just sent a message so avatar/username will not be present for the last message + assert.notOk( + lastMessage.querySelector(".chat-user-avatar"), + "Avatar is not shown" + ); + assert.notOk( + lastMessage.querySelector(".full-name"), + "Username is not shown" + ); + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + nextMessageContent + ); + }); + + test("cooked processing messages are handled properly", async function (assert) { + await visit("/chat/channel/11/another-category"); + + const cooked = "

    hello there

    "; + await publishToMessageBus(`/chat/11`, { + type: "processed", + chat_message: { + cooked, + id: 175, + }, + }); + + assert.ok( + query( + ".chat-message-container[data-id='175'] .chat-message-text" + ).innerHTML.includes(cooked) + ); + }); + + test("Code highlighting in a message", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messageContent = `Here's a message with code highlighting + +\`\`\`ruby +Widget.triangulate(arg: "test") +\`\`\``; + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, messageContent); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + cooked: `
    Widget.triangulate(arg: "test")
    +      
    `, + user: { + id: 1, + }, + }, + }); + + const messages = queryAll(".chat-message"); + const lastMessage = messages[messages.length - 1]; + assert.equal( + lastMessage.closest(".chat-message-container").dataset.id, + 202 + ); + assert.ok( + exists( + ".chat-message-container[data-id='202'] .chat-message-text code.lang-ruby.hljs" + ), + "chat message code block has been highlighted as ruby code" + ); + }); + + test("Drafts are saved and reloaded", async function (assert) { + await visit("/chat/channel/11/another-category"); + await fillIn(".chat-composer-input", "Hi people"); + + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), ""); + await fillIn(".chat-composer-input", "What up what up"); + + await visit("/chat/channel/11/another-category"); + assert.equal(query(".chat-composer-input").value.trim(), "Hi people"); + await fillIn(".chat-composer-input", ""); + + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), "What up what up"); + + // Send a message + const composerTextarea = query(".chat-composer-input"); + await focus(composerTextarea); + await triggerKeyEvent(composerTextarea, "keydown", "Enter"); + + assert.equal(query(".chat-composer-input").value.trim(), ""); + + // Navigate away and back to make sure input didn't re-fill + await visit("/chat/channel/11/another-category"); + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), ""); + }); + + test("Pressing escape cancels editing", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const dropdown = selectKit(".more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("edit"); + + assert.ok(exists(".chat-composer-message-details")); + await triggerKeyEvent(".chat-composer", "keydown", "Escape"); + + // chat-composer-message-details will be gone as no message is being edited + assert.notOk(exists(".chat-composer .chat-composer-message-details")); + }); + + test("Unread indicator increments for public channels when messages come in", async function (assert) { + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists(".header-dropdown-toggle.open-chat .chat-channel-unread-indicator") + ); + + await publishToMessageBus("/chat/9/new-messages", { + message_id: 201, + user_id: 2, + }); + + assert.ok( + exists(".header-dropdown-toggle.open-chat .chat-channel-unread-indicator") + ); + }); + + test("Unread count increments for direct message channels when messages come in", async function (assert) { + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + + await publishToMessageBus("/chat/75/new-messages", { + message_id: 201, + user_id: 2, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.equal( + query( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ).innerText.trim(), + 1 + ); + }); + + test("Unread DM count overrides the public unread indicator", async function (assert) { + await visit("/t/internationalization-localization/280"); + await publishToMessageBus("/chat/9/new-messages", { + message_id: 201, + user_id: 2, + }); + await publishToMessageBus("/chat/75/new-messages", { + message_id: 202, + user_id: 2, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator:not(.urgent)" + ) + ); + }); + + test("Mentions in public channels show the unread urgent indicator", async function (assert) { + await visit("/t/internationalization-localization/280"); + await publishToMessageBus("/chat/9/new-mentions", { + message_id: 201, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator:not(.urgent)" + ) + ); + }); + + test("message selection and live pane buttons for regular user", async function (assert) { + updateCurrentUser({ admin: false, moderator: false }); + await visit("/chat/channel/11/another-category"); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-message-actions-container[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.ok(exists("#chat-quote-btn")); + }); + + test("message selection is not present for regular user", async function (assert) { + updateCurrentUser({ admin: false, moderator: false }); + await visit("/chat/channel/11/another-category"); + assert.notOk( + exists( + ".chat-message-container .chat-message-actions-container .select-btn" + ) + ); + }); + + test("creating a new direct message channel works", async function (assert) { + await visit("/chat/channel/11/another-category"); + await click(".open-draft-channel-page-btn"); + await fillIn(".filter-usernames", "hawk"); + await click("li.user[data-username='hawk']"); + + assert.notOk( + query(".join-channel-btn"), + "Join channel button is not present" + ); + const enabledComposer = document.querySelector(".chat-composer-input"); + assert.ok(!enabledComposer.disabled); + assert.equal( + enabledComposer.placeholder, + I18n.t("chat.placeholder_start_conversation", { usernames: "hawk" }) + ); + }); + + test("creating a new direct message channel from popup chat works", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".open-draft-channel-page-btn"); + await fillIn(".filter-usernames", "hawk"); + await click('.chat-user-avatar-container[data-user-card="hawk"]'); + assert.ok(query(".selected-user").innerText, "hawk"); + }); + + test("Reacting works with no existing reactions", async function (assert) { + await visit("/chat/channel/11/another-category"); + const message = query(".chat-message-container"); + await triggerEvent(message, "mouseenter"); + assert.notOk(message.querySelector(".chat-message-reaction-list")); + await click(".chat-message-actions .react-btn"); + await click(`.chat-emoji-picker .emoji[alt="grinning"]`); + + assert.ok(message.querySelector(".chat-message-reaction-list")); + const reaction = message.querySelector( + ".chat-message-reaction-list .chat-message-reaction.reacted" + ); + assert.ok(reaction); + assert.equal(reaction.querySelector(".count").innerText.trim(), 1); + }); + + test("Reacting works with existing reactions", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + + // First 2 messages have no reactions; make sure the list isn't rendered + assert.notOk(messages[0].querySelector(".chat-message-reaction-list")); + assert.notOk(messages[1].querySelector(".chat-message-reaction-list")); + + const lastMessage = messages[2]; + assert.ok(lastMessage.querySelector(".chat-message-reaction-list")); + assert.equal( + lastMessage.querySelectorAll(".chat-message-reaction.reacted").length, + 2 + ); + assert.equal( + lastMessage.querySelectorAll(".chat-message-reaction:not(.reacted)") + .length, + 1 + ); + + // React with a heart and make sure the count increments and class is added + const heartReaction = lastMessage.querySelector( + `.chat-message-reaction[data-emoji-name="heart"]` + ); + assert.equal(heartReaction.innerText.trim(), "1"); + await click(heartReaction); + assert.equal(heartReaction.innerText.trim(), "2"); + assert.ok(heartReaction.classList.contains("reacted")); + + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 1, username: "eviltrout" }, + emoji: "heart", + type: "reaction", + chat_message_id: 176, + }); + + // Click again make sure count goes down + await click(heartReaction); + assert.equal(heartReaction.innerText.trim(), "1"); + assert.notOk(heartReaction.classList.contains("reacted")); + + // Message from another user coming in! + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 77, username: "rando" }, + emoji: "sneezing_face", + type: "reaction", + chat_message_id: 176, + }); + const sneezingFaceReaction = lastMessage.querySelector( + `.chat-message-reaction[data-emoji-name="sneezing_face"]` + ); + assert.ok(sneezingFaceReaction); + assert.equal(sneezingFaceReaction.innerText.trim(), "1"); + assert.notOk(sneezingFaceReaction.classList.contains("reacted")); + await click(sneezingFaceReaction); + assert.equal(sneezingFaceReaction.innerText.trim(), "2"); + assert.ok(sneezingFaceReaction.classList.contains("reacted")); + }); + + test("Reacting and unreacting works on newly created chat messages", async function (assert) { + await visit("/chat/channel/11/another-category"); + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "hellloooo"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + const messages = queryAll(".chat-message-container"); + const lastMessage = messages[messages.length - 1]; + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + user: { + id: 1, + }, + cooked: "

    hellloooo

    ", + }, + }); + + assert.deepEqual(lastMessage.dataset.id, "202"); + await triggerEvent(lastMessage, "mouseenter"); + await click( + `.chat-message-actions-container[data-id="${lastMessage.dataset.id}"] .react-btn` + ); + await click(`.emoji[alt="grinning"]`); + + const reaction = lastMessage.querySelector( + `.chat-message-reaction.reacted[data-emoji-name="grinning"]` + ); + + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 1, username: "eviltrout" }, + emoji: "grinning", + type: "reaction", + chat_message_id: 202, + }); + await click(reaction); + + assert.notOk( + lastMessage.querySelector( + `.chat-message-reaction.reacted[data-emoji-name="grinning"]` + ) + ); + }); + + test("mention warning is rendered", async function (assert) { + await visit("/chat/channel/11/another-category"); + await publishToMessageBus("/chat/11", { + type: "mention_warning", + cannot_see: [{ id: 75, username: "hawk" }], + without_membership: [ + { id: 76, username: "eviltrout" }, + { id: 77, username: "sam" }, + ], + chat_message_id: 176, + }); + + assert.ok( + exists( + ".chat-message-container[data-id='176'] .chat-message-mention-warning" + ) + ); + + assert.ok( + query( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .cannot-see" + ).innerText.includes("hawk") + ); + + const withoutMembershipText = query( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .without-membership" + ).innerText; + assert.ok(withoutMembershipText.includes("eviltrout")); + assert.ok(withoutMembershipText.includes("sam")); + + await click( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .invite-link" + ); + assert.notOk( + exists( + ".chat-message-container[data-id='176'] .chat-message-mention-warning" + ) + ); + }); + + test("It displays a separator between days", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.equal( + query(".first-daily-message").innerText.trim(), + "July 22, 2021" + ); + }); + + test("pressing keys focuses composer in full page chat", async function (assert) { + await visit("/chat/channel/11/another-category"); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 65); // 65 is `a` keycode + let composer = query(".chat-composer-input"); + assert.equal(composer.value, "a"); + assert.equal(document.activeElement, composer); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 65); + assert.equal(composer.value, "aa"); + assert.equal(document.activeElement, composer); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 191); // 191 is `?` + assert.notEqual( + document.activeElement, + composer, + "? is a special case and should not focus" + ); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", "Enter"); + assert.notEqual( + document.activeElement, + composer, + "enter is a special case and should not focus" + ); + }); + + test("changing channel resets message selection", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + await click("#chat-copy-btn"); + await click("#chat-channel-row-9"); + + assert.notOk(exists("#chat-copy-btn")); + }); +}); + +acceptance( + "Discourse Chat - Acceptance Test with unread public channel messages", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper, [ + { id: 11, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Expand button takes you to full page chat on the correct channel", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await click(".topic-chat-drawer-header__full-screen-btn"); + + assert.equal(currentURL(), `/chat/channel/11/another-category`); + }); + + test("Unread header indicator is present", async function (assert) { + await visit("/t/internationalization-localization/280"); + + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator" + ), + "Unread indicator present in header" + ); + }); + } +); + +acceptance( + "Discourse Chat - Acceptance Test show/hide close fullscreen chat button", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Close fullscreen chat button present", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.ok(exists(".open-drawer-btn")); + }); + } +); + +acceptance( + "Discourse Chat - Expand and collapse chat drawer (topic-chat-float)", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + ]); + + server.get("/chat/api/chat_channels/:id/memberships.json", () => { + return helper.response([]); + }); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("chat drawer can be collapsed and expanded", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + await click(".topic-chat-drawer-header__expand-btn"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--collapsed"), + "chat float is collapsed" + ); + await click(".topic-chat-drawer-header__expand-btn"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + }); + + test("chat drawer title links to channel info when expanded", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + await click("#chat-channel-row-9"); + await click(".topic-chat-drawer-header__title"); + assert.equal(currentURL(), `/chat/channel/9/site/info/members`); + }); + + test("chat drawer title expands the chat drawer when collapsed", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + + await click(".topic-chat-drawer-header__expand-btn"); + + assert.ok( + visible(".topic-chat-drawer-header__top-line--collapsed"), + "chat float is collapsed" + ); + + await click(".topic-chat-drawer-header__title"); + + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + }); + } +); + +acceptance( + "Discourse Chat - Acceptance Test with unread DMs and public channel messages", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + // chat channel with ID 75 is direct message channel. + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + { id: 75, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Unread indicator doesn't show when user is in do not disturb", async function (assert) { + let now = new Date(); + let later = new Date(); + later.setTime(now.getTime() + 600000); + updateCurrentUser({ do_not_disturb_until: later.toUTCString() }); + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-unread-urgent-indicator" + ) + ); + }); + + test("Chat float open to DM channel with unread messages with sidebar off", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + + await click(".header-dropdown-toggle.open-chat"); + await click("#chat-channel-row-75"); + const chatContainer = query(".topic-chat-container"); + assert.ok(chatContainer.classList.contains("channel-75")); + }); + + test("Chat full page open to DM channel with unread messages with sidebar on", async function (assert) { + this.chatService.set("sidebarActive", true); + this.owner.lookup("service:chat-state-manager").prefersFullPage(); + await visit("/t/internationalization-localization/280"); + await click(".header-dropdown-toggle.open-chat"); + + assert.equal(currentURL(), `/chat/channel/75/hawk`); + }); + } +); + +acceptance( + "Discourse Chat - chat channel settings and creation", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + + const channel = { + chatable: {}, + chatable_id: 88, + chatable_type: "Category", + chatable_url: null, + id: 88, + title: "Something", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }; + + server.get("/chat/api/chat_channels.json", () => { + return helper.response([fabricators.chatChannel()]); + }); + + server.get("/chat/chat_channels/:id.json", () => { + return helper.response(channel); + }); + + server.put("/chat/chat_channels", () => { + return helper.response({ + chat_channel: channel, + }); + }); + }); + + test("Create channel modal", async function (assert) { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual(currentURL(), "/chat/browse/open"); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByValue("6"); // Category 6 is "support" + assert.strictEqual( + query(".create-channel-modal .create-channel-name-input").value.trim(), + "support" + ); + assert.notOk(query(".create-channel-modal .btn.create").disabled); + + await click(".create-channel-modal .btn.create"); + assert.strictEqual(currentURL(), "/chat/channel/88/something"); + }); + } +); + +acceptance("Discourse Chat - chat preferences", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Chat preferences route takes user to homepage when can_chat is false", async function (assert) { + updateCurrentUser({ can_chat: false }); + await visit("/u/eviltrout/preferences/chat"); + assert.equal(currentURL(), "/latest"); + }); + + test("There are all 5 settings shown", async function (assert) { + this.chatService.set("sidebarActive", true); + await visit("/u/eviltrout/preferences/chat"); + assert.equal(currentURL(), "/u/eviltrout/preferences/chat"); + assert.equal(queryAll(".chat-setting").length, 5); + }); + + test("The user can save the settings", async function (assert) { + updateCurrentUser({ has_chat_enabled: false }); + const spy = sinon.spy(ajaxModule, "ajax"); + await visit("/u/eviltrout/preferences/chat"); + await click("#user_chat_enabled"); + await click("#user_chat_only_push_notifications"); + await click("#user_chat_ignore_channel_wide_mention"); + await selectKit("#user_chat_sounds").expand(); + await selectKit("#user_chat_sounds").selectRowByValue("bell"); + await selectKit("#user_chat_email_frequency").expand(); + await selectKit("#user_chat_email_frequency").selectRowByValue("never"); + + await click(".save-changes"); + + assert.ok( + spy.calledWithMatch("/u/eviltrout.json", { + data: { + chat_enabled: true, + chat_sound: "bell", + only_chat_push_notifications: true, + ignore_channel_wide_mention: true, + chat_email_frequency: "never", + }, + type: "PUT", + }), + "is able to save the chat preferences for the user" + ); + }); +}); + +acceptance("Discourse Chat - plugin API", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + + test("defines a decorateChatMessage plugin API", async function (assert) { + withPluginApi("1.1.0", (api) => { + api.decorateChatMessage((message) => { + message.innerText = "test"; + }); + }); + + await visit("/chat/channel/75/@hawk"); + + assert.equal( + document.querySelector('.chat-message-container[data-id="177"]') + .innerText, + "test" + ); + }); +}); + +acceptance("Discourse Chat - image uploads", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + chat_allow_uploads: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + + server.post( + "/uploads.json", + () => { + return helper.response({ + extension: "jpeg", + filesize: 126177, + height: 800, + human_filesize: "123 KB", + id: 202, + original_filename: "avatar.PNG.jpg", + retain_hours: null, + short_path: "/images/avatar.png", + short_url: "upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + thumbnail_height: 320, + thumbnail_width: 690, + url: "/images/avatar.png", + width: 1920, + }); + }, + 500 // this delay is important to slow down the uploads a bit so we can click elements in the UI like the cancel button + ); + }); + + test("uploading files in chat works", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.container.lookup("service:chat").set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + + const appEvents = loggedInUser().appEvents; + const done = assert.async(); + + appEvents.on( + "upload-mixin:chat-composer-uploader:all-uploads-complete", + async () => { + await settled(); + assert.ok( + exists(".preview .preview-img"), + "the chat upload preview should show" + ); + assert.notOk( + exists(".bottom-data .uploading"), + "the chat upload preview should no longer say it is uploading" + ); + assert.strictEqual( + queryAll(".chat-composer-input").val(), + "", + "the chat composer does not get the upload markdown when the upload is complete" + ); + done(); + } + ); + + appEvents.on( + "upload-mixin:chat-composer-uploader:upload-started", + async () => { + await settled(); + assert.ok( + exists(".chat-upload"), + "the chat upload preview should show" + ); + assert.ok( + exists(".bottom-data .uploading"), + "the chat upload preview should say it is uploading" + ); + assert.strictEqual( + queryAll(".chat-composer-input").val(), + "", + "the chat composer does not get an uploading... placeholder" + ); + } + ); + + const image = createFile("avatar.png"); + appEvents.trigger("upload-mixin:chat-composer-uploader:add-files", image); + }); + + test("uploading files in composer does not insert placeholder text into chat composer", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + this.container.lookup("service:chat").set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + + const appEvents = loggedInUser().appEvents; + const done = assert.async(); + await fillIn(".d-editor-input", "The image:\n"); + + appEvents.on("composer:all-uploads-complete", async () => { + await settled(); + assert.strictEqual( + query(".d-editor-input").value, + "The image:\n![avatar.PNG|690x320](upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg)\n", + "the topic composer gets the completed image markdown" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "the chat composer does not get the completed image markdown" + ); + done(); + }); + + appEvents.on("composer:upload-started", () => { + assert.strictEqual( + query(".d-editor-input").value, + "The image:\n[Uploading: avatar.png...]()\n", + "the topic composer gets the placeholder image markdown" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "the chat composer does not get the placeholder image markdown" + ); + }); + + const image = createFile("avatar.png"); + appEvents.trigger("composer:add-files", image); + }); +}); + +acceptance( + "Discourse Chat - image uploads - uploads not allowed", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + chat_allow_uploads: false, + discourse_local_dates_enabled: false, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + + test("uploads are not allowed in public channels", async function (assert) { + await visit("/chat/channel/4/public-category"); + await click(".chat-composer-dropdown__trigger-btn"); + + assert.notOk( + exists(".chat-composer-dropdown__item.chat-upload-btn"), + "composer dropdown should not be visible because uploads are not enabled and no other buttons are rendered" + ); + }); + + test("uploads are not allowed in direct message channels", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await click(".chat-composer-dropdown__trigger-btn"); + + assert.notOk( + exists(".chat-composer-dropdown__item.chat-upload-btn"), + "composer dropdown should not be visible because uploads are not enabled and no other buttons are rendered" + ); + }); + } +); + +acceptance("Discourse Chat - Insert Date", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + discourse_local_dates_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + test("can use local date modal", async function (assert) { + await visit("/chat/channel/4/public-category"); + await click(".chat-composer-dropdown__trigger-btn"); + await click(".chat-composer-dropdown__action-btn.local-dates"); + + assert.ok(exists(".discourse-local-dates-create-modal")); + + await click(".modal-footer .btn-primary"); + + assert.ok( + query(".chat-composer-input").value.startsWith("[date"), + "inserts date in composer input" + ); + }); +}); + +acceptance( + "Discourse Chat - Channel Status - Read only channel", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.readOnly; + return helper.response(cloned); + }); + }); + + test("read only channel composer is disabled", async function (assert) { + await visit("/chat/channel/5/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, true); + }); + + test("read only channel header status shows correct information", async function (assert) { + await visit("/chat/channel/5/public-category"); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.read_only_header") + ); + }); + + test("read only channels do not show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/5/public-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + assert.notOk(exists(".select-kit-row[data-value='edit']")); + assert.notOk(exists(".select-kit-row[data-value='deleteMessage']")); + assert.notOk(exists(".select-kit-row[data-value='rebakeMessage']")); + assert.notOk(exists(".reply-btn")); + assert.notOk(exists(".react-btn")); + }); + } +); + +acceptance( + "Discourse Chat - Channel Status - Closed channel (regular user)", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 4).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("closed channel composer is disabled", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, true); + }); + + test("closed channel header status shows correct information", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.closed_header") + ); + }); + + test("closed channels do not show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/4/public-category"); + + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + + assert.notOk(exists(".select-kit-row[data-value='edit']")); + assert.notOk(exists(".select-kit-row[data-value='deleteMessage']")); + assert.notOk(exists(".select-kit-row[data-value='rebakeMessage']")); + assert.notOk(exists(".reply-btn")); + assert.notOk(exists(".react-btn")); + }); + } +); + +acceptance( + "Discourse Chat - Channel Status - Closed channel (staff user)", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("closed channel composer is enabled", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, false); + }); + + test("closed channels show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/4/public-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-message-actions .more-buttons"); + await dropdown.expand(); + assert.ok( + exists(".select-kit-row[data-value='edit']"), + "the edit message button is shown" + ); + assert.ok( + exists(".select-kit-row[data-value='deleteMessage']"), + "the delete message button is shown" + ); + assert.ok( + exists(".select-kit-row[data-value='rebakeMessage']"), + "the rebake message button is shown" + ); + assert.ok(exists(".reply-btn", "the reply button is shown")); + assert.ok(exists(".react-btn"), "the react button is shown"); + }); + } +); + +acceptance("Discourse Chat - Channel Replying Indicator", function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("indicator content when replying/not replying", async function (assert) { + const user = { id: 8, username: "bob" }; + await visit("/chat/channel/4/public-category"); + await joinChannel("/chat-reply/4", user); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + I18n.t("chat.replying_indicator.single_user", { + username: user.username, + }) + ); + + await leaveChannel("/chat-reply/4", user); + + assert.notOk(exists(".chat-replying-indicator__text")); + }); +}); + +acceptance("Discourse Chat - Direct Message Creator", function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + + server.get("/u/search/users", () => { + return helper.response([]); + }); + }); + + test("Create a direct message", async function (assert) { + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + + assert.ok( + !exists(".open-draft-channel-page-btn.btn-floating"), + "mobile floating button should not exist on desktop" + ); + await click(".btn.open-draft-channel-page-btn"); + assert.ok(exists(".chat-draft"), "view changes to draft channel screen"); + }); +}); + +acceptance("Discourse Chat - Drawer", function (needs) { + needs.user({ has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Position after closing reduced composer", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".btn.create"); + await click(".toggle-preview"); + await click(".header-dropdown-toggle.open-chat"); + await click(".save-or-cancel .cancel"); + const float = document.querySelector(".topic-chat-float-container"); + const key = "--composer-right"; + const value = getComputedStyle(float).getPropertyValue(key); + + assert.strictEqual(value, "15px"); + }); +}); + +function createFile(name, type = "image/png") { + // the blob content doesn't matter at all, just want it to be random-ish + const file = new Blob([(Math.random() + 1).toString(36).substring(2)], { + type, + }); + file.name = name; + return file; +} diff --git a/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js b/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js new file mode 100644 index 0000000000..6321bbead1 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js @@ -0,0 +1,580 @@ +import PrettyText, { buildOptions } from "pretty-text/pretty-text"; +import { emojiUnescape } from "discourse/lib/text"; +import I18n from "I18n"; +import topicFixtures from "discourse/tests/fixtures/topic"; +import { cloneJSON, deepMerge } from "discourse-common/lib/object"; +import QUnit, { test } from "qunit"; + +import { click, fillIn, visit } from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; + +const rawOpts = { + siteSettings: { + enable_emoji: true, + enable_emoji_shortcuts: true, + enable_mentions: true, + emoji_set: "twitter", + external_emoji_url: "", + highlighted_languages: "json|ruby|javascript", + default_code_lang: "auto", + enable_markdown_linkify: true, + markdown_linkify_tlds: "com", + chat_enabled: true, + }, + getURL: (url) => url, +}; + +function cookMarkdown(input, opts) { + const merged = deepMerge({}, rawOpts, opts); + return new PrettyText(buildOptions(merged)).cook(input); +} + +QUnit.assert.cookedChatTranscript = function (input, opts, expected, message) { + const actual = cookMarkdown(input, opts); + this.pushResult({ + result: actual === expected, + actual, + expected, + message, + }); +}; + +function generateTranscriptHTML(messageContent, opts) { + const channelDataAttr = opts.channel + ? ` data-channel-name=\"${opts.channel}\"` + : ""; + const channelIdDataAttr = opts.channelId + ? ` data-channel-id=\"${opts.channelId}\"` + : ""; + const reactDataAttr = opts.reactions + ? ` data-reactions=\"${opts.reactionsAttr}\"` + : ""; + + let tabIndexHTML = opts.linkTabIndex ? ' tabindex="-1"' : ""; + + let transcriptClasses = ["chat-transcript"]; + if (opts.chained) { + transcriptClasses.push("chat-transcript-chained"); + } + + const transcript = []; + transcript.push( + `
    ` + ); + + if (opts.channel && opts.multiQuote) { + let originallySent = I18n.t("chat.quote.original_channel", { + channel: opts.channel, + channelLink: `/chat/channel/${opts.channelId}/-`, + }); + if (opts.linkTabIndex) { + originallySent = originallySent.replace(">", tabIndexHTML + ">"); + } + transcript.push(`
    +${originallySent}
    `); + } + + const dateTimeText = opts.showDateTimeText + ? moment + .tz(opts.datetime, opts.timezone) + .format(I18n.t("dates.long_no_year")) + : ""; + + const innerDatetimeEl = + opts.noLink || !opts.channelId + ? `${dateTimeText}` + : `${dateTimeText}`; + transcript.push(`
    +
    +
    +${opts.username}
    +
    +${innerDatetimeEl}
    `); + + if (opts.channel && !opts.multiQuote) { + transcript.push( + ` +#${opts.channel}
    ` + ); + } else { + transcript.push("
    "); + } + + let messageHtml = `
    \n${messageContent}`; + + if (opts.reactions) { + let reactionsHtml = [`
    \n`]; + opts.reactions.forEach((react) => { + reactionsHtml.push( + `
    \n${emojiUnescape( + `:${react.emoji}:`, + { lazy: true } + ).replace(/'/g, '"')} ${react.usernames.length}
    \n` + ); + }); + reactionsHtml.push(`
    \n`); + messageHtml += reactionsHtml.join(""); + } + transcript.push(`${messageHtml}
    `); + transcript.push("
    "); + return transcript.join("\n"); +} + +// these are both set by the plugin with Site.markdown_additional_options which we can't really +// modify the response for here, source of truth are consts in ChatMessage::MARKDOWN_FEATURES +// and ChatMessage::MARKDOWN_IT_RULES +function buildAdditionalOptions() { + return { + chat: { + limited_pretty_text_features: [ + "anchor", + "bbcode-block", + "bbcode-inline", + "code", + "category-hashtag", + "censored", + "discourse-local-dates", + "emoji", + "emojiShortcuts", + "inlineEmoji", + "html-img", + "mentions", + "onebox", + "text-post-process", + "upload-protocol", + "watched-words", + "table", + "spoiler-alert", + ], + limited_pretty_text_markdown_rules: [ + "autolink", + "list", + "backticks", + "newline", + "code", + "fence", + "table", + "linkify", + "link", + "strikethrough", + "blockquote", + "emphasis", + ], + }, + }; +} + +acceptance("Discourse Chat | chat-transcript", function (needs) { + let additionalOptions = buildAdditionalOptions(); + + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: false, + has_chat_enabled: false, + timezone: "Australia/Brisbane", + }); + + needs.settings({ + emoji_set: "twitter", + }); + + test("works with a minimal quote bbcode block", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + }), + "renders the chat message with the required CSS classes and attributes" + ); + }); + + test("renders the channel name if provided with multiQuote", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + timezone: "Australia/Brisbane", + }), + "renders the chat transcript with the channel name included above the user and datetime" + ); + }); + + test("renders the channel name if provided without multiQuote", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + timezone: "Australia/Brisbane", + }), + "renders the chat transcript with the channel name included next to the datetime" + ); + }); + + test("renders with the chained attribute for more compact quotes", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" chained="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + chained: true, + timezone: "Australia/Brisbane", + }), + "renders with the chained attribute" + ); + }); + + test("renders with the noLink attribute to remove the links to the individual messages from the datetimes", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" noLink="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + noLink: true, + timezone: "Australia/Brisbane", + }), + "renders with the noLink attribute" + ); + }); + + test("renders with the reactions attribute", function (assert) { + const reactionsAttr = "+1:martin;heart:martin,eviltrout"; + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" reactions="${reactionsAttr}"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

    This is a chat message.

    ", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + timezone: "Australia/Brisbane", + reactionsAttr, + reactions: [ + { emoji: "+1", usernames: ["martin"] }, + { emoji: "heart", usernames: ["martin", "eviltrout"] }, + ], + }), + "renders with the reaction data attribute and HTML" + ); + }); + + test("renders with minimal markdown rules inside the quote bbcode block, same as server-side chat messages", function (assert) { + assert.cookedChatTranscript( + `[chat quote="johnsmith;450;2021-04-25T05:40:39Z"] +[quote="martin, post:3, topic:6215"] +another cool reply +[/quote] +[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

    [quote="martin, post:3, topic:6215"]
    +another cool reply
    +[/quote]

    `, + { + messageId: "450", + username: "johnsmith", + datetime: "2021-04-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "does not render the markdown feature that has been excluded" + ); + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

    This does work with removed rules.

    +
      +
    • list item 1
    • +
    `, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly when the rule has not been excluded" + ); + + additionalOptions.chat.limited_pretty_text_markdown_rules = [ + "autolink", + // "list", + "backticks", + "newline", + "code", + "fence", + "table", + "linkify", + "link", + // "strikethrough", + "blockquote", + // "emphasis", + ]; + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

    This ~~does work~~ with removed _rules_.

    +

    * list item 1

    `, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly with some obvious rules excluded (list/strikethrough/emphasis)" + ); + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

    here is a message \":stuck_out_tongue:\" with category hashtag #test

    `, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly when the feature has not been excluded" + ); + + additionalOptions.chat.limited_pretty_text_features = [ + "anchor", + "bbcode-block", + "bbcode-inline", + "code", + // "category-hashtag", + "censored", + "discourse-local-dates", + "emoji", + // "emojiShortcuts", + "inlineEmoji", + "html-img", + "mentions", + "onebox", + "text-post-process", + "upload-protocolrouter.location.setURL", + "watched-words", + "table", + "spoiler-alert", + ]; + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

    here is a message :P with category hashtag #test

    `, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly with some obvious features excluded (category-hashtag, emojiShortcuts)" + ); + + assert.cookedChatTranscript( + `This ~~does work~~ with removed _rules_. + +* list item 1 + +here is a message :P with category hashtag #test + +[chat quote="martin;2321;2022-01-25T05:40:39Z"] +This ~~does work~~ with removed _rules_. + +* list item 1 + +here is a message :P with category hashtag #test +[/chat]`, + { additionalOptions }, + `

    This does work with removed rules.

    +
      +
    • list item 1
    • +
    +

    here is a message \":stuck_out_tongue:\" with category hashtag #test

    \n` + + generateTranscriptHTML( + `

    This ~~does work~~ with removed _rules_.

    +

    * list item 1

    +

    here is a message :P with category hashtag #test

    `, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "the rule changes do not apply outside the BBCode [chat] block" + ); + }); +}); + +acceptance( + "Discourse Chat | chat-transcript date decoration", + function (needs) { + let additionalOptions = buildAdditionalOptions(); + + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + timezone: "Australia/Brisbane", + }); + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + const postCooked = cookMarkdown( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions } + ); + firstPost.cooked += postCooked; + + server.get("/t/280.json", () => helper.response(topicResponse)); + }); + + test("chat transcript datetimes are formatted into the link with decorateCookedElement", async function (assert) { + await visit("/t/-/280"); + + assert.strictEqual( + query(".chat-transcript-datetime span").innerText.trim(), + moment + .tz("2022-01-25T05:40:39Z", "Australia/Brisbane") + .format(I18n.t("dates.long_no_year")), + "it decorates the chat transcript datetime link with a formatted date" + ); + }); + } +); + +acceptance( + "Discourse Chat - chat-transcript - Composer Oneboxes ", + function (needs) { + let additionalOptions = buildAdditionalOptions(); + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + timezone: "Australia/Brisbane", + }); + needs.settings({ + chat_enabled: true, + enable_markdown_linkify: true, + max_oneboxes_per_post: 2, + }); + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + const postCooked = cookMarkdown( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions } + ); + firstPost.cooked += postCooked; + + server.get("/t/280.json", () => helper.response(topicResponse)); + }); + + test("Preview should not error for oneboxes within [chat] bbcode", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("#topic-footer-buttons .btn.create"); + + await fillIn( + ".d-editor-input", + ` +[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"] +http://www.example.com/has-title.html +[/chat]` + ); + + const rendered = generateTranscriptHTML( + '

    ', + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + linkTabIndex: true, + showDateTimeText: true, + timezone: "Australia/Brisbane", + } + ); + + assert.strictEqual( + query(".d-editor-preview").innerHTML.trim(), + rendered.trim(), + "it renders correctly with the onebox inside the [chat] bbcode" + ); + + const textarea = query("#reply-control .d-editor-input"); + await fillIn(".d-editor-input", textarea.value + "\nA"); + assert.ok( + query(".d-editor-preview").innerHTML.trim().includes("\n

    A

    "), + "it does not error with a opts.discourse.hoisted error in the markdown pipeline when typing more text" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js b/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js new file mode 100644 index 0000000000..866def7056 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js @@ -0,0 +1,252 @@ +import I18n from "I18n"; +import { test } from "qunit"; + +import { click, visit } from "@ember/test-helpers"; + +import { + acceptance, + exists, + query, + queryAll, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; + +import { + baseChatPretenders, + chatChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance( + "Discourse Chat - experiment user menu notifications - user cannot chat", + function (needs) { + needs.user({ has_chat_enabled: false }); + needs.settings({ chat_enabled: false }); + + test("chat notifications tab is not displayed in user menu", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + assert.notOk( + exists("#user-menu-button-chat-notifications"), + "button for chat notifications tab is not displayed" + ); + }); + } +); + +acceptance( + "Discourse Chat - experimental user menu notifications ", + function (needs) { + needs.user({ redesigned_user_menu_enabled: true, has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + test("chat notifications tab", async function (assert) { + updateCurrentUser({ + grouped_unread_notifications: { + 29: 3, // chat_mention notification type + 31: 1, // chat_invitation notification type + }, + }); + + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + assert.ok( + exists("#user-menu-button-chat-notifications"), + "button for chat notifications tab is displayed" + ); + + assert.ok( + exists("#user-menu-button-chat-notifications .d-icon-comment"), + "displays the comment icon for chat notification tab button" + ); + + assert.strictEqual( + query("#user-menu-button-chat-notifications .badge-notification") + .textContent, + "4", + "displays the right badge count for chat notifications tab button" + ); + }); + + test("chat mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatMentionNotificationLink = queryAll(".chat-mention a")[0]; + + assert.strictEqual( + chatMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned you in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists(chatMentionNotificationLink.querySelector(".d-icon-comment")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("personal chat mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const personalChatMentionNotificationLink = + queryAll(".chat-mention a")[3]; + + assert.strictEqual( + personalChatMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + "hawk mentioned you in personal chat", + "displays the right text for notification" + ); + + assert.ok( + exists( + personalChatMentionNotificationLink.querySelector(".d-icon-comment") + ), + "displays the right icon for the notification" + ); + + assert.strictEqual( + personalChatMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + personalChatMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat group mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatGroupMentionNotificationLink = queryAll(".chat-mention a")[1]; + + assert.strictEqual( + chatGroupMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned @engineers in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists( + chatGroupMentionNotificationLink.querySelector(".d-icon-comment") + ), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatGroupMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatGroupMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat all mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatAllMentionNotificationLink = queryAll(".chat-mention a")[2]; + + assert.strictEqual( + chatAllMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned @all in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists(chatAllMentionNotificationLink.querySelector(".d-icon-comment")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatAllMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatAllMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat invite notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatInviteNotificationLink = queryAll(".chat-invitation a")[0]; + + assert.strictEqual( + chatInviteNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + "hawk invited you to join a chat channel", + "displays the right text for notification" + ); + + assert.ok( + exists(chatInviteNotificationLink.querySelector(".d-icon-link")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatInviteNotificationLink.title, + I18n.t("notifications.titles.chat_invitation"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatInviteNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js b/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js new file mode 100644 index 0000000000..107f62334d --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js @@ -0,0 +1,68 @@ +import { setCaretPosition } from "discourse/lib/utilities"; +import { + acceptance, + exists, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { chatChannelPretender } from "../helpers/chat-pretenders"; +import { fillIn, settled, triggerKeyEvent, visit } from "@ember/test-helpers"; + +acceptance( + "Discourse Chat - Composer hashtag autocompletion", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 100, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.post("/chat/drafts", () => helper.response(500, {})); + server.get("/hashtags/search.json", () => { + return helper.response({ + results: [ + { type: "category", text: "Design", slug: "design", ref: "design" }, + { type: "tag", text: "dev", slug: "dev", ref: "dev" }, + { type: "tag", text: "design", slug: "design", ref: "design::tag" }, + ], + }); + }); + }); + needs.settings({ + chat_enabled: true, + enable_experimental_hashtag_autocomplete: true, + }); + + test("using # in the chat composer shows category and tag autocomplete options", async function (assert) { + await visit("/chat/channel/11/-"); + const composerInput = query(".chat-composer-input"); + await fillIn(".chat-composer-input", "abc #"); + await triggerKeyEvent(".chat-composer-input", "keydown", "#"); + await fillIn(".chat-composer-input", "abc #"); + await setCaretPosition(composerInput, 5); + await triggerKeyEvent(".chat-composer-input", "keyup", "#"); + await triggerKeyEvent(".chat-composer-input", "keydown", "D"); + await fillIn(".chat-composer-input", "abc #d"); + await setCaretPosition(composerInput, 6); + await triggerKeyEvent(".chat-composer-input", "keyup", "D"); + await settled(); + assert.ok( + exists(".hashtag-autocomplete"), + "hashtag autocomplete menu appears" + ); + assert.strictEqual( + queryAll(".hashtag-autocomplete__option").length, + 3, + "all options should be shown" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js b/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js new file mode 100644 index 0000000000..4cf7fa9726 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js @@ -0,0 +1,680 @@ +import { + acceptance, + exists, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { click, currentURL, settled, visit } from "@ember/test-helpers"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import I18n from "I18n"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { emojiUnescape } from "discourse/lib/text"; +import User from "discourse/models/user"; + +acceptance("Discourse Chat - Core Sidebar", function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + let directChannels = cloneJSON(directMessageChannels).mapBy("chat_channel"); + directChannels[0].chatable.users = [directChannels[0].chatable.users[0]]; + directChannels[0].current_user_membership.unread_count = 1; + directChannels.push({ + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "sam", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 59, + chatable_type: "DirectMessage", + chatable_url: null, + id: 76, + title: "@sam", + last_message_sent_at: "2021-06-01T11:15:00.000Z", + current_user_membership: { + unread_count: 0, + muted: true, + following: true, + }, + }); + directChannels.push({ + chatable: { + users: [ + { + id: 1, + username: "", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_type: "DirectMessage", + chatable_url: null, + id: 77, + title: "@", + last_message_sent_at: "2021-06-01T11:15:00.000Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }); + + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "dev :bug:", + chatable_type: "Category", + chatable: { slug: "dev", read_restricted: true }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 0, + unread_mentions: 0, + }, + }, + { + id: 2, + title: "general", + chatable_type: "Category", + chatable: { slug: "general" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 0, + }, + }, + { + id: 3, + title: "random", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + muted: true, + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 4, + title: "", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + ], + direct_message_channels: directChannels, + }); + }); + + server.get("/chat/1/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/u/search/users", () => { + return helper.response({ + users: [ + { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/41988e/{size}.png", + }, + ], + }); + }); + + server.get("/chat/75/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/chat/direct_messages.json", () => { + return helper.response({ + chat_channel: { + id: 75, + title: "hawk", + chatable_type: "DirectMessage", + last_message_sent_at: "2021-07-20T08:14:16.950Z", + chatable: { + users: [{ username: "hawk" }], + }, + }, + }); + }); + }); + + needs.hooks.beforeEach(function () { + withPluginApi("1.3.0", (api) => { + api.addUsernameSelectorDecorator((username) => { + if (username === "hawk") { + return `${emojiUnescape( + ":desert_island:" + )}`; + } + }); + }); + }); + + test("Public channels section", async function (assert) { + await visit("/"); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-header-text" + ).textContent.trim(), + I18n.t("chat.chat_channels"), + "displays correct channels section title" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "dev channel section link displays hash icon prefix" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-prefix svg.prefix-badge.d-icon-lock" + ), + "dev channel section link displays lock badge for restricted channel" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .emoji" + ), + "unescapes emoji in channel title in the link" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug" + ).textContent.trim(), + "dev", + "dev channel section link displays channel title in the link" + ); + + assert.ok( + query( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug" + ).href.endsWith("/chat/channel/1/dev-bug"), + "dev channel section link has the right href attribute" + ); + + assert.notOk( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-suffix" + ), + "does not display new messages indicator" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "general channel section link displays hash icon prefix" + ); + + assert.notOk( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-prefix svg.prefix-badge" + ), + "general channel section link does not display lock badge for public channel" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-general" + ).textContent.trim(), + "general", + "general channel section link displays channel title in the link" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-suffix.unread" + ), + "general section link has new messages indicator" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "random channel section link displays hash icon prefix" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-random" + ).textContent.trim(), + "random", + "random channel section link displays channel title in the link" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random .sidebar-section-link-suffix.urgent" + ), + "random section link has new messages mention indicator" + ); + }); + + test("sidebar section link when direct message channel is muted by user", async function (assert) { + await visit("/"); + + assert.ok( + exists( + ".sidebar-section-chat-dms .sidebar-section-link-sam.sidebar-section-link--muted" + ), + "muted direct chat channel section link has right classname configured" + ); + }); + + test("sidebar section link when public channel is muted by user", async function (assert) { + await visit("/"); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random.sidebar-section-link--muted" + ), + "muted random chat channel section link has right classname configured" + ); + }); + + test("Direct messages section", async function (assert) { + const chatService = this.container.lookup("service:chat"); + chatService.directMessagesLimit = 2; + await visit("/"); + + assert.strictEqual( + query( + ".sidebar-section-chat-dms .sidebar-section-header-text" + ).textContent.trim(), + I18n.t("chat.direct_messages.title"), + "displays correct direct messages section title" + ); + + let directLinks = queryAll( + ".sidebar-section-chat-dms a.sidebar-section-link" + ); + + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-prefix img") + .classList.contains("prefix-image"), + true, + "displays avatar in prefix when two participants" + ); + + assert.strictEqual( + directLinks[0].textContent.trim(), + "hawk", + "displays user name in a link" + ); + + assert.ok( + directLinks[0].querySelector( + ".sidebar-section-link-content-text .on-holiday img" + ), + "displays flair when user is on holiday" + ); + + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-suffix") + .classList.contains("urgent"), + true, + "displays new messages indicator" + ); + + assert.strictEqual( + directLinks[1] + .querySelector("span.sidebar-section-link-prefix") + .classList.contains("text"), + true, + "displays text in prefix when more than two participants" + ); + + assert.strictEqual( + directLinks[1] + .querySelector(".sidebar-section-link-content-text") + .textContent.trim(), + "eviltrout, markvanlan", + "displays all participants name in a link" + ); + + assert.ok( + !directLinks[1].querySelector(".sidebar-section-link-suffix"), + "does not display new messages indicator" + ); + User.current().chat_channel_tracking_state[76].set("unread_count", 99); + chatService.reSortDirectMessageChannels(); + chatService.appEvents.trigger("chat:user-tracking-state-changed"); + await settled(); + + directLinks = queryAll(".sidebar-section-chat-dms a.sidebar-section-link"); + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-content-text") + .textContent.trim(), + "eviltrout, markvanlan", + "reorders private messages" + ); + + assert.equal( + directLinks.length, + 2, + "limits number of displayed direct messages" + ); + }); + + test("Plugin sidebar is hidden", async function (assert) { + await visit("/chat/channel/1/dev"); + assert.notOk(exists(".full-page-chat .channels-list")); + }); + + test("Open a new direct conversation", async function (assert) { + await visit("/"); + await click(".sidebar-section-chat-dms .sidebar-section-header-button"); + + assert.ok(exists(".direct-message-creator")); + assert.ok(exists(".topic-chat-container.expanded.visible")); + assert.strictEqual(currentURL(), "/"); + }); + + test("Escapes public channel titles", async function (assert) { + await visit("/"); + + const evilChannel = query( + ".sidebar-section-chat-channels .sidebar-section-link-wrapper .sidebar-section-link" + ); + + assert.strictEqual(evilChannel.title, "<script>evil</script>"); + + assert.ok( + evilChannel.className.includes( + "sidebar-section-link-ltscriptgtevilltscriptgt" + ) + ); + + assert.strictEqual( + evilChannel + .querySelector(".sidebar-section-link-content-text") + .innerHTML.trim(), + "<script>evil</script>" + ); + }); + + test("Escapes dm channel titles", async function (assert) { + await visit("/"); + + const evilChannel = queryAll( + ".sidebar-section-chat-dms .sidebar-section-link-wrapper .sidebar-section-link" + )[3]; + + assert.strictEqual(evilChannel.title, "@<script>sam</script>"); + + assert.ok( + evilChannel.className.includes( + "sidebar-section-link-ltscriptgtsamltscriptgt" + ) + ); + + assert.strictEqual( + evilChannel + .querySelector(".sidebar-section-link-content-text") + .innerHTML.trim(), + "&lt;script&gt;sam&lt;/script&gt;" + ); + }); +}); + +acceptance("Discourse Chat - Plugin Sidebar", function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_sidebar: false, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "dev :bug:", + chatable_type: "Category", + chatable: { slug: "dev", read_restricted: true }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 2, + title: "general", + chatable_type: "Category", + chatable: { slug: "general" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 3, + title: "random", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + ], + direct_message_channels: [], + }); + }); + + server.get("/chat/1/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + }); + + test("Plugin sidebar is visible", async function (assert) { + await visit("/chat/channel/1/dev"); + assert.ok(exists(".full-page-chat .channels-list")); + }); +}); + +acceptance( + "Discourse Chat - Core Sidebar - no joinable public channels, staff", + function (needs) { + needs.user({ has_chat_enabled: true, has_joinable_public_channels: false }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Chat channels section visibility", async function (assert) { + await visit("/"); + + assert.ok( + exists(".sidebar-section-chat-channels"), + "it shows the section for staff" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - no joinable public channels, regular user", + function (needs) { + needs.user({ + has_chat_enabled: true, + has_joinable_public_channels: false, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Chat channels section visibility", async function (assert) { + await visit("/"); + + assert.notOk( + exists(".sidebar-section-chat-channels"), + "it doesn’t show the section for regular user" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - regular user with no direct message channels who cannot send direct messages", + function (needs) { + needs.user({ + has_chat_enabled: true, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + direct_message_enabled_groups: "13", // trust_level_3 auto group ID; + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Direct message channels section visibility", async function (assert) { + await visit("/"); + + assert.notOk( + exists(".sidebar-section-chat-dms"), + "it doesn’t show the section for regular user" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - regular user with existing direct message channels who cannot send direct messages", + function (needs) { + needs.user({ + has_chat_enabled: true, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + direct_message_enabled_groups: "13", // trust_level_3 auto group ID; + }); + + needs.pretender((server, helper) => { + let directChannels = cloneJSON(directMessageChannels).mapBy( + "chat_channel" + ); + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: directChannels, + }); + }); + }); + + test("Direct message channels section visibility", async function (assert) { + await visit("/"); + + assert.ok( + exists(".sidebar-section-chat-dms"), + "it does show the section for a regular user" + ); + + assert.notOk( + exists(".sidebar-section-chat-dms .sidebar-section-header-button"), + "user cannot see the create DM channel button" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/create-channel-test.js b/plugins/chat/test/javascripts/acceptance/create-channel-test.js new file mode 100644 index 0000000000..a478f2225c --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/create-channel-test.js @@ -0,0 +1,179 @@ +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { click, visit } from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Create channel modal", function (needs) { + const maliciousText = '"'; + + needs.user({ + username: "tomtom", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + const catsCategory = { + id: 1, + name: "Cats", + slug: "cats", + permission: 1, + }; + + needs.site({ + categories: [ + catsCategory, + { + id: 2, + name: maliciousText, + slug: maliciousText, + permission: 1, + }, + { + id: 3, + name: "Kittens", + slug: "kittens", + permission: 1, + parentCategory: catsCategory, + }, + ], + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.get("/chat/api/chat_channels.json", () => helper.response([])); + + server.get( + "/chat/api/category-chatables/:categoryId/permissions.json", + (request) => { + if (request.params.categoryId === "2") { + return helper.response({ + allowed_groups: ["@"], + members_count: 2, + private: true, + }); + } else { + return helper.response({ + allowed_groups: ["@awesomeGroup"], + members_count: 2, + private: true, + }); + } + } + ); + }); + + test("links to categories and selected category's security settings", async function (assert) { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Cats"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "security settings" + ); + assert.ok( + query(".create-channel-hint a").href.includes("/c/cats/edit/security") + ); + }); + + test("links to selected category's security settings works with nested subcategories", async function (assert) { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Kittens"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "security settings" + ); + assert.ok( + query(".create-channel-hint a").href.includes( + "/c/cats/kittens/edit/security" + ) + ); + }); + + test("includes group names in the hint", async (assert) => { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Kittens"); + + assert.strictEqual( + query(".create-channel-hint").innerHTML.trim(), + 'Users in @awesomeGroup will have access to this channel per the security settings' + ); + }); + + test("escapes group name/category slug in the hint", async (assert) => { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + const categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByValue(2); + + assert.strictEqual( + query(".create-channel-hint").innerHTML.trim(), + 'Users in @<script>evilgroup</script> will have access to this channel per the security settings' + ); + assert.ok( + query(".create-channel-hint a").href.includes( + "c/%22%3Cscript%3E%3C/script%3E/edit/security" + ) + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js b/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js new file mode 100644 index 0000000000..7510ae1450 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js @@ -0,0 +1,42 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import fabricators from "../helpers/fabricators"; + +acceptance("Discourse Chat - delete chat channel modal", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [fabricators.chatChannel({ id: 2 })], + direct_message_channels: [], + }); + }); + + server.get("/chat/chat_channels/:id", (request) => { + return helper.response( + fabricators.chatChannel({ id: request.params.id }) + ); + }); + + server.get("/chat/:id/messages.json", () => { + return helper.response({ meta: {}, chat_messages: [] }); + }); + + server.delete("/chat/chat_channels/:id.json", () => { + return helper.response({}); + }); + }); + + test("Redirection after deleting a channel", async function (assert) { + await visit("chat/channel/1/my-category-title/info/settings"); + await click(".delete-btn"); + await fillIn("#channel-delete-confirm-name", "My category title"); + await click("#chat-confirm-delete-channel"); + + assert.equal(currentURL(), "/chat/channel/2/my-category-title"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js b/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js new file mode 100644 index 0000000000..62db7c85bb --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js @@ -0,0 +1,75 @@ +import { + acceptance, + exists, + loggedInUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, visit } from "@ember/test-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Mobile test", function (needs) { + needs.user({ can_chat: true, has_chat_enabled: true }); + + needs.mobileView(); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/u/search/users", () => { + return helper.response([]); + }); + + server.get("/chat/api/chat_channels.json", () => { + const channels = []; + return helper.response(channels); + }); + }); + + needs.settings({ + chat_enabled: true, + }); + + test("Chat index route shows channels list", async function (assert) { + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + assert.equal(currentURL(), "/chat"); + assert.ok(exists(".channels-list")); + await click(".chat-channel-row.chat-channel-7"); + assert.notOk(exists(".open-drawer-btn")); + }); + + test("Chat new personal chat buttons", async function (assert) { + await visit("/chat"); + await click(".open-draft-channel-page-btn.btn-floating"); + + assert.strictEqual( + currentURL(), + "/chat/draft-channel", + "Clicking the floating + button opens the new chat screen" + ); + + await click(".chat-draft-header__btn"); + + assert.strictEqual( + currentURL(), + "/chat", + "Clicking the left arrow button returns to the channels list" + ); + }); + + test("Chat browse screen back button", async function (assert) { + await visit("/chat/browse"); + await click(".chat-full-page-header__back-btn"); + + assert.strictEqual( + currentURL(), + "/chat", + "Clicking the back button returns to the channels list" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js b/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js new file mode 100644 index 0000000000..a4750fea4e --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js @@ -0,0 +1,117 @@ +import userFixtures from "discourse/tests/fixtures/user-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + acceptance, + exists, + loggedInUser, + query, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, visit } from "@ember/test-helpers"; +import { + chatChannels, + directMessageChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; + +acceptance("Discourse Chat - User card test", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/chat_channels/:channelId.json", () => + helper.response(helper.response(directMessageChannels[0])) + ); + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/chat/direct_messages/create.json", () => { + return helper.response({ + chat_channel: { + chat_channels: [], + chatable: { + users: [ + { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: + "/letter_avatar_proxy/v3/letter/t/41988e/{size}.png", + }, + ], + }, + chatable_id: 16, + chatable_type: "DirectMessage", + chatable_url: null, + id: 75, + title: "@hawk", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }, + }); + }); + let cardResponse = cloneJSON(userFixtures["/u/charlie/card.json"]); + cardResponse.user.can_chat_user = true; + server.get("/u/hawk/card.json", () => helper.response(cardResponse)); + }); + needs.settings({ + chat_enabled: true, + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "appEvents", { + get: () => this.container.lookup("service:appEvents"), + }); + }); + + test("user card has chat button that opens the correct channel", async function (assert) { + this.chatService.set("sidebarActive", false); + await visit("/"); + await click(".header-dropdown-toggle.open-chat"); + await click(".chat-channel-row.chat-channel-9"); + await click("[data-user-card='hawk']"); + + assert.ok(exists(".user-card-chat-btn")); + + await click(".user-card-chat-btn"); + + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-75")); + }); +}); + +acceptance( + "Discourse Chat - Anon user viewing user card test", + function (needs) { + needs.settings({ + chat_enabled: true, + }); + + test("user card has no chat button", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click('a[data-user-card="charlie"]'); + + assert.notOk( + exists(".user-card-chat-btn"), + "anon user should not be able to chat with anyone via the user card" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/chat-fixtures.js b/plugins/chat/test/javascripts/chat-fixtures.js new file mode 100644 index 0000000000..c8ab615160 --- /dev/null +++ b/plugins/chat/test/javascripts/chat-fixtures.js @@ -0,0 +1,334 @@ +import { deepMerge } from "discourse-common/lib/object"; + +export const messageContents = ["Hello world", "What up", "heyo!"]; + +export const directMessageChannels = [ + { + chat_channel: { + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "hawk", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 58, + chatable_type: "DirectMessage", + chatable_url: null, + id: 75, + title: "@hawk", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + last_message_sent_at: "2021-07-20T08:14:16.950Z", + }, + }, + { + chat_channel: { + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 3, + username: "eviltrout", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 59, + chatable_type: "DirectMessage", + chatable_url: null, + id: 76, + title: "@eviltrout, @markvanlan", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + last_message_sent_at: "2021-07-05T12:04:00.850Z", + }, + }, +]; + +const chatables = { + 1: { + id: 1, + name: "Bug", + color: "0088CC", + text_color: "FFFFFF", + slug: "bug", + }, + 8: { + id: 8, + name: "Public category", + slug: "public-category", + posts_count: 1, + }, + 12: { + id: 12, + name: "Another category", + slug: "another-category", + posts_count: 100, + }, +}; + +export const chatChannels = { + public_channels: [ + { + id: 9, + chatable_id: 1, + chatable_type: "Category", + chatable_url: "/c/bug/1", + title: "Site", + status: "open", + chatable: chatables[1], + last_message_sent_at: "2021-07-24T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 7, + chatable_id: 1, + chatable_type: "Category", + chatable_url: "/c/bug/1", + title: "Bug", + status: "open", + chatable: chatables[1], + last_message_sent_at: "2021-07-15T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 4, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category", + status: "open", + chatable: chatables[8], + last_message_sent_at: "2021-07-14T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 5, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (read-only)", + status: "read_only", + chatable: chatables[8], + last_message_sent_at: "2021-07-10T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 6, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (closed)", + status: "closed", + chatable: chatables[8], + last_message_sent_at: "2021-07-21T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 10, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (archived)", + status: "archived", + chatable: chatables[8], + last_message_sent_at: "2021-07-25T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 11, + chatable_id: 12, + chatable_type: "Category", + chatable_url: "/c/another-category/12", + title: "Another Category", + status: "open", + chatable: chatables[12], + last_message_sent_at: "2021-07-02T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + ], + direct_message_channels: directMessageChannels.mapBy("chat_channel"), +}; + +const message0 = { + id: 174, + message: messageContents[0], + cooked: messageContents[0], + excerpt: messageContents[0], + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + available_flags: ["spam"], +}; + +const message1 = { + id: 175, + message: messageContents[1], + cooked: messageContents[1], + excerpt: messageContents[1], + created_at: "2021-07-20T08:14:22.043Z", + flag_count: 0, + user: { + id: 2, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + in_reply_to: message0, + uploads: [ + { + extension: "pdf", + filesize: 861550, + height: null, + human_filesize: "841 KB", + id: 38, + original_filename: "Chat message PDF!", + retain_hours: null, + short_path: "/uploads/short-url/vYozObYao54I6G3x8wvOf73epfX.pdf", + short_url: "upload://vYozObYao54I6G3x8wvOf73epfX.pdf", + thumbnail_height: null, + thumbnail_width: null, + url: "/images/avatar.png", + width: null, + }, + ], + available_flags: ["spam"], +}; + +const message2 = { + id: 176, + message: messageContents[2], + cooked: messageContents[2], + excerpt: messageContents[2], + created_at: "2021-07-20T08:14:25.043Z", + flag_count: 0, + user: { + id: 2, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + in_reply_to: message0, + uploads: [ + { + extension: "png", + filesize: 50419, + height: 393, + human_filesize: "49.2 KB", + id: 37, + original_filename: "image.png", + retain_hours: null, + short_path: "/uploads/short-url/2LbadI7uOM7JsXyVoc12dHUjJYo.png", + short_url: "upload://2LbadI7uOM7JsXyVoc12dHUjJYo.png", + thumbnail_height: 224, + thumbnail_width: 689, + url: "/images/avatar.png", + width: 1209, + }, + ], + reactions: { + heart: { + count: 1, + reacted: false, + users: [{ id: 99, username: "im-penar" }], + }, + kiwi_fruit: { + count: 2, + reacted: true, + users: [{ id: 99, username: "im-penar" }], + }, + tada: { + count: 1, + reacted: true, + users: [], + }, + }, + available_flags: ["spam"], +}; + +const message3 = { + id: 177, + message: "gg @osama @mark @here", + cooked: + '

    gg @osama @mark @here

    ', + excerpt: + '

    gg @osama @mark @here

    ', + created_at: "2021-07-22T08:14:16.950Z", + flag_count: 0, + user: { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + available_flags: ["spam"], +}; + +export function generateChatView(loggedInUser, metaOverrides = {}) { + const metaDefaults = { + can_flag: true, + user_silenced: false, + can_moderate: loggedInUser.staff, + can_delete_self: true, + can_delete_others: loggedInUser.staff, + }; + return { + meta: deepMerge(metaDefaults, metaOverrides), + chat_messages: [message0, message1, message2, message3], + }; +} diff --git a/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js new file mode 100644 index 0000000000..60daa3adb9 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js @@ -0,0 +1,149 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { render, settled } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import I18n from "I18n"; + +module( + "Discourse Chat | Component | chat-channel-about-view | admin user", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.set("description", "foo"); + this.currentUser.set("admin", true); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("chatable name", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + }); + + test("chatable description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + + this.channel.set("description", null); + await settled(); + + assert.equal( + query(".channel-info-about-view__description__helper-text").innerText, + I18n.t("chat.channel_edit_description_modal.description") + ); + }); + + test("edit title", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".edit-title-btn")); + }); + + test("edit description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".edit-description-btn")); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-join")); + }); + + test("leave", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-about-view | regular user", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.set("description", "foo"); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("escapes channel title", async function (assert) { + this.channel.set("title", "
    evil
    "); + + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("chatable name", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + }); + + test("chatable description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + + this.channel.set("description", null); + await settled(); + + assert.notOk(exists(".channel-info-about-view__description")); + }); + + test("edit title", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".edit-title-btn")); + }); + + test("edit description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".edit-description-btn")); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-join")); + }); + + test("leave", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js new file mode 100644 index 0000000000..405d2c7aa7 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js @@ -0,0 +1,31 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-archive-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel({})); + }); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set("channel.title", ``); + + await render( + hbs`{{chat-channel-archive-modal-inner chatChannel=channel}}` + ); + + assert.ok( + query(".chat-channel-archive-modal-instructions").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-card-test.js b/plugins/chat/test/javascripts/components/chat-channel-card-test.js new file mode 100644 index 0000000000..15068e6a4b --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-card-test.js @@ -0,0 +1,130 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import I18n from "I18n"; + +module("Discourse Chat | Component | chat-channel-card", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel()); + this.channel.set( + "description", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + ); + }); + + test("escapes channel title", async function (assert) { + this.channel.set("title", "
    evil
    "); + + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("escapes channel description", async function (assert) { + this.channel.set("description", "
    evil
    "); + + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("Closed channel", async function (assert) { + this.channel.set("status", "closed"); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card.-closed")); + }); + + test("Archived channel", async function (assert) { + this.channel.set("status", "archived"); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card.-archived")); + }); + + test("Muted channel", async function (assert) { + this.channel.current_user_membership.set("muted", true); + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__tag.-muted").textContent.trim(), + I18n.t("chat.muted") + ); + }); + + test("Joined channel", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__tag.-joined").textContent.trim(), + I18n.t("chat.joined") + ); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + + test("Joinable channel", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card__join-btn")); + }); + + test("Memberships count", async function (assert) { + this.channel.set("memberships_count", 4); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__members").textContent.trim(), + I18n.t("chat.channel.memberships_count", { count: 4 }) + ); + }); + + test("No description", async function (assert) { + this.channel.set("description", null); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".chat-channel-card__description")); + }); + + test("Description", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__description").textContent.trim(), + this.channel.description + ); + }); + + test("Name", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__name").innerText.trim(), + this.channel.title + ); + }); + + test("Settings button", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card__setting")); + }); + + test("Read restricted chatable", async function (assert) { + this.channel.set("chatable.read_restricted", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".d-icon-lock")); + assert.equal( + query(".chat-channel-card").style.borderLeftColor, + "rgb(213, 99, 83)" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js new file mode 100644 index 0000000000..c860fd8b48 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js @@ -0,0 +1,31 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-delete-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel({})); + }); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set("channel.title", ``); + + await render( + hbs`{{chat-channel-delete-modal-inner chatChannel=channel}}` + ); + + assert.ok( + query(".chat-channel-delete-modal-instructions").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js b/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js new file mode 100644 index 0000000000..786f65b946 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js @@ -0,0 +1,81 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import pretender from "discourse/tests/helpers/create-pretender"; +import I18n from "I18n"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-leave-btn", function (hooks) { + setupRenderingTest(hooks); + + componentTest("accepts an optional onLeaveChannel callback", { + template: hbs`{{chat-channel-leave-btn channel=channel onLeaveChannel=onLeaveChannel}}`, + + beforeEach() { + this.set("foo", 1); + this.set("onLeaveChannel", () => this.set("foo", 2)); + this.set("channel", { + id: 1, + chatable_type: "DirectMessage", + chatable: { + users: [{ id: 1 }], + }, + }); + }, + + async test(assert) { + pretender.post("/chat/chat_channels/:chatChannelId/unfollow", () => { + return [200, { current_user_membership: { following: false } }, {}]; + }); + assert.equal(this.foo, 1); + + await click(".chat-channel-leave-btn"); + + assert.equal(this.foo, 2); + }, + }); + + componentTest("has a specific title for direct message channel", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.set("channel", { chatable_type: "DirectMessage" }); + }, + + async test(assert) { + const btn = query(".chat-channel-leave-btn"); + + assert.equal(btn.title, I18n.t("chat.direct_messages.leave")); + }, + }); + + componentTest("has a specific title for message channel", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.set("channel", { chatable_type: "Topic" }); + }, + + async test(assert) { + const btn = query(".chat-channel-leave-btn"); + + assert.equal(btn.title, I18n.t("chat.leave")); + }, + }); + + componentTest("is not visible on mobile", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.site.mobileView = true; + this.set("channel", { chatable_type: "Topic" }); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-leave-btn")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js new file mode 100644 index 0000000000..d1953870e1 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js @@ -0,0 +1,140 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import I18n from "I18n"; +import { Promise } from "rsvp"; +import { fillIn, triggerEvent } from "@ember/test-helpers"; +import { module } from "qunit"; + +function fetchMembersHandler(channelId, params = {}) { + if (params.offset === 50) { + return Promise.resolve([{ user: { id: 3, username: "clara" } }]); + } + + if (params.offset === 100) { + return Promise.resolve([]); + } + + if (!params.username) { + return Promise.resolve([ + { user: { id: 1, username: "jojo" } }, + { user: { id: 2, username: "bob" } }, + ]); + } + + if (params.username === "jojo") { + return Promise.resolve([{ user: { id: 1, username: "jojo" } }]); + } else { + return Promise.resolve([]); + } +} + +function setupState(context) { + context.set("fetchMembersHandler", fetchMembersHandler); + context.set("channel", fabricators.chatChannel()); + context.channel.set("memberships_count", 2); +} + +module( + "Discourse Chat | Component | chat-channel-members-view", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("no filter", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + this.set("fetchMembersHandler", fetchMembersHandler); + this.set("channel", fabricators.chatChannel()); + this.channel.set("memberships_count", 2); + }, + + async test(assert) { + assert.ok( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.ok( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("filter", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + setupState(this); + }, + + async test(assert) { + await fillIn(".channel-members-view__search-input", "jojo"); + + assert.ok( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("filter with no results", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + setupState(this); + }, + + async test(assert) { + await fillIn(".channel-members-view__search-input", "cat"); + + assert.equal( + query(".channel-members-view__list").innerText.trim(), + I18n.t("chat.channel.no_memberships_found") + ); + + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("loading more", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + this.set("fetchMembersHandler", fetchMembersHandler); + this.set("channel", fabricators.chatChannel()); + this.channel.set("memberships_count", 3); + }, + + async test(assert) { + await triggerEvent(".channel-members-view__list", "scroll"); + + ["jojo", "bob", "clara"].forEach((username) => { + assert.ok( + exists( + `.channel-members-view__list-item[data-user-card='${username}']` + ) + ); + }); + + await triggerEvent(".channel-members-view__list", "scroll"); + + ["jojo", "bob", "clara"].forEach((username) => { + assert.ok( + exists( + `.channel-members-view__list-item[data-user-card='${username}']` + ) + ); + }); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js b/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js new file mode 100644 index 0000000000..e4b693970e --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js @@ -0,0 +1,95 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import fabricators from "../helpers/fabricators"; + +module( + "Discourse Chat | Component | chat-channel-preview-card", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.setProperties({ + description: "Important stuff is announced here.", + title: "announcements", + }); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("channel title", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.equal( + query(".chat-channel-title__name").innerText, + this.channel.title, + "it shows the channel title" + ); + + assert.ok( + exists(query(".chat-channel-title__category-badge")), + "it shows the category hashtag badge" + ); + }); + + test("channel description", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.equal( + query(".chat-channel-preview-card__description").innerText, + this.channel.description, + "the channel description is shown" + ); + }); + + test("no channel description", async function (assert) { + this.channel.set("description", null); + + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.notOk( + exists(".chat-channel-preview-card__description"), + "no line is left for the channel description if there is none" + ); + + assert.ok( + exists(".chat-channel-preview-card.-no-description"), + "it adds a modifier class for styling" + ); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.ok( + exists(".toggle-channel-membership-button.-join"), + "it shows the join channel button" + ); + }); + + test("browse all", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.ok( + exists(".chat-channel-preview-card__browse-all"), + "it shows a link to browse all channels" + ); + }); + + test("closed channel", async function (assert) { + this.channel.set("status", "closed"); + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.notOk( + exists(".chat-channel-preview-card__join-channel-btn"), + "it does not show the join channel button" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-row-test.js b/plugins/chat/test/javascripts/components/chat-channel-row-test.js new file mode 100644 index 0000000000..6d3a1b37be --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-row-test.js @@ -0,0 +1,117 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-row", function (hooks) { + setupRenderingTest(hooks); + + componentTest("with leaveButton", { + template: hbs`{{chat-channel-row channel=channel options=(hash leaveButton=true)}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + current_user_membership: { following: true }, + }) + ); + }, + + async test(assert) { + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }, + }); + + componentTest("without leaveButton", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-leave-btn")); + }, + }); + + componentTest( + "a row is active when the associated channel is active and visible", + { + template: hbs`{{chat-channel-row switchChannel=switchChannel channel=channel chat=chat router=router}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + this.set("chat", { activeChannel: this.channel }); + this.set("router", { currentRouteName: "chat.channel" }); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-row.active")); + + this.set("router.currentRouteName", "chat.browse"); + + assert.notOk(exists(".chat-channel-row.active")); + + this.set("router.currentRouteName", "chat.channel"); + this.set("chat.activeChannel", null); + + assert.notOk(exists(".chat-channel-row.active")); + }, + } + ); + + componentTest("can receive a tab event", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-row[tabindex=0]")); + }, + }); + + componentTest("shows user status on the direct message channel", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + const status = { description: "Off to dentist", emoji: "tooth" }; + const channel = fabricators.directMessageChatChannel(); + channel.chatable.users[0].status = status; + this.set("channel", channel); + }, + + async test(assert) { + assert.ok(exists(".user-status-message")); + }, + }); + + componentTest( + "doesn't show user status on a direct message channel with multiple users", + { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + const status = { description: "Off to dentist", emoji: "tooth" }; + const channel = fabricators.directMessageChatChannel(); + channel.chatable.users[0].status = status; + channel.chatable.users.push({ + id: 2, + username: "bill", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/31188e/{size}.png", + }); + this.set("channel", channel); + }, + + async test(assert) { + assert.notOk(exists(".user-status-message")); + }, + } + ); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js new file mode 100644 index 0000000000..73dce053f4 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js @@ -0,0 +1,221 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +function membershipFixture(id, options = {}) { + options = Object.assign({}, options, { muted: false, following: true }); + + return { + following: options.following, + muted: options.muted, + desktop_notification_level: "mention", + mobile_notification_level: "mention", + chat_channel_id: id, + chatable_type: "Category", + user_count: 2, + }; +} + +module( + "Discourse Chat | Component | chat-channel-settings-view | Public channel - regular user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("saving desktop notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__desktop-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("saving mobile notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__mobile-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("muted", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id, { muted: true }), + ]; + } + ); + + const sk = selectKit(".channel-settings-view__muted-selector"); + await sk.expand(); + await sk.selectRowByName("Off"); + + assert.equal(sk.header().value(), "false"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-settings-view | Direct Message channel - regular user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("saving desktop notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__desktop-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("saving mobile notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__mobile-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("muted", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id, { muted: true }), + ]; + } + ); + + const sk = selectKit(".channel-settings-view__muted-selector"); + await sk.expand(); + await sk.selectRowByName("Off"); + + assert.equal(sk.header().value(), "false"); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-title-test.js b/plugins/chat/test/javascripts/components/chat-channel-title-test.js new file mode 100644 index 0000000000..29f5526c76 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-title-test.js @@ -0,0 +1,173 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-title", function (hooks) { + setupRenderingTest(hooks); + + componentTest("category channel", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-channel-title__category-badge").getAttribute("style"), + `color: #${this.channel.chatable.color}` + ); + assert.equal( + query(".chat-channel-title__name").innerText, + this.channel.title + ); + }, + }); + + componentTest("category channel - escapes title", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + title: "
    evil
    ", + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".xss")); + }, + }); + + componentTest("category channel - read restricted", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + chatable: { read_restricted: true }, + }) + ); + }, + + async test(assert) { + assert.ok(exists(".d-icon-lock")); + }, + }); + + componentTest("category channel - not read restricted", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + chatable: { read_restricted: false }, + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".d-icon-lock")); + }, + }); + + componentTest("direct message channel - one user", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.directMessageChatChannel()); + }, + + async test(assert) { + const user = this.channel.chatable.users[0]; + + assert.ok( + exists(`.chat-user-avatar-container .avatar[title="${user.username}"]`) + ); + + assert.equal( + query(".chat-channel-title__name").innerText.trim(), + user.username + ); + }, + }); + + componentTest("direct message channel - multiple users", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + const channel = fabricators.directMessageChatChannel(); + + channel.chatable.users.push({ + id: 2, + username: "joffrey", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/31188e/{size}.png", + }); + + this.set("channel", channel); + }, + + async test(assert) { + const users = this.channel.chatable.users; + + assert.equal( + parseInt( + query(".chat-channel-title__users-count").innerText.trim(), + 10 + ), + users.length + ); + + assert.equal( + query(".chat-channel-title__name").innerText.trim(), + users.mapBy("username").join(", ") + ); + }, + }); + + componentTest("unreadIndicator", { + template: hbs`{{chat-channel-title channel=channel unreadIndicator=unreadIndicator}}`, + + beforeEach() { + const channel = fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }); + + const state = {}; + state[channel.id] = { + unread_count: 1, + }; + this.currentUser.set("chat_channel_tracking_state", state); + + this.set("channel", channel); + }, + + async test(assert) { + this.set("unreadIndicator", true); + + assert.ok(exists(".chat-channel-unread-indicator")); + + this.set("unreadIndicator", false); + + assert.notOk(exists(".chat-channel-unread-indicator")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js new file mode 100644 index 0000000000..426e0b3d18 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js @@ -0,0 +1,104 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { click } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import I18n from "I18n"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-toggle-view | closed channel", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("texts", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "closed" })); + }, + + async test(assert) { + assert.equal( + query("#chat-channel-toggle").innerText.trim(), + I18n.t("chat.channel_open.instructions") + ); + assert.equal( + query("#chat-channel-toggle-btn").innerText.trim(), + I18n.t("chat.channel_settings.open_channel") + ); + }, + }); + + componentTest("action", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "closed" })); + }, + + async test(assert) { + pretender.put( + `/chat/chat_channels/${this.channel.id}/change_status.json`, + () => { + return [200, { "Content-Type": "application/json" }, {}]; + } + ); + + await click("#chat-channel-toggle-btn"); + + assert.equal(this.channel.isClosed, false); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-toggle-view | opened channel", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("texts", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "open" })); + }, + + async test(assert) { + assert.equal( + query("#chat-channel-toggle").innerText.trim(), + I18n.t("chat.channel_close.instructions") + ); + assert.equal( + query("#chat-channel-toggle-btn").innerText.trim(), + I18n.t("chat.channel_settings.close_channel") + ); + }, + }); + + componentTest("action", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "open" })); + }, + + async test(assert) { + pretender.put( + `/chat/chat_channels/${this.channel.id}/change_status.json`, + () => { + return [200, { "Content-Type": "application/json" }, {}]; + } + ); + + await click("#chat-channel-toggle-btn"); + + assert.equal(this.channel.isClosed, true); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js b/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js new file mode 100644 index 0000000000..718757683c --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js @@ -0,0 +1,91 @@ +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +const directMessageChannel = { + id: 1, + chatable_type: CHATABLE_TYPES.directMessageChannel, + chatable: { + users: [{ id: 1 }], + }, +}; + +const topicChannel = { + id: 2, + chatable_type: CHATABLE_TYPES.topicChannel, + chatable: { + users: [{ id: 1 }], + }, +}; + +module( + "Discourse Chat | Component | chat-channel-unread-indicator", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("has no unread", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + unread_count: 0, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-unread-indicator")); + }, + }); + + componentTest("has unread and no mentions", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [topicChannel.id]: { unread_count: 1 }, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator:not(.urgent)")); + }, + }); + + componentTest("has unread and mentions", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [topicChannel.id]: { unread_count: 1, unread_mentions: 1 }, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator.urgent")); + }, + }); + + componentTest("direct message channel | has unread", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [directMessageChannel.id]: { unread_count: 1 }, + }); + this.set("channel", directMessageChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator.urgent")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js b/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js new file mode 100644 index 0000000000..4a4ba9656d --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js @@ -0,0 +1,28 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { click } from "@ember/test-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-composer-dropdown", function (hooks) { + setupRenderingTest(hooks); + + componentTest("buttons", { + template: hbs`{{chat-composer-dropdown buttons=buttons}}`, + + async beforeEach() { + this.set("buttons", [{ id: "foo", icon: "times", action: () => {} }]); + }, + + async test(assert) { + await click(".chat-composer-dropdown__trigger-btn"); + + assert.ok(exists(".chat-composer-dropdown__item.foo")); + assert.ok( + exists(".chat-composer-dropdown__action-btn.foo .d-icon-times") + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js b/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js new file mode 100644 index 0000000000..732a0212ea --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js @@ -0,0 +1,26 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-composer-inline-buttons", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("buttons", { + template: hbs`{{chat-composer-inline-buttons buttons=buttons}}`, + + async beforeEach() { + this.set("buttons", [{ id: "foo", icon: "times", action: () => {} }]); + }, + + async test(assert) { + assert.ok(exists(".chat-composer-inline-button.foo")); + assert.ok(exists(".chat-composer-inline-button.foo .d-icon-times")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js new file mode 100644 index 0000000000..76a3098ca4 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js @@ -0,0 +1,87 @@ +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-composer placeholder", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("direct message to self shows Jot something down", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + set(this.currentUser, "id", 1); + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "DirectMessage", + chatable: { + users: [{ id: 1 }], + }, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Jot something down" + ); + }, + }); + + componentTest("direct message to multiple folks shows their names", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "DirectMessage", + chatable: { + users: [ + { name: "Tomtom" }, + { name: "Steaky" }, + { username: "zorro" }, + ], + }, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Chat with Tomtom, Steaky, @zorro" + ); + }, + }); + + componentTest("message to channel shows send message to channel name", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "Category", + title: "just-cats", + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Chat with #just-cats" + ); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js new file mode 100644 index 0000000000..c2822af7da --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js @@ -0,0 +1,158 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { click } from "@ember/test-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-composer-upload", function (hooks) { + setupRenderingTest(hooks); + + componentTest("file - uploading in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + progress: 50, + extension: ".pdf", + fileName: "test.pdf", + }); + }, + + async test(assert) { + assert.ok(exists(".upload-progress[value=50]")); + assert.strictEqual( + query(".uploading").innerText.trim(), + I18n.t("uploading") + ); + }, + }); + + componentTest("image - uploading in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + extension: ".png", + progress: 78, + fileName: "test.png", + }); + }, + + async test(assert) { + assert.ok(exists(".d-icon-far-image")); + assert.ok(exists(".upload-progress[value=78]")); + assert.strictEqual( + query(".uploading").innerText.trim(), + I18n.t("uploading") + ); + }, + }); + + componentTest("image - preprocessing upload in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + extension: ".png", + progress: 78, + fileName: "test.png", + processing: true, + }); + }, + + async test(assert) { + assert.strictEqual( + query(".processing").innerText.trim(), + I18n.t("processing") + ); + }, + }); + + componentTest("file - upload complete", { + template: hbs`{{chat-composer-upload isDone=true upload=upload}}`, + + beforeEach() { + this.set("upload", { + type: ".pdf", + original_filename: "some file.pdf", + extension: "pdf", + }); + }, + + async test(assert) { + assert.ok(exists(".d-icon-file-alt")); + assert.strictEqual(query(".file-name").innerText.trim(), "some file.pdf"); + assert.strictEqual(query(".extension-pill").innerText.trim(), "pdf"); + }, + }); + + componentTest("image - upload complete", { + template: hbs`{{chat-composer-upload isDone=true upload=upload}}`, + + beforeEach() { + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + assert.ok(exists("img.preview-img[src='/images/avatar.png']")); + assert.strictEqual(query(".file-name").innerText.trim(), "bar_image.png"); + assert.strictEqual(query(".extension-pill").innerText.trim(), "png"); + }, + }); + + componentTest("removing completed upload", { + template: hbs`{{chat-composer-upload isDone=true upload=upload onCancel=(action "removeUpload" upload)}}`, + + beforeEach() { + this.set("uploadRemoved", false); + this.set("actions", { + removeUpload: () => { + this.set("uploadRemoved", true); + }, + }); + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + await click(".remove-upload"); + assert.strictEqual(this.uploadRemoved, true); + }, + }); + + componentTest("cancelling in progress upload", { + template: hbs`{{chat-composer-upload upload=upload onCancel=(action "removeUpload" upload)}}`, + + beforeEach() { + this.set("uploadRemoved", false); + this.set("actions", { + removeUpload: () => { + this.set("uploadRemoved", true); + }, + }); + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + await click(".remove-upload"); + assert.strictEqual(this.uploadRemoved, true); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js new file mode 100644 index 0000000000..1cfceea4bc --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js @@ -0,0 +1,163 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { + count, + createFile, + exists, +} from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { click, settled, waitFor } from "@ember/test-helpers"; +import { module } from "qunit"; +import { run } from "@ember/runloop"; + +const fakeUpload = { + type: ".png", + extension: "png", + name: "myfile.png", + short_path: "/images/avatar.png", +}; + +const mockUploadResponse = { + extension: "jpeg", + filesize: 126177, + height: 800, + human_filesize: "123 KB", + id: 202, + original_filename: "avatar.PNG.jpg", + retain_hours: null, + short_path: "/images/avatar.png", + short_url: "upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + thumbnail_height: 320, + thumbnail_width: 690, + url: "/images/avatar.png", + width: 1920, +}; + +function setupUploadPretender() { + pretender.post( + "/uploads.json", + () => { + return [200, { "Content-Type": "application/json" }, mockUploadResponse]; + }, + 500 // this delay is important to slow down the uploads a bit so we can click elements in the UI like the cancel button + ); +} + +module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { + setupRenderingTest(hooks); + + componentTest( + "loading uploads from an outside source (e.g. draft or editing message)", + { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader"}}`, + + async test(assert) { + this.appEvents = this.container.lookup("service:appEvents"); + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]); + await settled(); + + assert.strictEqual(count(".chat-composer-upload"), 1); + assert.strictEqual(exists(".chat-composer-upload"), true); + }, + } + ); + + componentTest("upload starts and completes", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + setupUploadPretender(); + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + const done = assert.async(); + this.appEvents = this.container.lookup("service:appEvents"); + this.appEvents.on( + "upload-mixin:chat-composer-uploader:upload-success", + (fileName, upload) => { + assert.strictEqual(fileName, "avatar.png"); + assert.deepEqual(upload, mockUploadResponse); + done(); + } + ); + + this.appEvents.trigger( + "upload-mixin:chat-composer-uploader:add-files", + createFile("avatar.png") + ); + + await waitFor(".chat-composer-upload"); + assert.strictEqual(count(".chat-composer-upload"), 1); + }, + }); + + componentTest("removing a completed upload", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + this.appEvents = this.container.lookup("service:appEvents"); + run(() => + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]) + ); + assert.strictEqual(count(".chat-composer-upload"), 1); + + await click(".remove-upload"); + assert.strictEqual(count(".chat-composer-upload"), 0); + }, + }); + + componentTest("cancelling in progress upload", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + setupUploadPretender(); + + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + const image = createFile("avatar.png"); + const done = assert.async(); + this.appEvents = this.container.lookup("service:appEvents"); + + this.appEvents.on( + `upload-mixin:chat-composer-uploader:upload-cancelled`, + (fileId) => { + assert.strictEqual( + fileId.includes("uppy-avatar/"), + true, + "upload was cancelled" + ); + done(); + } + ); + + this.appEvents.trigger( + "upload-mixin:chat-composer-uploader:add-files", + image + ); + + await waitFor(".chat-composer-upload"); + assert.strictEqual(count(".chat-composer-upload"), 1); + + await click(".remove-upload"); + assert.strictEqual(count(".chat-composer-upload"), 0); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js b/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js new file mode 100644 index 0000000000..20b4e54016 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js @@ -0,0 +1,26 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-emoji-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("uses an emoji as avatar", { + template: hbs`{{chat-emoji-avatar emoji=emoji}}`, + + async beforeEach() { + this.set("emoji", ":otter:"); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-emoji-avatar .chat-emoji-avatar-container .emoji[title=otter]` + ) + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js new file mode 100644 index 0000000000..ff22704010 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js @@ -0,0 +1,243 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { click, fillIn, render } from "@ember/test-helpers"; + +function emojisResponse() { + return { + favorites: [ + { + name: "grinning", + tonable: false, + url: "/images/emoji/twitter/grinning.png?v=12", + group: "smileys_\u0026_emotion", + search_aliases: ["smiley_cat", "star_struck"], + }, + ], + "smileys_&_emotion": [ + { + name: "grinning", + tonable: false, + url: "/images/emoji/twitter/grinning.png?v=12", + group: "smileys_\u0026_emotion", + search_aliases: ["smiley_cat", "star_struck"], + }, + ], + "people_&_body": [ + { + name: "raised_hands", + tonable: true, + url: "/images/emoji/twitter/raised_hands.png?v=12", + group: "people_&_body", + search_aliases: [], + }, + { + name: "man_rowing_boat", + tonable: true, + url: "/images/emoji/twitter/man_rowing_boat.png?v=12", + group: "people_&_body", + search_aliases: [], + }, + ], + objects: [ + { + name: "womans_clothes", + tonable: false, + url: "/images/emoji/twitter/womans_clothes.png?v=12", + group: "objects", + search_aliases: [], + }, + ], + }; +} + +module("Discourse Chat | Component | chat-emoji-picker", function (hooks) { + setupRenderingTest(hooks); + + hooks.afterEach(function () { + this.emojiReactionStore.diversity = 1; + }); + + hooks.beforeEach(function () { + pretender.get("/chat/emojis.json", () => { + return [200, {}, emojisResponse()]; + }); + + this.chatEmojiPickerManager = this.container.lookup( + "service:chat-emoji-picker-manager" + ); + this.chatEmojiPickerManager.startFromComposer(() => {}); + this.chatEmojiPickerManager.addVisibleSections([ + "smileys_&_emotion", + "people_&_body", + "objects", + ]); + + this.emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + }); + + test("When displaying navigation", async function (assert) { + await render(hbs``); + + assert.ok( + exists( + `.chat-emoji-picker__section-btn.active[data-section="favorites"]` + ), + "it renders first section as active" + ); + assert.ok( + exists( + `.chat-emoji-picker__section-btn[data-section="smileys_&_emotion"]` + ) + ); + assert.ok( + exists(`.chat-emoji-picker__section-btn[data-section="people_&_body"]`) + ); + assert.ok( + exists(`.chat-emoji-picker__section-btn[data-section="objects"]`) + ); + }); + + test("When changing tone scale", async function (assert) { + await render(hbs``); + await click(".chat-emoji-picker__fitzpatrick-modifier-btn.current.t1"); + await click(".chat-emoji-picker__fitzpatrick-modifier-btn.t6"); + + assert.ok( + exists(`img[src="/images/emoji/twitter/raised_hands/6.png"]`), + "it applies the tone to emojis" + ); + assert.ok( + exists(".chat-emoji-picker__fitzpatrick-modifier-btn.current.t6"), + "it changes the current scale to t6" + ); + }); + + test("When requesting section", async function (assert) { + await render(hbs``); + + assert.strictEqual( + document.querySelector("#ember-testing-container").scrollTop, + 0 + ); + + await click(`.chat-emoji-picker__section-btn[data-section="objects"]`); + + assert.ok( + document.querySelector("#ember-testing-container").scrollTop > 0, + "it scrolls to the section" + ); + }); + + test("When filtering emojis", async function (assert) { + await render(hbs``); + await fillIn(".dc-filter-input", "grinning"); + + assert.strictEqual( + queryAll(".chat-emoji-picker__sections > img").length, + 1, + "it filters the emojis list" + ); + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it filters the correct emoji" + ); + + await fillIn(".dc-filter-input", "Grinning"); + + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it is case insensitive" + ); + + await fillIn(".dc-filter-input", "smiley_cat"); + + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it filters the correct emoji using search alias" + ); + }); + + test("When selecting an emoji", async function (assert) { + let selection; + this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + selection = emoji; + }; + await render(hbs``); + await click('img.emoji[data-emoji="grinning"]'); + + assert.strictEqual(selection, "grinning"); + }); + + test("When selecting a toned an emoji", async function (assert) { + let selection; + this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + selection = emoji; + }; + await render(hbs``); + this.emojiReactionStore.diversity = 1; + await click('img.emoji[data-emoji="man_rowing_boat"]'); + + assert.strictEqual(selection, "man_rowing_boat"); + + this.emojiReactionStore.diversity = 2; + await click('img.emoji[data-emoji="man_rowing_boat"]'); + + assert.strictEqual(selection, "man_rowing_boat:t2"); + }); + + test("When opening the picker", async function (assert) { + await render(hbs``); + + assert.ok(document.activeElement.classList.contains("dc-filter-input")); + }); + + test("When hovering an emoji", async function (assert) { + await render(hbs``); + + assert.strictEqual( + query( + '.chat-emoji-picker__section[data-section="people_&_body"] img.emoji:nth-child(1)' + ).title, + ":raised_hands:", + "first emoji has a title" + ); + + assert.strictEqual( + query( + '.chat-emoji-picker__section[data-section="people_&_body"] img.emoji:nth-child(2)' + ).title, + ":man_rowing_boat:", + "second emoji has a title" + ); + + await fillIn(".dc-filter-input", "grinning"); + assert.strictEqual( + query('img.emoji[data-emoji="grinning"]').title, + ":grinning:", + "filtered emoji have a title" + ); + + this.emojiReactionStore.diversity = 1; + await render(hbs``); + + assert.strictEqual( + query('img.emoji[data-emoji="man_rowing_boat"]').title, + ":man_rowing_boat:", + "it has a title without the scale as diversity value is 1" + ); + + this.emojiReactionStore.diversity = 2; + await render(hbs``); + + assert.strictEqual( + query('img.emoji[data-emoji="man_rowing_boat"]').title, + ":man_rowing_boat:t2:", + "it has a title with the scale" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-live-pane-test.js b/plugins/chat/test/javascripts/components/chat-live-pane-test.js new file mode 100644 index 0000000000..a02292651a --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-live-pane-test.js @@ -0,0 +1,44 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import fabricators from "../helpers/fabricators"; +import { render } from "@ember/test-helpers"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import MockPresenceChannel from "../helpers/mock-presence-channel"; + +function mockChat(context) { + const mock = context.container.lookup("service:chat"); + mock.draftStore = {}; + mock.currentUser = context.currentUser; + mock.presenceChannel = MockPresenceChannel.create(); + return mock; +} + +module("Discourse Chat | Component | chat-live-pane", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("chat", mockChat(this)); + this.set("channel", fabricators.chatChannel()); + }); + + test("Shows skeleton when loading", async function (assert) { + pretender.get(`/chat/chat_channels.json`, () => response(this.channel)); + pretender.get(`/chat/:id/messages.json`, () => + response({ chat_messages: [], meta: { can_delete_self: true } }) + ); + + await render( + hbs`{{chat-live-pane loadingMorePast=true chat=chat chatChannel=channel}}` + ); + + assert.ok(exists(".chat-skeleton")); + + await render( + hbs`{{chat-live-pane loadingMoreFuture=true chat=chat chatChannel=channel}}` + ); + + assert.ok(exists(".chat-skeleton")); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-avatar-test.js b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js new file mode 100644 index 0000000000..04430313b2 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js @@ -0,0 +1,34 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("chat_webhook_event", { + template: hbs`{{chat-message-avatar message=message}}`, + + beforeEach() { + this.set("message", { chat_webhook_event: { emoji: ":heart:" } }); + }, + + async test(assert) { + assert.equal(query(".chat-emoji-avatar .emoji").title, "heart"); + }, + }); + + componentTest("user", { + template: hbs`{{chat-message-avatar message=message}}`, + + beforeEach() { + this.set("message", { user: { username: "discobot" } }); + }, + + async test(assert) { + assert.ok(exists('.chat-user-avatar [data-user-card="discobot"]')); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-collapser-test.js b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js new file mode 100644 index 0000000000..cb2be4c008 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js @@ -0,0 +1,680 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click, render } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { + query, + queryAll, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { module, test } from "qunit"; + +const youtubeCooked = + "

    written text

    " + + '
    Vid 1
    ' + + "

    more written text

    " + + '
    Vid 2
    ' + + "

    and even more

    "; + +const animatedImageCooked = + "

    written text

    " + + '

    ' + + "

    more written text

    " + + '

    ' + + "

    and even more

    "; + +const externalImageCooked = + "

    written text

    " + + '

    ' + + "

    more written text

    " + + '

    ' + + "

    and even more

    "; + +const imageCooked = + "

    written text

    " + + '

    shows alt

    ' + + "

    more written text

    " + + '

    ' + + "

    and even more

    " + + '

    '; + +const galleryCooked = + "

    written text

    " + + '" + + "

    more written text

    "; + +const evilString = ""; +const evilStringEscaped = "<script>someeviltitle</script>"; + +module("Discourse Chat | Component | chat message collapser", function (hooks) { + setupRenderingTest(hooks); + + test("escapes uploads header", async function (assert) { + this.set("uploads", [{ original_filename: evilString }]); + await render(hbs`{{chat-message-collapser uploads=uploads}}`); + + assert.ok( + query(".chat-message-collapser-link-small").innerHTML.includes( + evilStringEscaped + ) + ); + }); +}); + +module( + "Discourse Chat | Component | chat message collapser youtube", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes youtube header", async function (assert) { + this.set("cooked", youtubeCooked.replace("ytId1", evilString)); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + query(".chat-message-collapser-link").href.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + }); + + componentTest("shows youtube link in header", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const link = document.querySelectorAll(".chat-message-collapser-link"); + + assert.equal(link.length, 2, "two youtube links rendered"); + assert.strictEqual( + link[0].href, + "https://www.youtube.com/watch?v=ytId1" + ); + assert.strictEqual( + link[1].href, + "https://www.youtube.com/watch?v=ytId2" + ); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + youtubeCooked.youtubeid; + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 3, "shows all written text"); + assert.strictEqual( + text[0].innerText, + "written text", + "first line of written text" + ); + assert.strictEqual( + text[1].innerText, + "more written text", + "third line of written text" + ); + assert.strictEqual( + text[2].innerText, + "and even more", + "fifth line of written text" + ); + }, + }); + + componentTest("collapses and expands cooked youtube", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const youtubeDivs = document.querySelectorAll(".onebox"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[data-youtube-id='ytId1']"), + "first youtube preview hidden" + ); + assert.ok( + visible(".onebox[data-youtube-id='ytId2']"), + "second youtube preview still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[data-youtube-id='ytId1']"), + "first youtube preview still visible" + ); + assert.notOk( + visible(".onebox[data-youtube-id='ytId2']"), + "second youtube preview hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser images", + function (hooks) { + setupRenderingTest(hooks); + const imageTextCooked = "

    A picture of Tomtom

    "; + + componentTest("shows filename for one image", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{ original_filename: "tomtom.jpeg" }]); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "tomtom.jpeg" + ) + ); + }, + }); + + componentTest("shows number of files for multiple images", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{}, {}]); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "2 files" + ) + ); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{ original_filename: "tomtom.png" }]); + }, + + async test(assert) { + const uploads = ".chat-uploads"; + const chatImageUpload = ".chat-img-upload"; + + assert.ok(visible(uploads)); + assert.ok(visible(chatImageUpload)); + + await click(".chat-message-collapser-opened"); + + assert.notOk(visible(uploads)); + assert.notOk(visible(chatImageUpload)); + + await click(".chat-message-collapser-closed"); + + assert.ok(visible(uploads)); + assert.ok(visible(chatImageUpload)); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser animated image", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("shows links for animated image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("avatar.png")); + assert.ok(links[0].href.includes("avatar.png")); + + assert.ok( + links[1].innerText.trim().includes("d-logo-sketch-small.png") + ); + assert.ok(links[1].href.includes("d-logo-sketch-small.png")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 5, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands animated image onebox", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const animatedOneboxes = document.querySelectorAll(".animated.onebox"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[src='/images/avatar.png']"), + "first onebox hidden" + ); + assert.ok( + visible(".onebox[src='/images/d-logo-sketch-small.png']"), + "second onebox still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[src='/images/avatar.png']"), + "first onebox still visible" + ); + assert.notOk( + visible(".onebox[src='/images/d-logo-sketch-small.png']"), + "second onebox hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser external image onebox", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("shows links for animated image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("http://cat1.com")); + assert.ok(links[0].href.includes("http://cat1.com")); + + assert.ok(links[1].innerText.trim().includes("http://cat2.com")); + assert.ok(links[1].href.includes("http://cat2.com")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 5, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands image oneboxes", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const imageOneboxes = document.querySelectorAll(".onebox"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[href='http://cat1.com']"), + "first onebox hidden" + ); + assert.ok( + visible(".onebox[href='http://cat2.com']"), + "second onebox still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[href='http://cat1.com']"), + "first onebox still visible" + ); + assert.notOk( + visible(".onebox[href='http://cat2.com']"), + "second onebox hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser images", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes link", async function (assert) { + this.set( + "cooked", + imageCooked + .replace("shows alt", evilString) + .replace("/images/d-logo-sketch-small.png", evilString) + ); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + queryAll(".chat-message-collapser-link-small")[0].innerHTML.includes( + evilStringEscaped + ) + ); + assert.ok( + queryAll(".chat-message-collapser-link-small")[1].innerHTML.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + }); + + componentTest("shows alt or links (if no alt) for linked image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("shows alt")); + assert.ok(links[0].href.includes("/images/avatar.png")); + + assert.ok( + links[1].innerText.trim().includes("/images/d-logo-sketch-small.png") + ); + assert.ok(links[1].href.includes("/images/d-logo-sketch-small.png")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 6, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const images = document.querySelectorAll("img"); + + assert.equal(images.length, 3); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible("img[src='/images/avatar.png']"), + "first image hidden" + ); + assert.ok( + visible("img[src='/images/d-logo-sketch-small.png']"), + "second image still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(images.length, 3); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible("img[src='/images/avatar.png']"), + "first image still visible" + ); + assert.notOk( + visible("img[src='/images/d-logo-sketch-small.png']"), + "second image hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(images.length, 3); + }, + }); + + componentTest("does not show collapser for emoji images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + const images = document.querySelectorAll("img"); + const collapser = document.querySelectorAll( + ".chat-message-collapser-opened" + ); + + assert.equal(links.length, 2); + assert.equal(images.length, 3, "shows images and emoji"); + assert.equal(collapser.length, 2); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser galleries", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes title/link", async function (assert) { + this.set( + "cooked", + galleryCooked + .replace("https://imgur.com/gallery/yyVx5lJ", evilString) + .replace("Le tomtom album", evilString) + ); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + query(".chat-message-collapser-link-small").href.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + assert.strictEqual( + query(".chat-message-collapser-link-small").innerHTML.trim(), + "someeviltitle" + ); + }); + + componentTest("removes album title overlay", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.notOk(visible(".album-title"), "album title removed"); + }, + }); + + componentTest("shows gallery link", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "Le tomtom album" + ) + ); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 2, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[1].innerText, "more written text"); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.ok(visible("img"), "image visible initially"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close preview" + ); + + assert.notOk(visible("img"), "image hidden"); + + await click(".chat-message-collapser-closed"); + + assert.ok(visible("img"), "image visible initially"); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-info-test.js b/plugins/chat/test/javascripts/components/chat-message-info-test.js new file mode 100644 index 0000000000..ffe946fda1 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-info-test.js @@ -0,0 +1,140 @@ +import Bookmark from "discourse/models/bookmark"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { module } from "qunit"; +import User from "discourse/models/user"; + +module("Discourse Chat | Component | chat-message-info", function (hooks) { + setupRenderingTest(hooks); + + componentTest("chat_webhook_event", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { chat_webhook_event: { username: "discobot" } }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__username").innerText.trim(), + this.message.chat_webhook_event.username + ); + assert.equal( + query(".chat-message-info__bot-indicator").textContent.trim(), + I18n.t("chat.bot") + ); + }, + }); + + componentTest("user", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { user: { username: "discobot" } }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__username").innerText.trim(), + this.message.user.username + ); + }, + }); + + componentTest("date", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + created_at: moment(), + }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__date")); + }, + }); + + componentTest("bookmark (with reminder)", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + reminder_at: moment(), + name: "some name", + }), + }); + }, + + async test(assert) { + assert.ok( + exists(".chat-message-info__bookmark .d-icon-discourse-bookmark-clock") + ); + }, + }); + + componentTest("bookmark (no reminder)", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + name: "some name", + }), + }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__bookmark .d-icon-bookmark")); + }, + }); + + componentTest("user status", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + const status = { description: "off to dentist", emoji: "tooth" }; + this.set("message", { user: User.create({ status }) }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__status .user-status-message")); + }, + }); + + componentTest("reviewable", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + user_flag_status: 0, + }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__flag > .svg-icon-title").title, + I18n.t("chat.you_flagged") + ); + + this.set("message", { + user: { username: "discobot" }, + reviewable_id: 1, + }); + + assert.equal( + query(".chat-message-info__flag a .svg-icon-title").title, + I18n.t("chat.flagged") + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js new file mode 100644 index 0000000000..5763df82a8 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js @@ -0,0 +1,32 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-message-move-to-channel-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set( + "channel", + fabricators.chatChannel({ title: "" }) + ); + this.set("chat", { publicChannels: [this.channel] }); + this.set("selectedMessageIds", [1]); + + await render( + hbs`{{chat-message-move-to-channel-modal-inner selectedMessageIds=selectedMessageIds sourceChannel=channel chat=chat}}` + ); + + assert.ok( + query(".chat-message-move-to-channel-modal-inner").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js new file mode 100644 index 0000000000..9b1dd1af53 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js @@ -0,0 +1,104 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-reaction", function (hooks) { + setupRenderingTest(hooks); + + componentTest("accepts arbitrary class property", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart") class="foo"}}`, + + async test(assert) { + assert.ok(exists(".chat-message-reaction.foo")); + }, + }); + + componentTest("adds reacted class when user reacted", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" reacted=true)}}`, + + async test(assert) { + assert.ok(exists(".chat-message-reaction.reacted")); + }, + }); + + componentTest("adds reaction name as class", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + assert.ok(exists(`.chat-message-reaction[data-emoji-name="heart"]`)); + }, + }); + + componentTest("adds show class when count is positive", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" count=this.count)}}`, + + beforeEach() { + this.set("count", 0); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction.show")); + + this.set("count", 1); + + assert.ok(exists(".chat-message-reaction.show")); + }, + }); + + componentTest("title/alt attributes", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + assert.equal(query(".chat-message-reaction").title, ":heart:"); + assert.equal(query(".chat-message-reaction img").alt, ":heart:"); + }, + }); + + componentTest("count of reactions", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" count=this.count)}}`, + + beforeEach() { + this.set("count", 0); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction .count")); + + this.set("count", 2); + + assert.equal(query(".chat-message-reaction .count").innerText, "2"); + }, + }); + + componentTest("reaction’s image", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + const src = query(".chat-message-reaction img").src; + assert.ok(/heart\.png/.test(src)); + }, + }); + + componentTest("click action", { + template: hbs`{{chat-message-reaction class="show" reaction=(hash emoji="heart" count=this.count) react=this.react}}`, + + beforeEach() { + this.set("count", 0); + this.set("react", () => { + this.set("count", 1); + }); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction .count")); + + await click(".chat-message-reaction"); + + assert.equal(query(".chat-message-reaction .count").innerText, "1"); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-separator-test.js b/plugins/chat/test/javascripts/components/chat-message-separator-test.js new file mode 100644 index 0000000000..6282130718 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-separator-test.js @@ -0,0 +1,44 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-separator", function (hooks) { + setupRenderingTest(hooks); + + componentTest("newest message", { + template: hbs`{{chat-message-separator message=message}}`, + + async beforeEach() { + this.set("message", { newestMessage: true }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-separator.new-message .text").innerText.trim(), + I18n.t("chat.new_messages") + ); + }, + }); + + componentTest("first message of the day", { + template: hbs`{{chat-message-separator message=message}}`, + + async beforeEach() { + this.set("date", moment().format("LLL")); + this.set("message", { firstMessageOfTheDayAt: this.date }); + }, + + async test(assert) { + assert.equal( + query( + ".chat-message-separator.first-daily-message .text" + ).innerText.trim(), + this.date + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-test.js b/plugins/chat/test/javascripts/components/chat-message-test.js new file mode 100644 index 0000000000..a937d66828 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-test.js @@ -0,0 +1,117 @@ +import User from "discourse/models/user"; +import { render, waitFor } from "@ember/test-helpers"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | chat-message", function (hooks) { + setupRenderingTest(hooks); + + function generateMessageProps(messageData = {}) { + const chatChannel = ChatChannel.create({ + chatable: { id: 1 }, + chatable_type: "Category", + id: 9, + title: "Site", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 0, + muted: false, + }, + }); + return { + message: ChatMessage.create( + Object.assign( + { + id: 178, + message: "from deleted user", + cooked: "

    from deleted user

    ", + excerpt: "

    from deleted user

    ", + created_at: "2021-07-22T08:14:16.950Z", + flag_count: 0, + user: User.create({ username: "someguy", id: 1424 }), + edited: false, + }, + messageData + ) + ), + canInteractWithChat: true, + details: { + can_delete_self: true, + can_delete_others: true, + can_flag: true, + user_silenced: false, + can_moderate: true, + }, + chatChannel, + setReplyTo: () => {}, + replyMessageClicked: () => {}, + editButtonClicked: () => {}, + afterExpand: () => {}, + selectingMessages: false, + onStartSelectingMessages: () => {}, + onSelectMessage: () => {}, + bulkSelectMessages: () => {}, + afterReactionAdded: () => {}, + onHoverMessage: () => {}, + }; + } + + const template = hbs`{{chat-message + message=message + canInteractWithChat=canInteractWithChat + details=this.details + chatChannel=chatChannel + setReplyTo=setReplyTo + replyMessageClicked=replyMessageClicked + editButtonClicked=editButtonClicked + selectingMessages=selectingMessages + onStartSelectingMessages=onStartSelectingMessages + onSelectMessage=onSelectMessage + bulkSelectMessages=bulkSelectMessages + onHoverMessage=onHoverMessage + afterReactionAdded=reStickScrollIfNeeded + }}`; + + test("Message with edits", async function (assert) { + this.setProperties(generateMessageProps({ edited: true })); + await render(template); + assert.ok( + exists(".chat-message-edited"), + "has the correct edited css class" + ); + }); + + test("Deleted message", async function (assert) { + this.setProperties(generateMessageProps({ deleted_at: moment() })); + await render(template); + assert.ok( + exists(".chat-message-deleted .chat-message-expand"), + "has the correct deleted css class and expand button within" + ); + }); + + test("Hidden message", async function (assert) { + this.setProperties(generateMessageProps({ hidden: true })); + await render(template); + assert.ok( + exists(".chat-message-hidden .chat-message-expand"), + "has the correct hidden css class and expand button within" + ); + }); + + test("Message marked as visible", async function (assert) { + this.setProperties(generateMessageProps()); + + await render(template); + await waitFor("div[data-visible=true]"); + + assert.ok( + exists(".chat-message-container[data-visible=true]"), + "message is marked as visible" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-text-test.js b/plugins/chat/test/javascripts/components/chat-message-text-test.js new file mode 100644 index 0000000000..938cb5bc76 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-text-test.js @@ -0,0 +1,76 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-text", function (hooks) { + setupRenderingTest(hooks); + + componentTest("yields", { + template: hbs`{{#chat-message-text cooked=cooked uploads=uploads}}
    {{/chat-message-text}}`, + + beforeEach() { + this.set("cooked", "

    "); + }, + + async test(assert) { + assert.ok(exists(".yield-me")); + }, + }); + + componentTest("shows collapsed", { + template: hbs`{{chat-message-text cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set( + "cooked", + '
    ' + ); + }, + + async test(assert) { + assert.ok(exists(".chat-message-collapser")); + }, + }); + + componentTest("does not collapse a non-image onebox", { + template: hbs`{{chat-message-text cooked=cooked}}`, + + beforeEach() { + this.set( + "cooked", + '

    ' + ); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-collapser")); + }, + }); + + componentTest("shows edits - regular message", { + template: hbs`{{chat-message-text cooked=cooked edited=true}}`, + + beforeEach() { + this.set("cooked", "

    "); + }, + + async test(assert) { + assert.ok(exists(".chat-message-edited")); + }, + }); + + componentTest("shows edits - collapsible message", { + template: hbs`{{chat-message-text cooked=cooked edited=true}}`, + + beforeEach() { + this.set("cooked", '
    '); + }, + + async test(assert) { + assert.ok(exists(".chat-message-edited")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js b/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js new file mode 100644 index 0000000000..84468a68b1 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js @@ -0,0 +1,182 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import MockPresenceChannel from "../helpers/mock-presence-channel"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-replying-indicator", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("not displayed when no one is replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".chat-replying-indicator__text")); + }, + }); + + componentTest("displays indicator when user is replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + this.set("presenceChannel.users", [sam]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} is typing` + ); + }, + }); + + componentTest("displays indicator when 2 or 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + this.set("presenceChannel.users", [sam, mark]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} and ${mark.username} are typing` + ); + }, + }); + + componentTest("displays indicator when 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + const joffrey = { id: 3, username: "joffrey" }; + this.set("presenceChannel.users", [sam, mark, joffrey]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username}, ${mark.username} and ${joffrey.username} are typing` + ); + }, + }); + + componentTest("displays indicator when more than 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + const joffrey = { id: 3, username: "joffrey" }; + const taylor = { id: 4, username: "taylor" }; + this.set("presenceChannel.users", [sam, mark, joffrey, taylor]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username}, ${mark.username} and 2 others are typing` + ); + }, + }); + + componentTest("filters current user from list of repliers", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + this.set("presenceChannel.users", [sam, this.currentUser]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} is typing` + ); + }, + }); + + componentTest("resets presence when channel is draft", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + subscribed: true, + }) + ); + }, + + async test(assert) { + assert.ok(this.presenceChannel.subscribed); + + this.set("chatChannel", fabricators.chatChannel({ isDraft: true })); + + assert.notOk(this.presenceChannel.subscribed); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js new file mode 100644 index 0000000000..89620f1c93 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js @@ -0,0 +1,93 @@ +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-retention-reminder", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("Shows for public channels when user needs it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "Category" }) + ); + set(this.currentUser, "needs_channel_retention_reminder", true); + this.siteSettings.chat_channel_retention_days = 100; + }, + + async test(assert) { + assert.equal( + query(".chat-retention-reminder-text").innerText.trim(), + I18n.t("chat.retention_reminders.public", { days: 100 }) + ); + }, + }); + + componentTest( + "Doesn't show for public channels when user has dismissed it", + { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "Category" }) + ); + set(this.currentUser, "needs_channel_retention_reminder", false); + this.siteSettings.chat_channel_retention_days = 100; + }, + + async test(assert) { + assert.notOk(exists(".chat-retention-reminder")); + }, + } + ); + + componentTest("Shows for direct message channels when user needs it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "DirectMessage" }) + ); + set(this.currentUser, "needs_dm_retention_reminder", true); + this.siteSettings.chat_dm_retention_days = 100; + }, + + async test(assert) { + assert.equal( + query(".chat-retention-reminder-text").innerText.trim(), + I18n.t("chat.retention_reminders.dm", { days: 100 }) + ); + }, + }); + + componentTest("Doesn't show for dm channels when user has dismissed it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "DirectMessage" }) + ); + set(this.currentUser, "needs_dm_retention_reminder", false); + this.siteSettings.chat_dm_retention_days = 100; + }, + + async test(assert) { + assert.notOk(exists(".chat-retention-reminder")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-upload-test.js b/plugins/chat/test/javascripts/components/chat-upload-test.js new file mode 100644 index 0000000000..989da1f8be --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-upload-test.js @@ -0,0 +1,118 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; +import { settled } from "@ember/test-helpers"; + +const IMAGE_FIXTURE = { + id: 290, + url: null, // Nulled out to avoid actually setting the img src - avoids an HTTP request + original_filename: "image.jpg", + filesize: 172214, + width: 1024, + height: 768, + thumbnail_width: 666, + thumbnail_height: 500, + extension: "jpeg", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + retain_hours: null, + human_filesize: "168 KB", + dominant_color: "788370", // rgb(120, 131, 112) +}; + +const VIDEO_FIXTURE = { + id: 290, + url: null, // Nulled out to avoid actually setting the img src - avoids an HTTP request + original_filename: "video.mp4", + filesize: 172214, + width: 1024, + height: 768, + thumbnail_width: 666, + thumbnail_height: 500, + extension: "mp4", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.mp4", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.mp4", + retain_hours: null, + human_filesize: "168 KB", +}; + +const TXT_FIXTURE = { + id: 290, + url: "https://example.com/file.txt", + original_filename: "file.txt", + filesize: 172214, + extension: "txt", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + retain_hours: null, + human_filesize: "168 KB", +}; + +module("Discourse Chat | Component | chat-upload", function (hooks) { + setupRenderingTest(hooks); + + componentTest("with an image", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", IMAGE_FIXTURE); + }, + + async test(assert) { + assert.true(exists("img.chat-img-upload"), "displays as an image"); + const image = query("img.chat-img-upload"); + assert.strictEqual(image.loading, "lazy", "is lazy loading"); + + assert.strictEqual( + image.style.backgroundColor, + "rgb(120, 131, 112)", + "sets background to dominant color" + ); + + image.dispatchEvent(new Event("load")); // Fake that the image has loaded + await settled(); + + assert.strictEqual( + image.style.backgroundColor, + "", + "removes the background color once the image has loaded" + ); + }, + }); + + componentTest("with a video", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", VIDEO_FIXTURE); + }, + + async test(assert) { + assert.true(exists("video.chat-video-upload"), "displays as an video"); + const video = query("video.chat-video-upload"); + assert.ok(video.hasAttribute("controls"), "has video controls"); + assert.strictEqual( + video.getAttribute("preload"), + "metadata", + "video has correct preload settings" + ); + }, + }); + + componentTest("non image upload", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", TXT_FIXTURE); + }, + + async test(assert) { + assert.true(exists("a.chat-other-upload"), "displays as a link"); + const link = query("a.chat-other-upload"); + assert.strictEqual(link.href, TXT_FIXTURE.url, "has the correct URL"); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-user-avatar-test.js b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js new file mode 100644 index 0000000000..3bed00c31e --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js @@ -0,0 +1,55 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +const user = { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", +}; + +module("Discourse Chat | Component | chat-user-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("user is not online", { + template: hbs`{{chat-user-avatar chat=chat user=user}}`, + + async beforeEach() { + this.set("user", user); + this.set("chat", { presenceChannel: { users: [] } }); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-user-avatar .chat-user-avatar-container[data-user-card=${user.username}] .avatar[title=${user.username}]` + ) + ); + assert.notOk(exists(".chat-user-avatar.is-online")); + }, + }); + + componentTest("user is online", { + template: hbs`{{chat-user-avatar chat=chat user=user}}`, + + async beforeEach() { + this.set("user", user); + this.set("chat", { + presenceChannel: { users: [{ id: user.id }] }, + }); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-user-avatar .chat-user-avatar-container[data-user-card=${user.username}] .avatar[title=${user.username}]` + ) + ); + assert.ok(exists(".chat-user-avatar.is-online")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-user-display-name-test.js b/plugins/chat/test/javascripts/components/chat-user-display-name-test.js new file mode 100644 index 0000000000..26e56d2053 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-user-display-name-test.js @@ -0,0 +1,76 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +function displayName() { + return query(".chat-user-display-name").innerText.trim(); +} + +module( + "Discourse Chat | Component | chat-user-display-name | prioritize username in UX", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("username and no name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = true; + this.set("user", { username: "bob", name: null }); + }, + + async test(assert) { + assert.equal(displayName(), "bob"); + }, + }); + + componentTest("username and name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = true; + this.set("user", { username: "bob", name: "Bobcat" }); + }, + + async test(assert) { + assert.equal(displayName(), "bob — Bobcat"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-user-display-name | prioritize name in UX", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("no name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = false; + this.set("user", { username: "bob", name: null }); + }, + + async test(assert) { + assert.equal(displayName(), "bob"); + }, + }); + + componentTest("name and username", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = false; + this.set("user", { username: "bob", name: "Bobcat" }); + }, + + async test(assert) { + assert.equal(displayName(), "Bobcat — bob"); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/collapser-test.js b/plugins/chat/test/javascripts/components/collapser-test.js new file mode 100644 index 0000000000..8409c97a99 --- /dev/null +++ b/plugins/chat/test/javascripts/components/collapser-test.js @@ -0,0 +1,45 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query, visible } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; +import { htmlSafe } from "@ember/template"; + +module("Discourse Chat | Component | collapser", function (hooks) { + setupRenderingTest(hooks); + + componentTest("renders header", { + template: hbs`{{collapser header=header}}`, + + beforeEach() { + this.set("header", htmlSafe("
    tomtom
    ")); + }, + + async test(assert) { + const element = query(".cat"); + + assert.ok(exists(element)); + }, + }); + + componentTest("collapses and expands yielded body", { + template: hbs`{{#collapser}}
    body text
    {{/collapser}}`, + + test: async function (assert) { + const openButton = ".chat-message-collapser-closed"; + const closeButton = ".chat-message-collapser-opened"; + const body = ".cat"; + + assert.ok(visible(body)); + await click(closeButton); + + assert.notOk(visible(body)); + + await click(openButton); + + assert.ok(visible(body)); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/dc-filter-input-test.js b/plugins/chat/test/javascripts/components/dc-filter-input-test.js new file mode 100644 index 0000000000..10e5e75f85 --- /dev/null +++ b/plugins/chat/test/javascripts/components/dc-filter-input-test.js @@ -0,0 +1,56 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { fillIn, render, triggerEvent } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | dc-filter-input", function (hooks) { + setupRenderingTest(hooks); + + test("Left icon", async function (assert) { + await render(hbs``); + + assert.ok(exists(".d-icon-bell.-left")); + }); + + test("Right icon", async function (assert) { + await render(hbs``); + + assert.ok(exists(".d-icon-bell.-right")); + }); + + test("Class attribute", async function (assert) { + await render(hbs``); + + assert.ok(exists(".dc-filter-input-container.foo")); + }); + + test("Html attributes", async function (assert) { + await render(hbs``); + + assert.ok(exists('.dc-filter-input[data-foo="1"]')); + assert.ok(exists('.dc-filter-input[placeholder="bar"]')); + }); + + test("Filter action", async function (assert) { + this.set("value", null); + this.set("action", (event) => { + this.set("value", event.target.value); + }); + await render(hbs``); + await fillIn(".dc-filter-input", "foo"); + + assert.equal(this.value, "foo"); + }); + + test("Focused state", async function (assert) { + await render(hbs``); + await triggerEvent(".dc-filter-input", "focusin"); + + assert.ok(exists(".dc-filter-input-container.is-focused")); + + await triggerEvent(".dc-filter-input", "focusout"); + + assert.notOk(exists(".dc-filter-input-container.is-focused")); + }); +}); diff --git a/plugins/chat/test/javascripts/components/direct-message-creator-test.js b/plugins/chat/test/javascripts/components/direct-message-creator-test.js new file mode 100644 index 0000000000..8bcbd42ec0 --- /dev/null +++ b/plugins/chat/test/javascripts/components/direct-message-creator-test.js @@ -0,0 +1,167 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click, fillIn } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { createDirectMessageChannelDraft } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { Promise } from "rsvp"; +import fabricators from "../helpers/fabricators"; +import { module } from "qunit"; + +function mockChat(context, options = {}) { + const mock = context.container.lookup("service:chat"); + mock.searchPossibleDirectMessageUsers = () => { + return Promise.resolve({ + users: options.users || [{ username: "hawk" }, { username: "mark" }], + }); + }; + mock.getDmChannelForUsernames = () => { + return Promise.resolve({ chat_channel: fabricators.chatChannel() }); + }; + return mock; +} + +module("Discourse Chat | Component | direct-message-creator", function (hooks) { + setupRenderingTest(hooks); + + componentTest("search", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + assert.ok(exists("li.user[data-username='hawk']")); + }, + }); + + componentTest("select/deselect", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + assert.notOk(exists(".selected-user")); + + await fillIn(".filter-usernames", "hawk"); + await click("li.user[data-username='hawk']"); + + assert.ok(exists(".selected-user")); + + await click(".selected-user"); + + assert.notOk(exists(".selected-user")); + }, + }); + + componentTest("no search results", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this, { users: [] })); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "bad cat"); + + assert.ok(exists(".no-results")); + }, + }); + + componentTest("loads user on first load", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + assert.ok(exists("li.user[data-username='hawk']")); + assert.ok(exists("li.user[data-username='mark']")); + }, + }); + + componentTest("do not load more users after selection", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await click("li.user[data-username='hawk']"); + + assert.notOk(exists("li.user[data-username='mark']")); + }, + }); + + componentTest("apply is-focused to filter-area on focus input", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await click(".filter-usernames"); + + assert.ok(exists(".filter-area.is-focused")); + + await click(".test-blur"); + + assert.notOk(exists(".filter-area.is-focused")); + }, + }); + + componentTest("state is reset on channel change", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + + assert.equal(query(".filter-usernames").value, "hawk"); + + this.set("channel", fabricators.chatChannel()); + this.set("channel", createDirectMessageChannelDraft()); + + assert.equal(query(".filter-usernames").value, ""); + assert.ok(exists(".filter-area.is-focused")); + assert.ok(exists("li.user[data-username='hawk']")); + }, + }); + + componentTest("shows user status", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + const userWithStatus = { + username: "hawk", + status: { emoji: "tooth", description: "off to dentist" }, + }; + const chat = mockChat(this, { users: [userWithStatus] }); + this.set("chat", chat); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + assert.ok(exists(".user-status-message")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/on-visibility-action-test.js b/plugins/chat/test/javascripts/components/on-visibility-action-test.js new file mode 100644 index 0000000000..8eea7855bf --- /dev/null +++ b/plugins/chat/test/javascripts/components/on-visibility-action-test.js @@ -0,0 +1,29 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { render, waitUntil } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | on-visibility-action", function (hooks) { + setupRenderingTest(hooks); + + test("Calling an action on visibility gained", async function (assert) { + this.set("value", null); + this.set("display", false); + this.set("action", () => { + this.set("value", "foo"); + }); + + this.set("root", document.querySelector("#ember-testing")); + + await render( + hbs`{{#if display}}{{on-visibility-action action=action root=root}}{{/if}}` + ); + + assert.equal(this.value, null); + + this.set("display", true); + await waitUntil(() => this.value !== null); + + assert.equal(this.value, "foo"); + }); +}); diff --git a/plugins/chat/test/javascripts/components/sidebar-channels-test.js b/plugins/chat/test/javascripts/components/sidebar-channels-test.js new file mode 100644 index 0000000000..053c662cfd --- /dev/null +++ b/plugins/chat/test/javascripts/components/sidebar-channels-test.js @@ -0,0 +1,78 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { + setup as setupChatStub, + teardown as teardownChatStub, +} from "../helpers/chat-stub"; +import { module } from "qunit"; + +module("Discourse Chat | Component | sidebar-channels", function (hooks) { + setupRenderingTest(hooks); + + componentTest("default", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.ok(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("chat is on chat page", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { fullScreenChatOpen: true }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.ok(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("none of the conditions are fulfilled", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { userCanChat: false, fullScreenChatOpen: false }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.notOk(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("user cant chat", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { userCanChat: false }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.notOk(exists("[data-chat-channel-id]")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/helpers/chat-pretenders.js b/plugins/chat/test/javascripts/helpers/chat-pretenders.js new file mode 100644 index 0000000000..98a71f824f --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/chat-pretenders.js @@ -0,0 +1,165 @@ +import { + chatChannels, + directMessageChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +import { cloneJSON } from "discourse-common/lib/object"; +import User from "discourse/models/user"; + +export function baseChatPretenders(server, helper) { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(User.current())) + ); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + + server.get("/notifications", () => { + return helper.response({ + notifications: [ + { + id: 42, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "First notification", + post_number: null, + topic_id: null, + slug: null, + data: { + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 43, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Second notification", + post_number: null, + topic_id: null, + slug: null, + data: { + identifier: "engineers", + is_group: true, + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 44, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Third notification", + post_number: null, + topic_id: null, + slug: null, + data: { + identifier: "all", + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 45, + user_id: 1, + notification_type: 31, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Fourth notification", + post_number: null, + topic_id: null, + slug: null, + data: { + message: "chat.invitation_notification", + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + invited_by_username: "hawk", + }, + }, + { + id: 46, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Fifth notification", + post_number: null, + topic_id: null, + slug: null, + data: { + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + is_direct_message_channel: true, + mentioned_by_username: "hawk", + }, + }, + ], + seen_notification_id: null, + }); + }); + + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(User.current())) + ); + + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + + server.get("/chat/api/category-chatables/:categoryId/permissions.json", () => + helper.response({ allowed_groups: ["@everyone"], private: false }) + ); +} + +export function directMessageChannelPretender( + server, + helper, + opts = { unread_count: 0, muted: false } +) { + let copy = cloneJSON(directMessageChannels[0]); + copy.chat_channel.current_user_membership.unread_count = opts.unread_count; + copy.chat_channel.current_user_membership.muted = opts.muted; + server.get("/chat/chat_channels/75.json", () => helper.response(copy)); +} + +export function chatChannelPretender(server, helper, changes = []) { + // changes is [{ id: X, unread_count: Y, muted: true}] + let copy = cloneJSON(chatChannels); + changes.forEach((change) => { + let found; + found = copy.public_channels.find((c) => c.id === change.id); + if (found) { + found.current_user_membership.unread_count = change.unread_count; + found.current_user_membership.muted = change.muted; + } + if (!found) { + found = copy.direct_message_channels.find((c) => c.id === change.id); + if (found) { + found.current_user_membership.unread_count = change.unread_count; + found.current_user_membership.muted = change.muted; + } + } + }); + server.get("/chat/chat_channels.json", () => helper.response(copy)); +} diff --git a/plugins/chat/test/javascripts/helpers/chat-stub.js b/plugins/chat/test/javascripts/helpers/chat-stub.js new file mode 100644 index 0000000000..7d9420b3c0 --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/chat-stub.js @@ -0,0 +1,32 @@ +import fabricators from "../helpers/fabricators"; +import { isPresent } from "@ember/utils"; +import Service from "@ember/service"; + +let publicChannels; +let userCanChat; +let fullScreenChatOpen; + +class ChatStub extends Service { + userCanChat = userCanChat; + publicChannels = publicChannels; + fullScreenChatOpen = fullScreenChatOpen; +} + +export function setup(context, options = {}) { + context.registry.register("service:chat-stub", ChatStub); + context.registry.injection("component", "chat", "service:chat-stub"); + + publicChannels = isPresent(options.publicChannels) + ? options.publicChannels + : [fabricators.chatChannel()]; + userCanChat = isPresent(options.userCanChat) ? options.userCanChat : true; + fullScreenChatOpen = isPresent(options.fullScreenChatOpen) + ? options.fullScreenChatOpen + : false; +} + +export function teardown() { + publicChannels = []; + userCanChat = true; + fullScreenChatOpen = false; +} diff --git a/plugins/chat/test/javascripts/helpers/fabricator.js b/plugins/chat/test/javascripts/helpers/fabricator.js new file mode 100644 index 0000000000..8a513c4812 --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/fabricator.js @@ -0,0 +1,21 @@ +import { cloneJSON } from "discourse-common/lib/object"; + +// heavily inspired by https://github.com/travelperk/fabricator +export function Fabricator(Model, attributes = {}) { + return (opts) => fabricate(Model, attributes, opts); +} + +function fabricate(Model, attributes, opts = {}) { + if (typeof attributes === "function") { + return attributes(); + } + + const extendedModel = cloneJSON({ ...attributes, ...opts }); + const props = {}; + + for (const [key, value] of Object.entries(extendedModel)) { + props[key] = typeof value === "function" ? value() : value; + } + + return Model.create(props); +} diff --git a/plugins/chat/test/javascripts/helpers/fabricators.js b/plugins/chat/test/javascripts/helpers/fabricators.js new file mode 100644 index 0000000000..a316792cba --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/fabricators.js @@ -0,0 +1,50 @@ +import ChatChannel, { + CHATABLE_TYPES, +} from "discourse/plugins/chat/discourse/models/chat-channel"; +import EmberObject from "@ember/object"; +import { Fabricator } from "./fabricator"; + +const userFabricator = Fabricator(EmberObject, { + id: 1, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/41988e/{size}.png", +}); + +const categoryChatableFabricator = Fabricator(EmberObject, { + id: 1, + color: "D56353", + read_restricted: false, + name: "My category", +}); + +const directChannelChatableFabricator = Fabricator(EmberObject, { + users: [userFabricator({ id: 1, username: "bob" })], +}); + +export default { + chatChannel: Fabricator(ChatChannel, { + id: 1, + chatable_type: CHATABLE_TYPES.categoryChannel, + status: "open", + title: "My category title", + name: "My category name", + chatable: categoryChatableFabricator(), + last_message_sent_at: "2021-11-08T21:26:05.710Z", + }), + + chatChannelMessage: Fabricator(EmberObject, { + id: 1, + chat_channel_id: 1, + user_id: 1, + cooked: "This is a test message", + }), + + directMessageChatChannel: Fabricator(ChatChannel, { + id: 1, + chatable_type: CHATABLE_TYPES.directMessageChannel, + status: "open", + chatable: directChannelChatableFabricator(), + last_message_sent_at: "2021-11-08T21:26:05.710Z", + }), +}; diff --git a/plugins/chat/test/javascripts/helpers/mock-presence-channel.js b/plugins/chat/test/javascripts/helpers/mock-presence-channel.js new file mode 100644 index 0000000000..320529dfbd --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/mock-presence-channel.js @@ -0,0 +1,15 @@ +import EmberObject from "@ember/object"; + +export default class MockPresenceChannel extends EmberObject { + users = []; + name = null; + subscribed = false; + + async unsubscribe() { + this.set("subscribed", false); + } + + async subscribe() { + this.set("subscribed", true); + } +} diff --git a/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js b/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js new file mode 100644 index 0000000000..9d352560bc --- /dev/null +++ b/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js @@ -0,0 +1,31 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import I18n from "I18n"; + +module( + "Integration | Component | user-menu | chat-notifications-list", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(() => { + pretender.get("/notifications", () => { + return response({ notifications: [] }); + }); + }); + + const template = hbs``; + + test("empty state when there are no notifications", async function (assert) { + await render(template); + assert.ok(exists(".empty-state .empty-state-body")); + assert.strictEqual( + query(".empty-state .empty-state-title").textContent.trim(), + I18n.t("user_menu.no_chat_notifications_title") + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js new file mode 100644 index 0000000000..e05981401b --- /dev/null +++ b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js @@ -0,0 +1,36 @@ +import { render, waitFor } from "@ember/test-helpers"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Modifier | track-message-visibility", + function (hooks) { + setupRenderingTest(hooks); + + test("Marks message as visible when it intersects with the viewport", async function (assert) { + const template = hbs`
    `; + + await render(template); + await waitFor("div[data-visible=true]"); + + assert.ok( + exists("div[data-visible=true]"), + "message is marked as visible" + ); + }); + + test("Marks message as visible when it doesn't intersect with the viewport", async function (assert) { + const template = hbs`
    `; + + await render(template); + await waitFor("div[data-visible=false]"); + + assert.ok( + exists("div[data-visible=false]"), + "message is not marked as visible" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js new file mode 100644 index 0000000000..fc97420889 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js @@ -0,0 +1,20 @@ +import { module, test } from "qunit"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; + +module("Discourse Chat | Unit | Helpers | format-chat-date", function (hooks) { + setupRenderingTest(hooks); + + test("link to chat message", async function (assert) { + this.set("details", { chat_channel_id: 1 }); + this.set("message", { id: 1 }); + await render(hbs`{{format-chat-date this.message this.details}}`); + + assert.equal( + query(".chat-time").getAttribute("href"), + "/chat/channel/1/-?messageId=1" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js b/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js new file mode 100644 index 0000000000..785841167a --- /dev/null +++ b/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js @@ -0,0 +1,44 @@ +import { module, test } from "qunit"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module( + "Discourse Chat | Unit | Helpers | tonable-emoji-title", + function (hooks) { + setupRenderingTest(hooks); + + test("When emoji is not tonable", async function (assert) { + this.set("emoji", { name: "foo", tonable: false }); + this.set("diversity", 1); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:" + ); + }); + + test("When emoji is tonable and diversity is 1", async function (assert) { + this.set("emoji", { name: "foo", tonable: true }); + this.set("diversity", 1); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:" + ); + }); + + test("When emoji is tonable and diversity is greater than 1", async function (assert) { + this.set("emoji", { name: "foo", tonable: true }); + this.set("diversity", 2); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:t2:" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js b/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js new file mode 100644 index 0000000000..eabb9300e6 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js @@ -0,0 +1,38 @@ +import { module, test } from "qunit"; +import { + chatComposerButtons, + chatComposerButtonsDependentKeys, + clearChatComposerButtons, + registerChatComposerButton, +} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +module("Discourse Chat | Unit | chat-composer-buttons", function (hooks) { + hooks.beforeEach(function () { + registerChatComposerButton({ + id: "foo", + icon: "times", + dependentKeys: ["test"], + }); + + registerChatComposerButton({ + id: "bar", + translatedLabel() { + return this.baz; + }, + }); + }); + + hooks.afterEach(function () { + clearChatComposerButtons(); + }); + + test("chatComposerButtons", function (assert) { + const button = chatComposerButtons({ baz: "fooz" }, "inline")[1]; + assert.equal(button.id, "bar"); + assert.equal(button.label, "fooz"); + }); + + test("chatComposerButtonsDependentKeys", function (assert) { + assert.deepEqual(chatComposerButtonsDependentKeys(), ["test"]); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js new file mode 100644 index 0000000000..b53a2084dc --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js @@ -0,0 +1,128 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; + +module("Discourse Chat | Unit | chat-emoji-reaction-store", function (hooks) { + hooks.beforeEach(function () { + this.siteSettings = getOwner(this).lookup("service:site-settings"); + this.chatEmojiReactionStore = getOwner(this).lookup( + "service:chat-emoji-reaction-store" + ); + + this.chatEmojiReactionStore.siteSettings = this.siteSettings; + this.chatEmojiReactionStore.reset(); + }); + + hooks.afterEach(function () { + this.chatEmojiReactionStore.reset(); + }); + + // TODO (martin) Remove site setting workarounds after core PR#1290 + test("defaults", function (assert) { + assert.deepEqual( + this.chatEmojiReactionStore.favorites, + (this.siteSettings.default_emoji_reactions || "") + .split("|") + .filter((val) => val) + ); + }); + + test("diversity", function (assert) { + assert.strictEqual(this.chatEmojiReactionStore.diversity, 1); + + this.chatEmojiReactionStore.diversity = 2; + + assert.strictEqual(this.chatEmojiReactionStore.diversity, 2); + }); + + test("#favorites with defaults", function (assert) { + this.siteSettings.default_emoji_reactions = "smile|heart|tada"; + + assert.deepEqual(this.chatEmojiReactionStore.favorites, [ + "smile", + "heart", + "tada", + ]); + }); + + test("#favorites", function (assert) { + this.chatEmojiReactionStore.storedFavorites = ["grinning"]; + + assert.deepEqual(this.chatEmojiReactionStore.favorites, ["grinning"]); + }); + + test("#favorites when tracking multiple times the same emoji", function (assert) { + this.chatEmojiReactionStore.storedFavorites = [ + "grinning", + "yum", + "not_yum", + "yum", + ]; + + assert.deepEqual( + this.chatEmojiReactionStore.favorites, + ["yum", "grinning", "not_yum"], + "it favors count over order" + ); + }); + + test("#favorites when reaching displayed limit", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + [...Array(this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS)].forEach( + (_, index) => { + this.chatEmojiReactionStore.track("yum" + index); + } + ); + this.chatEmojiReactionStore.track("grinning"); + + assert.strictEqual( + this.chatEmojiReactionStore.favorites.length, + this.chatEmojiReactionStore.MAX_DISPLAYED_EMOJIS, + "it enforces the max length" + ); + }); + + test("#storedFavorites", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + this.chatEmojiReactionStore.track("yum"); + + assert.deepEqual( + this.chatEmojiReactionStore.storedFavorites, + ["yum"].concat(this.siteSettings.default_emoji_reactions.split("|")) + ); + }); + + test("#storedFavorites when tracking different emojis", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + this.chatEmojiReactionStore.track("yum"); + this.chatEmojiReactionStore.track("not_yum"); + this.chatEmojiReactionStore.track("yum"); + this.chatEmojiReactionStore.track("grinning"); + + assert.deepEqual( + this.chatEmojiReactionStore.storedFavorites, + ["grinning", "yum", "not_yum", "yum"].concat( + this.siteSettings.default_emoji_reactions.split("|") + ), + "it ensures last in is first" + ); + }); + + test("#storedFavorites when tracking an emoji after reaching the limit", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + [...Array(this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS)].forEach(() => { + this.chatEmojiReactionStore.track("yum"); + }); + this.chatEmojiReactionStore.track("grinning"); + + assert.strictEqual( + this.chatEmojiReactionStore.storedFavorites.length, + this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS, + "it enforces the max length" + ); + assert.strictEqual( + this.chatEmojiReactionStore.storedFavorites.firstObject, + "grinning", + "it correctly stores the last tracked emoji" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js b/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js new file mode 100644 index 0000000000..9ce84b57ae --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js @@ -0,0 +1,39 @@ +import { module, test } from "qunit"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +module("Discourse Chat | Unit | slugify-channel", function () { + test("defaults for title", function (assert) { + assert.equal(slugifyChannel({ title: "Foo bar" }), "foo-bar"); + }); + + test("a very long name for the title", function (assert) { + const string = + "xAq8l5ca2CtEToeMLe2pEr2VUGQBx3HPlxbkDExKrJHp4f7jCVw9id1EQv1N1lYMRdAIiZNnn94Kr0uU0iiEeVO4XkBVmpW8Mknmd"; + + assert.equal( + slugifyChannel({ title: string }), + string.toLowerCase().slice(0, -1) + ); + }); + + test("a cyrillic name for the title", function (assert) { + const string = "Русская литература и фольклор"; + + assert.equal( + slugifyChannel({ title: string }), + "русская-литература-и-фольклор" + ); + }); + + test("channel has escapedTitle", function (assert) { + assert.equal(slugifyChannel({ escapedTitle: "Foo bar" }), "foo-bar"); + }); + + test("channel has slug and title", function (assert) { + assert.equal( + slugifyChannel({ title: "Foo bar", slug: "some-other-thing" }), + "some-other-thing", + "slug takes priority" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js new file mode 100644 index 0000000000..c4a37c337f --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js @@ -0,0 +1,45 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; + +module( + "Discourse Chat | Unit | Service | chat-channel-info-route-origin-manager", + function (hooks) { + hooks.beforeEach(function () { + this.manager = getOwner(this).lookup( + "service:chat-channel-info-route-origin-manager" + ); + }); + + hooks.afterEach(function () { + this.manager.origin = null; + }); + + test(".origin", function (assert) { + this.manager.origin = ORIGINS.channnel; + assert.strictEqual(this.manager.origin, ORIGINS.channnel); + }); + + test(".isBrowse", function (assert) { + this.manager.origin = ORIGINS.browse; + assert.strictEqual(this.manager.isBrowse, true); + + this.manager.origin = null; + assert.strictEqual(this.manager.isBrowse, false); + + this.manager.origin = ORIGINS.channel; + assert.strictEqual(this.manager.isBrowse, false); + }); + + test(".isChannel", function (assert) { + this.manager.origin = ORIGINS.channnel; + assert.strictEqual(this.manager.isChannel, true); + + this.manager.origin = ORIGINS.browse; + assert.strictEqual(this.manager.isChannel, false); + + this.manager.origin = null; + assert.strictEqual(this.manager.isChannel, true); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js new file mode 100644 index 0000000000..052a31a683 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js @@ -0,0 +1,180 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { settled } from "@ember/test-helpers"; + +function emojisReponse() { + return { favorites: [{ name: "sad" }] }; +} + +module( + "Discourse Chat | Unit | Service | chat-emoji-picker-manager", + function (hooks) { + hooks.beforeEach(function () { + pretender.get("/chat/emojis.json", () => { + return [200, {}, emojisReponse()]; + }); + + this.manager = getOwner(this).lookup("service:chat-emoji-picker-manager"); + }); + + hooks.afterEach(function () { + this.manager.close(); + }); + + test("startFromMessageReactionList", async function (assert) { + const callback = () => {}; + this.manager.startFromMessageReactionList({ id: 1 }, false, callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-message"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("startFromMessageActions", async function (assert) { + const callback = () => {}; + this.manager.startFromMessageReactionList({ id: 1 }, false, callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-message"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("addVisibleSections", async function (assert) { + this.manager.addVisibleSections(["favorites", "objects"]); + + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + "objects", + ]); + }); + + test("sections", async function (assert) { + assert.deepEqual(this.manager.sections, []); + + this.manager.startFromComposer(() => {}); + + assert.deepEqual(this.manager.sections, []); + + await settled(); + + assert.deepEqual(this.manager.sections, ["favorites"]); + }); + + test("startFromComposer", async function (assert) { + const callback = () => {}; + this.manager.startFromComposer(callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-composer"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("closeExisting", async function (assert) { + const callback = () => { + return; + }; + + this.manager.startFromComposer(() => {}); + this.manager.addVisibleSections("objects"); + this.manager.lastVisibleSection = "objects"; + this.manager.startFromComposer(callback); + + assert.strictEqual( + this.manager.callback, + callback, + "it resets the callback to latest picker" + ); + assert.deepEqual( + this.manager.visibleSections, + ["favorites", "smileys_&_emotion"], + "it resets sections" + ); + assert.strictEqual( + this.manager.lastVisibleSection, + "favorites", + "it resets last visible section" + ); + }); + + test("didSelectEmoji", async function (assert) { + let value; + const callback = (emoji) => { + value = emoji.name; + }; + this.manager.startFromComposer(callback); + this.manager.didSelectEmoji({ name: "joy" }); + + assert.notOk(this.manager.callback); + assert.strictEqual(value, "joy"); + + await settled(); + + assert.notOk(this.manager.opened, "it closes the picker after selection"); + }); + + test("close", async function (assert) { + this.manager.startFromComposer(() => {}); + + assert.ok(this.manager.opened); + assert.ok(this.manager.callback); + + this.manager.addVisibleSections("objects"); + this.manager.lastVisibleSection = "objects"; + this.manager.close(); + + assert.notOk(this.manager.callback); + assert.ok(this.manager.closing); + assert.ok(this.manager.opened); + + await settled(); + + assert.notOk(this.manager.opened); + assert.notOk(this.manager.closing); + assert.deepEqual( + this.manager.visibleSections, + ["favorites", "smileys_&_emotion"], + "it resets visible sections" + ); + assert.strictEqual( + this.manager.lastVisibleSection, + "favorites", + "it resets last visible section" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js b/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js new file mode 100644 index 0000000000..cc9fb4f171 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js @@ -0,0 +1,94 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { set } from "@ember/object"; +import fabricators from "../../helpers/fabricators"; + +acceptance("Discourse Chat | Unit | Service | chat-guardian", function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatGuardian", { + get: () => this.container.lookup("service:chat-guardian"), + }); + Object.defineProperty(this, "siteSettings", { + get: () => this.container.lookup("service:site-settings"), + }); + Object.defineProperty(this, "currentUser", { + get: () => this.container.lookup("service:current-user"), + }); + }); + + needs.user(); + needs.settings(); + + test("#canEditChatChannel", async function (assert) { + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + assert.ok(this.chatGuardian.canEditChatChannel()); + }); + + test("#canUseChat", async function (assert) { + set(this.currentUser, "has_chat_enabled", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canUseChat()); + + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canUseChat()); + + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + assert.ok(this.chatGuardian.canUseChat()); + }); + + test("#canArchiveChannel", async function (assert) { + const channel = fabricators.chatChannel(); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + this.siteSettings.chat_allow_archiving_channels = true; + assert.ok(this.chatGuardian.canArchiveChannel(channel)); + + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + set(this.currentUser, "admin", true); + set(this.currentUser, "moderator", true); + + channel.set("status", "read_only"); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + channel.set("status", "open"); + + channel.set("status", "archived"); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + channel.set("status", "open"); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/services/chat-state-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-state-manager-test.js new file mode 100644 index 0000000000..596d8dd5a2 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-state-manager-test.js @@ -0,0 +1,82 @@ +import { module, test } from "qunit"; +import { setupTest } from "ember-qunit"; +import Site from "discourse/models/site"; +import sinon from "sinon"; + +module( + "Discourse Chat | Unit | Service | chat-state-manager", + function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.subject = this.owner.lookup("service:chat-state-manager"); + }); + + hooks.afterEach(function () { + this.subject.reset(); + }); + + test("isFullPagePreferred", function (assert) { + assert.notOk(this.subject.isFullPagePreferred); + + this.subject.prefersFullPage(); + + assert.ok(this.subject.isFullPagePreferred); + + this.subject.prefersDrawer(); + + assert.notOk(this.subject.isFullPagePreferred); + + this.subject.prefersDrawer(); + Site.currentProp("mobileView", true); + + assert.ok(this.subject.isFullPagePreferred); + }); + + test("isDrawerPreferred", function (assert) { + assert.ok(this.subject.isDrawerPreferred); + + this.subject.prefersFullPage(); + + assert.notOk(this.subject.isDrawerPreferred); + + this.subject.prefersDrawer(); + + assert.ok(this.subject.isDrawerPreferred); + }); + + test("lastKnownChatURL", function (assert) { + assert.strictEqual(this.subject.lastKnownChatURL, "/chat"); + + sinon.stub(this.subject.router, "currentURL").value("/foo"); + this.subject.storeChatURL(); + + assert.strictEqual(this.subject.lastKnownChatURL, "/foo"); + + this.subject.storeChatURL("/bar"); + + assert.strictEqual(this.subject.lastKnownChatURL, "/bar"); + }); + + test("lastKnownAppURL", function (assert) { + assert.strictEqual(this.subject.lastKnownAppURL, "/latest"); + + sinon.stub(this.subject.router, "currentURL").value("/foo"); + this.subject.storeAppURL(); + + assert.strictEqual(this.subject.lastKnownAppURL, "/foo"); + + this.subject.storeAppURL("/bar"); + + assert.strictEqual(this.subject.lastKnownAppURL, "/bar"); + }); + + test("isFullPage", function (assert) { + sinon.stub(this.subject.router, "currentRouteName").value("foo"); + assert.notOk(this.subject.isFullPage); + + sinon.stub(this.subject.router, "currentRouteName").value("chat"); + assert.ok(this.subject.isFullPage); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/chat-test.js b/plugins/chat/test/javascripts/unit/services/chat-test.js new file mode 100644 index 0000000000..ebcb36da95 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-test.js @@ -0,0 +1,263 @@ +import MockPresenceChannel from "../../helpers/mock-presence-channel"; +import { + acceptance, + publishToMessageBus, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import fabricators from "../../helpers/fabricators"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import sinon from "sinon"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { settled } from "@ember/test-helpers"; + +acceptance("Discourse Chat | Unit | Service | chat", function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "currentUser", { + get: () => this.container.lookup("service:current-user"), + }); + }); + + needs.user({ ignored_users: [] }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "something", + chatable_type: "Category", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 2, + last_read_message_id: 123, + unread_mentions: 0, + muted: false, + }, + }, + ], + direct_message_channels: [], + }); + }); + + server.put("/chat/:chatChannelId/read/:messageId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + function setupMockPresenceChannel(chatService) { + chatService.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/1`, + }) + ); + } + + test("#markNetworkAsReliable", async function (assert) { + setupMockPresenceChannel(this.chatService); + + this.chatService.markNetworkAsReliable(); + + assert.strictEqual(this.chatService.isNetworkUnreliable, false); + }); + + test("#markNetworkAsUnreliable", async function (assert) { + setupMockPresenceChannel(this.chatService); + this.chatService.markNetworkAsUnreliable(); + + assert.strictEqual(this.chatService.isNetworkUnreliable, true); + + await settled(); + + assert.strictEqual( + this.chatService.isNetworkUnreliable, + false, + "it resets state after a delay" + ); + }); + + test("#startTrackingChannel - sorts dm channels", async function (assert) { + setupMockPresenceChannel(this.chatService); + const fixtures = cloneJSON(directMessageChannels).mapBy("chat_channel"); + const channel1 = ChatChannel.create(fixtures[0]); + const channel2 = ChatChannel.create(fixtures[1]); + await this.chatService.startTrackingChannel(channel1); + this.currentUser.set( + `chat_channel_tracking_state.${channel1.id}.unread_count`, + 0 + ); + await this.chatService.startTrackingChannel(channel2); + + assert.strictEqual( + this.chatService.directMessageChannels.firstObject.title, + channel2.title + ); + }); + + test("#refreshTrackingState", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + + await this.chatService.refreshTrackingState(); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2 + ); + }); + + test("attempts to track a non followed channel", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + const channel = fabricators.chatChannel(); + await this.chatService.startTrackingChannel(channel); + + assert.false(channel.current_user_membership.following); + assert.notOk( + this.currentUser.chat_channel_tracking_state[channel.id], + "it doesn’t track it" + ); + }); + + test("/chat/:channelId/new-messages - message from current user", async function (assert) { + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: this.currentUser.id, + username: this.currentUser.username, + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 124, + "updates tracking state last message id to the message id sent by current user" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2, + "does not increment unread count" + ); + }); + + test("/chat/:channelId/new-messages - message from user that current user is ignoring", async function (assert) { + this.currentUser.set("ignored_users", ["johnny"]); + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: 2327, + username: "johnny", + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 124, + "updates tracking state last message id to the message id sent by johnny" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2, + "does not increment unread count" + ); + }); + + test("/chat/:channelId/new-messages - message from another user", async function (assert) { + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: 2327, + username: "jane", + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 123, + "does not update tracking state last message id to the message id sent by jane" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 3, + "does increment unread count" + ); + }); + + test("#updateLastReadMessage - updates and tracks the last read message", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 2 } }]; + }); + const activeChannel = fabricators.chatChannel({ + current_user_membership: { last_read_message_id: 1, following: true }, + }); + this.chatService.setActiveChannel(activeChannel); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(activeChannel.lastSendReadMessageId, 2); + }); + + test("#updateLastReadMessage - does nothing if the user doesn't follow the channel", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + this.chatService.setActiveChannel( + fabricators.chatChannel({ current_user_membership: { following: false } }) + ); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 1 } }]; + }); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(this.chatService.activeChannel.lastSendReadMessageId, null); + }); + + test("#updateLastReadMessage - does nothing if the user already read the message", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 1 } }]; + }); + const activeChannel = fabricators.chatChannel({ + current_user_membership: { last_read_message_id: 2, following: true }, + }); + this.chatService.setActiveChannel(activeChannel); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(activeChannel.lastSendReadMessageId, 2); + }); +}); + +acceptance( + "Discourse Chat | Unit | Service | chat - no current user", + function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("#refreshTrackingState", async function (assert) { + pretender.get(`/chat/chat_channels.json`, () => { + assert.step("unexpected"); + return [200, { "Content-Type": "application/json" }, {}]; + }); + + assert.step("start"); + await this.chatService.refreshTrackingState(); + assert.step("end"); + + assert.verifySteps(["start", "end"], "it does no requests"); + }); + } +); diff --git a/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js new file mode 100644 index 0000000000..8e850d9f8f --- /dev/null +++ b/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js @@ -0,0 +1,52 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { deepMerge } from "discourse-common/lib/object"; +import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; +import Notification from "discourse/models/notification"; +import hbs from "htmlbars-inline-precompile"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +function getNotification(overrides = {}) { + return Notification.create( + deepMerge( + { + id: 11, + notification_type: NOTIFICATION_TYPES.chat_invitation, + read: false, + data: { + message: "chat.invitation_notification", + invited_by_username: "eviltrout", + chat_channel_id: 9, + chat_message_id: 2, + chat_channel_title: "Site", + }, + }, + overrides + ) + ); +} + +module( + "Discourse Chat | Widget | chat-invitation-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("notification url", async function (assert) { + this.set("args", getNotification()); + + await render( + hbs`` + ); + + const data = this.args.data; + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel({ + title: data.chat_channel_title, + })}?messageId=${data.chat_message_id}` + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js new file mode 100644 index 0000000000..65532c33b1 --- /dev/null +++ b/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js @@ -0,0 +1,139 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { deepMerge } from "discourse-common/lib/object"; +import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; +import Notification from "discourse/models/notification"; +import hbs from "htmlbars-inline-precompile"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import I18n from "I18n"; + +function getNotification(overrides = {}) { + return Notification.create( + deepMerge( + { + id: 11, + notification_type: NOTIFICATION_TYPES.chat_invitation, + read: false, + data: { + message: "chat.mention_notification", + mentioned_by_username: "eviltrout", + chat_channel_id: 9, + chat_message_id: 2, + chat_channel_title: "Site", + }, + }, + overrides + ) + ); +} + +module( + "Discourse Chat | Widget | chat-mention-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set("args", getNotification()); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.direct_html", { + username: "eviltrout", + identifier: null, + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel({ + title: data.chat_channel_title, + })}?messageId=${data.chat_message_id}` + ); + }); + } +); + +module( + "Discourse Chat | Widget | chat-group-mention-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set( + "args", + getNotification({ + data: { + mentioned_by_username: "eviltrout", + identifier: "moderators", + }, + }) + ); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.other_html", { + username: "eviltrout", + identifier: "@moderators", + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel({ + title: data.chat_channel_title, + })}?messageId=${data.chat_message_id}` + ); + }); + } +); + +module( + "Discourse Chat | Widget | chat-group-mention-notification-item (@all)", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set( + "args", + getNotification({ + data: { + mentioned_by_username: "eviltrout", + identifier: "all", + }, + }) + ); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.other_html", { + username: "eviltrout", + identifier: "@all", + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel({ + title: data.chat_channel_title, + })}?messageId=${data.chat_message_id}` + ); + }); + } +); diff --git a/plugins/discourse-details/config/locales/server.nl.yml b/plugins/discourse-details/config/locales/server.nl.yml index 2f82311688..50ccc3c028 100644 --- a/plugins/discourse-details/config/locales/server.nl.yml +++ b/plugins/discourse-details/config/locales/server.nl.yml @@ -6,4 +6,4 @@ nl: details: - excerpt_details: "(klik voor meer details)" + excerpt_details: "(klik voor meer informatie)" diff --git a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js index a0897f6c42..8a63df5517 100644 --- a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js +++ b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js @@ -14,6 +14,9 @@ acceptance("Details Button", function (needs) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); @@ -115,6 +118,9 @@ acceptance("Details Button", function (needs) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await fillIn(".d-editor-input", multilineInput); const textarea = query(".d-editor-input"); diff --git a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js index a436ce76df..b7019a8ba5 100644 --- a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js +++ b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js @@ -4,7 +4,7 @@ import { module, test } from "qunit"; const defaultOpts = buildOptions({ siteSettings: { enable_emoji: true, - emoji_set: "google_classic", + emoji_set: "twitter", highlighted_languages: "json|ruby|javascript", default_code_lang: "auto", }, diff --git a/plugins/discourse-local-dates/config/locales/client.ar.yml b/plugins/discourse-local-dates/config/locales/client.ar.yml index da47774c9e..f7ef43efb5 100644 --- a/plugins/discourse-local-dates/config/locales/client.ar.yml +++ b/plugins/discourse-local-dates/config/locales/client.ar.yml @@ -41,4 +41,4 @@ ar: every_three_months: "كل ثلاثة أشهر" every_six_months: "كل ستة أشهر" every_year: "كل سنة" - default_title: "%{site_name} حدث" + default_title: "حدث %{site_name}" diff --git a/plugins/discourse-local-dates/config/locales/client.nb_NO.yml b/plugins/discourse-local-dates/config/locales/client.nb_NO.yml index 8baf50f7a7..9dd05491ff 100644 --- a/plugins/discourse-local-dates/config/locales/client.nb_NO.yml +++ b/plugins/discourse-local-dates/config/locales/client.nb_NO.yml @@ -31,6 +31,7 @@ nb_NO: format_title: Datoformat timezone: Tidssone until: Inntil... + current_timezone: "Gjeldende tidssone:" recurring: every_day: "Hver dag" every_week: "Hver uke" diff --git a/plugins/discourse-local-dates/config/locales/client.nl.yml b/plugins/discourse-local-dates/config/locales/client.nl.yml index 2262a1eddc..ca2963b327 100644 --- a/plugins/discourse-local-dates/config/locales/client.nl.yml +++ b/plugins/discourse-local-dates/config/locales/client.nl.yml @@ -23,7 +23,7 @@ nl: timezones_title: Tijdzones om weer te geven timezones_description: Tijdzones worden gebruikt voor het weergeven van datums in voorbeeld en terugval. recurring_title: Herhaling - recurring_description: "De herhaling van een gebeurtenis definiëren. U kunt de door het formulier gegenereerde herhalingsoptie ook handmatig bewerken en een van de volgende sleutels gebruiken: years, quarters, months, weeks, days, hours, minutes, seconds, milliseconds." + recurring_description: "Definieer de herhaling van een gebeurtenis. Je kunt de door het formulier gegenereerde herhalingsoptie ook handmatig bewerken en een van de volgende sleutels gebruiken: jaren, kwartalen, maanden, weken, dagen, uren, minuten, seconden, milliseconden." recurring_none: Geen herhaling invalid_date: Ongeldige datum; zorg ervoor dat de datum en tijd juist zijn date_title: Datum diff --git a/plugins/discourse-local-dates/config/locales/server.fr.yml b/plugins/discourse-local-dates/config/locales/server.fr.yml index ebdedfb25d..c5352454f7 100644 --- a/plugins/discourse-local-dates/config/locales/server.fr.yml +++ b/plugins/discourse-local-dates/config/locales/server.fr.yml @@ -9,4 +9,4 @@ fr: discourse_local_dates_enabled: "Activer la fonctionnalité « discourse-local-dates ». Ceci ajoutera la gestion des dates locales dans les messages, en utilisant l'élément [date]." discourse_local_dates_default_formats: "Formats de date fréquemment utilisés, voir : momentjs string format" discourse_local_dates_default_timezones: "Liste de fuseaux horaires TZ (page en anglais) par défaut" - discourse_local_dates_email_format: "Format utilisé pour les dates dans les courriels." + discourse_local_dates_email_format: "Format utilisé pour les dates dans les e-mails." diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js index 3c4855cb61..b0411398b8 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js @@ -23,6 +23,9 @@ acceptance("Local Dates - composer", function (needs) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await fillIn( ".d-editor-input", @@ -73,6 +76,9 @@ acceptance("Local Dates - composer", function (needs) { test("date modal", async function (assert) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await click(".d-editor-button-bar .local-dates"); const timezoneChooser = selectKit(".timezone-input"); @@ -88,6 +94,9 @@ acceptance("Local Dates - composer", function (needs) { test("date modal - controls", async function (assert) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await click(".d-editor-button-bar .local-dates"); await click('.pika-table td[data-day="5"] > .pika-button'); diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js index 265cf34940..5c17034018 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js @@ -49,7 +49,7 @@ acceptance("Local Dates - quoting", function (needs) { await click(".insert-quote"); assert.strictEqual( query(".d-editor-input").value.trim(), - `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] + `[quote=\"uwe_keim, post:1, topic:280\"] This is a test [date=2022-06-17 time=10:00:00 timezone="Australia/Brisbane" displayedTimezone="Australia/Perth"] [/quote]`, "converts the date to markdown with all options correctly" @@ -88,7 +88,7 @@ acceptance("Local Dates - quoting range", function (needs) { await click(".insert-quote"); assert.strictEqual( query(".d-editor-input").value.trim(), - `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] + `[quote=\"uwe_keim, post:1, topic:280\"] Some text [date-range from=2022-06-17T09:30:00 to=2022-06-18T10:30:00 format="LL" timezone="Australia/Brisbane" timezones="Africa/Accra|Australia/Brisbane|Europe/Paris"] [/quote]`, "converts the date range to markdown with all options correctly" @@ -130,7 +130,7 @@ acceptance( await click(".insert-quote"); assert.strictEqual( query(".d-editor-input").value.trim(), - `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] + `[quote=\"uwe_keim, post:1, topic:280\"] Testing countdown [date=2022-06-21 time=09:30:00 format="LL" timezone="Australia/Brisbane" countdown="true"] Testing recurring [date=2022-06-22 timezone="Australia/Brisbane" recurring="2.weeks"] diff --git a/plugins/discourse-narrative-bot/config/locales/server.ar.yml b/plugins/discourse-narrative-bot/config/locales/server.ar.yml index dd4c1e7c56..64e8afd582 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.ar.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.ar.yml @@ -89,13 +89,13 @@ ar: quote: "عقل الإنسان قادر على تحقيق أي شيء يمكنه أن يتصوره ويؤمن به." author: "نابليون هيل" "11": - quote: "السلام في المنزل، السلام في العالم." + quote: "سلام في الوطن، سلام في العالم." author: "مصطفى كمال أتاتورك" "12": quote: "التعليم ليس وسيلة للهروب من الفقر، بل هو وسيلة لمكافحته." author: "جوليوس نيريري" "13": - quote: "يجب أن تبدأ رحلة الألف ميل بخطوة واحدة." + quote: "رحلة الألف ميل تبدأ بخطوة." author: "لاو تزو" results: |- > :left_speech_bubble: _%{quote}_ — %{author} @@ -222,7 +222,7 @@ ar: - أو اضغط على الزر < b> B أو I في المحرِّر reply: |- - أحسنت! يعمل HTML وBBCode أيضًا على التنسيق - لمعرفة المزيد،[جرِّب هذا الدرس التعليمي](https://commonmark.org/help) :nerd: + أحسنت! يمكن استخدام HTML وBBCode أيضًا في التنسيق - لمعرفة المزيد، [جرِّب هذا الدرس التعليمي](https://commonmark.org/help) :nerd: not_found: |- عذرًا، لم أجد أي تنسيق في ردك. :Pencil2: diff --git a/plugins/discourse-narrative-bot/config/locales/server.ja.yml b/plugins/discourse-narrative-bot/config/locales/server.ja.yml index 894516d086..04459fd45e 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.ja.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.ja.yml @@ -166,7 +166,7 @@ ja: - セキュリティの都合上、新規ユーザーはできることには限られています。使用回数が増えれば、新しい機能 (https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) (とバッジ (%{base_uri}/badges)) を得られます。 - - コミュニティでは常に礼節ある行動 (%{base_uri}/guidelines) を取ってもらえると信じています。 + - コミュニティーでは常に礼節ある行動 (%{base_uri}/guidelines) を取ってもらえると信じています。 onebox: instructions: |- 次に、下のいずれかのリンクを私にシェアしてください。**リンクだけを 1 行で**返信すると、気の利いた要約を含めて自動的に展開表示されます。 @@ -250,7 +250,7 @@ ja: instructions: |- もっと詳しく知るには、下の を選択して **この個人メッセージをブックマーク**してください。ブックマークすると、後で :gift: がもらえるかも! reply: |- - お見事!これで、[プロフィールのブックマークタブ](%{bookmark_url})からこの非公開の会話にいつでも簡単に戻れるようになりました。右上のプロフィール写真を選択するだけです ↗ + お見事!これで、[プロファイルのブックマークタブ](%{bookmark_url})からこの非公開の会話にいつでも簡単に戻れるようになりました。右上のプロファイル写真を選択するだけです ↗ not_found: |- あれ?このトピックにはブックマークがありませんね。投稿の下にあるブックマークを見つけられましたか?必要であれば、もっと表示 を使うと、ほかの操作を表示できます。 emoji: @@ -286,13 +286,13 @@ ja: ](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) flag: instructions: |- - ディスカッションを友好的に保ちたいと思っています。そこで、[マナーを維持](%{guidelines_url})するにはあなたの力が必要です。問題に遭遇したら、非公開で作成者に知らせるか、[スタッフ](%{about_url})に通報してください。投稿を通報する理由は、無害なトピックの分割から明確なコミュニティ規則の違反までさまざまです。**その他**を選択すると、モデレーターと非公開メッセージでのディスカッションを始められます。さらに詳しく質問できます。 + ディスカッションを友好的に保ちたいと思っています。そこで、[マナーを維持](%{guidelines_url})するにはあなたの力が必要です。問題に遭遇したら、非公開で作成者に知らせるか、[スタッフ](%{about_url})に通報してください。投稿を通報する理由は、無害なトピックの分割から明確なコミュニティー規則の違反までさまざまです。**その他**を選択すると、モデレーターと非公開メッセージでのディスカッションを始められます。さらに詳しく質問できます。 > :imp: 不愉快なことを書き込みました 早速**この投稿を通報** して、理由に「**不適切**」を選択しましょう! reply: |- - この通報は[スタッフ](%{base_uri}/groups/staff)に非公開で通知されます。十分な数のコミュニティメンバーからこの投稿への通報が寄せられた場合は、予防措置として自動的に非表示になります。(実際には、不愉快な内容は書き込まなかった :angel: ので、この通報は取り消しておきました。) + この通報は[スタッフ](%{base_uri}/groups/staff)に非公開で通知されます。十分な数のコミュニティーメンバーからこの投稿への通報が寄せられた場合は、予防措置として自動的に非表示になります。(実際には、不愉快な内容は書き込まなかった :angel: ので、この通報は取り消しておきました。) not_found: |- おや?私の不愉快な投稿はまだ不適切の通報を受けていないようです。:worried: **通報** を使って投稿を不適切だと通報してみてください。投稿の操作をさらに表示するには、もっと表示ボタン を使用してくださいね。 search: diff --git a/plugins/discourse-narrative-bot/config/locales/server.nl.yml b/plugins/discourse-narrative-bot/config/locales/server.nl.yml index c18f603c5c..0908c8283a 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.nl.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.nl.yml @@ -10,7 +10,7 @@ nl: disable_discourse_narrative_bot_welcome_post: "Het Discourse Narrative Bot-welkomstbericht uitschakelen" discourse_narrative_bot_ignored_usernames: "Gebruikersnamen die de Discourse Narrative Bot moet negeren" discourse_narrative_bot_disable_public_replies: "Antwoorden door de Discourse Narrative Bot uitschakelen" - discourse_narrative_bot_welcome_post_type: "Type welkomstbericht dat de Discourse Narrative Bot zou moeten uitsturen" + discourse_narrative_bot_welcome_post_type: "Type welkomstbericht dat de Discourse Narrative Bot moet sturen" discourse_narrative_bot_welcome_post_delay: "(n) seconden wachten voordat de Discourse Narrative Bot het welkomstbericht uitstuurt." discourse_narrative_bot_skip_tutorials: "Discourse Narrative Bot-handleidingen die worden overgeslagen" badges: @@ -18,12 +18,12 @@ nl: name: Gecertificeerd description: "Heeft onze handleiding voor nieuwe gebruikers afgerond" long_description: | - Deze badge wordt toegekend wanneer de interactieve handleiding voor nieuwe gebruikers met succes is doorlopen. U hebt het initiatief genomen om de basishulpmiddelen voor discussie te leren, en u bent nu gecertificeerd! + Deze badge wordt toegekend wanneer de interactieve tutorial voor nieuwe gebruikers is voltooid. Je hebt het initiatief genomen om de basisgereedschappen voor discussie te leren en je bent nu gecertificeerd! licensed: name: Gelicentieerd description: "Heeft onze handleiding voor gevorderde gebruikers afgerond" long_description: | - Deze badge wordt toegekend wanneer de interactieve handleiding voor gevorderde gebruikers met succes is doorlopen. U hebt de geavanceerde hulpmiddelen voor discussie onder de knie – en u bent nu volledig gecertificeerd! + Deze badge wordt toegekend wanneer de interactieve tutorial voor gevorderde gebruikers is voltooid. Je hebt de geavanceerde gereedschappen voor discussie onder de knie en je bent nu volledig gecertificeerd! discourse_narrative_bot: dice: trigger: "gooien" @@ -64,7 +64,7 @@ nl: "2": "Het is zo besloten" "3": "Zonder twijfel" "4": "Ja zeker" - "5": "Daar kunt u van uitgaan" + "5": "Daar kun je van uitgaan" "6": "Zoals ik het zie, ja" "7": "Zeer waarschijnlijk" "8": "De vooruitzichten zijn goed" @@ -72,9 +72,9 @@ nl: "10": "Alle signalen wijzen naar ja" "11": "Onduidelijk, probeer het nog eens" "12": "Vraag later nogmaals" - "13": "Beter om u dat nu niet te vertellen" + "13": "Beter om je dat nu niet te vertellen" "14": "Kan nu niet voorspellen" - "15": "Concentreer u en vraag het opnieuw" + "15": "Concentreer je en vraag het opnieuw" "16": "Reken er niet op" "17": "Mijn antwoord is nee" "18": "Mijn bronnen zeggen nee" @@ -88,22 +88,22 @@ nl: help_trigger: "help weergeven" random_mention: reply: |- - Hallo! Zeg `@%{discobot_username} %{help_trigger}` om te ontdekken wat ik kan. + Hallo! Zeg '@%{discobot_username} %{help_trigger}' om te ontdekken wat ik kan. bot_actions: |- - `@%{discobot_username} %{dice_trigger} 2d6` + '@%{discobot_username} %{dice_trigger} 2d6' > :game_die: 3, 6 - `@%{discobot_username} %{quote_trigger}` + '@%{discobot_username} %{quote_trigger}' %{quote_sample} - `@%{discobot_username} %{magic_8_ball_trigger}` - > :crystal_ball: U kunt erop vertrouwen + '@%{discobot_username} %{magic_8_ball_trigger}' + > :crystal_ball: Je kunt erop vertrouwen do_not_understand: first_response: |- Hallo, bedankt voor het antwoord! Helaas kan ik dat, als slecht geprogrammeerde bot, niet helemaal begrijpen. :frowning: - track_response: U kunt het opnieuw proberen, of als u deze stap wilt overslaan, `%{skip_trigger}` zeggen. Zeg anders `%{reset_trigger}` om opnieuw te beginnen. + track_response: Je kunt het opnieuw proberen, of '%{skip_trigger}' zeggen als je deze stap wilt overslaan. Zeg anders '%{reset_trigger}' om opnieuw te beginnen. new_user_narrative: reset_trigger: "handleiding" title: "Certificaat voor het voltooien van de handleiding voor nieuwe gebruikers" @@ -117,19 +117,19 @@ nl: reply: |- Uitstekend werk! HTML en BBCode werken ook voor de opmaak – om meer te leren, [probeer deze handleiding](https://commonmark.org/help) :nerd: not_found: |- - Oh, ik vond geen opmaak in uw antwoord. :pencil2: + Ik heb geen opmaak gevonden in je antwoord. :pencil2: - Kan u het opnieuw proberen? Gebruik de B vet of I cursief knoppen in de editor als u vast komt te zitten. + Kun je het opnieuw proberen? Gebruik de knoppen B (vet) of I (cursief) in de editor als je vast komt te zitten. search: reply: |- - Joepie! U hebt het gevonden :tada: + Je hebt het gevonden :tada: - - Voor meer gedetailleerde zoekopdrachten gaat u naar de [geavanceerde zoekpagina](%{search_url}). + - Ga voor gedetailleerdere zoekopdrachten naar de [geavanceerde zoekpagina](%{search_url}). - - Om ergens in een lange discussie te springen, probeer de tijdslijnbediening aan de rechterzijde (en onderaan, op mobiele apparaten). + - Probeer de tijdlijnbediening rechts (en onderaan op mobiel) om ergens in een lange discussie te springen. - - Indien u een fysiek toetsenbord hebt :keyboard:, druk op ? om onze handige snelkoppelingen te bekijken. + - Als je een fysiek toetsenbord :keyboard: hebt, druk dan op ? om onze handige snelkoppelingen te bekijken. advanced_user_narrative: reset_trigger: "geavanceerde handleiding" - cert_title: "Als erkenning voor de succesvolle voltooiing van de geavanceerde gebruikershandleiding" + cert_title: "Als erkenning voor de succesvolle voltooiing van de tutorial voor gevorderde gebruikers" title: ":arrow_up: Geavanceerde gebruikersfuncties" diff --git a/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js index 72c9d9e18f..9487e88c96 100644 --- a/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js +++ b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js @@ -21,6 +21,9 @@ acceptance("Discourse Presence Plugin", function (needs) { test("Doesn't break topic creation", async function (assert) { await visit("/"); await click("#create-topic"); + const categoryChooser = selectKit(".category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(2); await fillIn("#reply-title", "Internationalization Localization"); await fillIn( ".d-editor-input", diff --git a/plugins/poll/config/locales/client.ar.yml b/plugins/poll/config/locales/client.ar.yml index f9c6dc262e..9eae56b7b4 100644 --- a/plugins/poll/config/locales/client.ar.yml +++ b/plugins/poll/config/locales/client.ar.yml @@ -64,7 +64,7 @@ ar: title: "عرض نتائج استطلاع الرأي" label: "عرض النتائج" remove-vote: - title: "إزالة تصويتك" + title: "إزالة صوتك" label: "إزالة التصويت" hide-results: title: "العودة إلى تصويتاتك" diff --git a/plugins/poll/config/locales/client.nl.yml b/plugins/poll/config/locales/client.nl.yml index 66199fa096..844d3f0a00 100644 --- a/plugins/poll/config/locales/client.nl.yml +++ b/plugins/poll/config/locales/client.nl.yml @@ -18,7 +18,7 @@ nl: title: "Stemmen zijn openbaar." results: groups: - title: "U dient lid van %{groups} te zijn om in deze poll te stemmen." + title: "Je moet lid van %{groups} zijn om te stemmen in deze poll." vote: title: "Resultaten worden getoond bij stemmen." closed: @@ -39,7 +39,7 @@ nl: between_min_and_max_options: "Kies %{min} tot %{max} opties." cast-votes: title: "Uw stemmen uitbrengen" - label: "Nu stemmen!" + label: "Stem nu!" show-results: title: "De pollresultaten weergeven" label: "Resultaten tonen" @@ -55,11 +55,11 @@ nl: open: title: "De poll openen" label: "Openen" - confirm: "Weet u zeker dat u deze poll wilt openen?" + confirm: "Weet je zeker dat je deze poll wilt openen?" close: title: "De poll sluiten" label: "Sluiten" - confirm: "Weet u zeker dat u deze poll wilt sluiten?" + confirm: "Weet je zeker dat je deze poll wilt sluiten?" automatic_close: closes_in: "Sluit over %{timeLeft}." age: "Gesloten: %{age}" diff --git a/plugins/poll/config/locales/server.ar.yml b/plugins/poll/config/locales/server.ar.yml index e529a5086d..107abe436e 100644 --- a/plugins/poll/config/locales/server.ar.yml +++ b/plugins/poll/config/locales/server.ar.yml @@ -48,20 +48,20 @@ ar: user_cant_post_in_topic: "لا يمكنك التصويت لأنه لا يمكنك النشر في هذا الموضوع." topic_must_be_open_to_vote: "يجب أن يكون الموضوع مفتوحًا للتصويت." poll_must_be_open_to_vote: "يجب أن يكون الاستطلاع مفتوحًا للتصويت." - one_vote_per_user: "مسموح فقط بصوت واحد لهذا الاستطلاع." + one_vote_per_user: "مسموح بصوت واحد فقط لهذا الاستطلاع." max_vote_per_user: - zero: '%{count} تصويت مسموح به لهذا الاستطلاع.' - one: مسموح فقط بصوت واحد %{count} لهذا الاستطلاع. - two: مسموح فقط بتصويتين %{count} لهذا الاستطلاع. - few: يُسمح فقط بـ %{count} تصويتات لهذا الاستطلاع. - many: يُسمح فقط بـ %{count} تصويت لهذا الاستطلاع. - other: يُسمح بحد أقصى %{count} صوت لهذا الاستطلاع. + zero: مسموح بعدد %{count} صوت فقط لهذا الاستطلاع. + one: مسموح بصوت واحد (%{count}) فقط لهذا الاستطلاع. + two: مسموح بصوتين (%{count}) فقط لهذا الاستطلاع. + few: مسموح بعدد %{count} أصوات فقط لهذا الاستطلاع. + many: مسموح بعدد %{count} صوتًا فقط لهذا الاستطلاع. + other: مسموح بعدد %{count} صوت فقط لهذا الاستطلاع. min_vote_per_user: zero: مطلوب ما لا يقل عن %{count} صوت لهذا الاستطلاع. - one: مطلوب ما لا يقل عن صوت واحد %{count} لهذا الاستطلاع. - two: مطلوب ما لا يقل عن صوتين %{count} لهذا الاستطلاع. + one: مطلوب ما لا يقل عن صوت واحد (%{count}) لهذا الاستطلاع. + two: مطلوب ما لا يقل عن صوتين (%{count}) لهذا الاستطلاع. few: مطلوب ما لا يقل عن %{count} أصوات لهذا الاستطلاع. - many: مطلوب ما لا يقل عن %{count} صوت لهذا الاستطلاع. + many: مطلوب ما لا يقل عن %{count} صوتًا لهذا الاستطلاع. other: مطلوب ما لا يقل عن %{count} صوت لهذا الاستطلاع. topic_must_be_open_to_toggle_status: "يجب أن يكون الموضوع مفتوحًا لتبديل الحالة." only_staff_or_op_can_toggle_status: "يمكن لعضو فريق العمل أو الناشر الأصلي فقط تبديل حالة الاستطلاع." diff --git a/plugins/poll/config/locales/server.en.yml b/plugins/poll/config/locales/server.en.yml index 19bedc3903..a5a0aef4f7 100644 --- a/plugins/poll/config/locales/server.en.yml +++ b/plugins/poll/config/locales/server.en.yml @@ -37,7 +37,7 @@ en: edit_window_expired: cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes." - cannot_edit_named_poll_with_votes: "You cannot change the poll name ${name} after the first %{minutes} minutes." + cannot_edit_named_poll_with_votes: "You cannot change the poll named ${name} after the first %{minutes} minutes." no_poll_with_this_name: "No poll named %{name} associated with this post." diff --git a/plugins/poll/config/locales/server.nl.yml b/plugins/poll/config/locales/server.nl.yml index a1473c70c9..18131f028e 100644 --- a/plugins/poll/config/locales/server.nl.yml +++ b/plugins/poll/config/locales/server.nl.yml @@ -9,7 +9,7 @@ nl: poll_enabled: "Polls toestaan?" poll_maximum_options: "Maximale aantal toegestane opties in een poll." poll_edit_window_mins: "Aantal minuten na het aanmaken van een bericht waarin polls kunnen worden bewerkt." - poll_minimum_trust_level_to_create: "Het minimale vertrouwensniveau dat nodig is om polls aan te maken." + poll_minimum_trust_level_to_create: "Het minimale vertrouwensniveau dat nodig is om polls te maken." poll_groupable_user_fields: "Een aantal gebruikersveldnamen die voor het groeperen en filteren van pollresultaten kunnen worden gebruikt." poll_export_data_explorer_query_id: "ID van de te gebruiken gegevensverkennerquery voor het exporteren van pollresultaten (0 voor uitschakelen)." poll: @@ -31,19 +31,19 @@ nl: named_poll_must_not_have_any_empty_options: "De poll met de naam %{name} mag geen lege opties hebben." default_poll_with_multiple_choices_has_invalid_parameters: "De poll met meerdere keuzes heeft ongeldige parameters." named_poll_with_multiple_choices_has_invalid_parameters: "De poll met de naam %{name} met meerdere keuzes heeft ongeldige parameters." - requires_at_least_1_valid_option: "U moet minstens 1 geldige optie selecteren." + requires_at_least_1_valid_option: "Je moet minimaal 1 geldige optie selecteren." edit_window_expired: - cannot_edit_default_poll_with_votes: "U kunt na de eerste %{minutes} minuten geen poll wijzigen." - cannot_edit_named_poll_with_votes: "U kunt de pollnaam ${name} na de eerste %{minutes} minuten niet wijzigen." + cannot_edit_default_poll_with_votes: "Je kunt een poll niet wijzigen na de eerste %{minutes} minuten." + cannot_edit_named_poll_with_votes: "Je kunt de pollnaam ${name} niet wijzigen na de eerste %{minutes} minuten." no_poll_with_this_name: "Er is geen poll met de naam %{name} aan dit bericht gekoppeld." post_is_deleted: "Dit kan niet bij een verwijderd bericht." - user_cant_post_in_topic: "U kunt niet stemmen, omdat u geen berichten in dit topic kunt plaatsen." + user_cant_post_in_topic: "Je kunt niet stemmen, omdat je geen berichten in dit topic kunt plaatsen." topic_must_be_open_to_vote: "Het topic moet geopend zijn om te kunnen stemmen." poll_must_be_open_to_vote: "De poll moet geopend zijn om te kunnen stemmen." topic_must_be_open_to_toggle_status: "Het topic moet geopend zijn om de status te kunnen omschakelen." only_staff_or_op_can_toggle_status: "Alleen een staflid of de oorspronkelijke plaatser van een bericht kan de status van een poll omschakelen." - insufficient_rights_to_create: "U mag geen polls maken." + insufficient_rights_to_create: "Je mag geen polls maken." email: - link_to_poll: "Klik om de poll te bekijken." + link_to_poll: "Klik om de poll weer te geven." user_field: no_data: "Geen gegevens" diff --git a/plugins/poll/lib/tasks/migrate_old_polls.rake b/plugins/poll/lib/tasks/migrate_old_polls.rake index c058c06a69..91046fb1df 100644 --- a/plugins/poll/lib/tasks/migrate_old_polls.rake +++ b/plugins/poll/lib/tasks/migrate_old_polls.rake @@ -58,7 +58,7 @@ task "poll:migrate_old_polls" => :environment do options = post.custom_fields["polls"]["poll"]["options"] # iterate over all votes PluginStoreRow.where(plugin_name: "poll") - .where("key LIKE 'poll_vote_#{post_id}_%'") + .where("key LIKE ?", "poll_vote_#{post_id}_%") .pluck(:key, :value) .each do |poll_vote_key, vote| # extract the user_id diff --git a/plugins/styleguide/config/locales/client.ar.yml b/plugins/styleguide/config/locales/client.ar.yml index 63624d73e9..b52a09a40d 100644 --- a/plugins/styleguide/config/locales/client.ar.yml +++ b/plugins/styleguide/config/locales/client.ar.yml @@ -88,5 +88,7 @@ ar: empty_state: title: "حالة فارغة" rich_tooltip: + title: "تلميح مفصَّل" description: "الوصف" header: "الرأس" + hover_to_see: "مرِّر فوق الأقسام لرؤية تلميح" diff --git a/plugins/styleguide/config/locales/client.ja.yml b/plugins/styleguide/config/locales/client.ja.yml index 14b1792312..d302a2184f 100644 --- a/plugins/styleguide/config/locales/client.ja.yml +++ b/plugins/styleguide/config/locales/client.ja.yml @@ -80,7 +80,7 @@ ja: header: "モーダルタイトル" footer: "モーダルフッター" user_about: - title: "ユーザープロフィールボックス" + title: "ユーザープロファイルボックス" header_icons: title: "ヘッダーアイコン" spinners: diff --git a/plugins/styleguide/config/locales/client.nl.yml b/plugins/styleguide/config/locales/client.nl.yml index a6a5a69db0..1f8979e95e 100644 --- a/plugins/styleguide/config/locales/client.nl.yml +++ b/plugins/styleguide/config/locales/client.nl.yml @@ -8,7 +8,7 @@ nl: js: styleguide: title: "Stijlgids" - welcome: "Kies een sectie in het menu aan de linkerkant om te beginnen." + welcome: "Kies een sectie in het menu links om te beginnen." categories: atoms: Atomen molecules: Moleculen @@ -26,7 +26,7 @@ nl: title: "Kleuren" icons: title: "Pictogrammen" - full_list: "Volledige lijst met Font Awesome-pictogrammen bekijken" + full_list: "Zie de volledige lijst van Font Awesome-pictogrammen" input_fields: title: "Invoervelden" buttons: @@ -46,7 +46,7 @@ nl: categories_list: title: "Categorielijst" topic_link: - title: "Topickoppeling" + title: "Topiclink" topic_list_item: title: "Topiclijstitem" topic_statuses: diff --git a/public/403.nl.html b/public/403.nl.html index e7bb3ac1b2..d95332dc30 100644 --- a/public/403.nl.html +++ b/public/403.nl.html @@ -20,7 +20,7 @@

    403

    -

    U kunt die bron niet bekijken!

    +

    Je kunt die bron niet bekijken!

    Dit zal worden vervangen door een eigen Discourse 403-pagina.

    diff --git a/public/422.nl.html b/public/422.nl.html index ed786e9b1b..8fee7bbf9c 100644 --- a/public/422.nl.html +++ b/public/422.nl.html @@ -21,7 +21,7 @@

    De geplande wijziging is geweigerd.

    -

    Misschien probeerde u iets te wijzigen waarvoor u geen toegang hebt.

    +

    Misschien probeerde je iets te wijzigen waartoe je geen toegang hebt.

    diff --git a/public/500.nl.html b/public/500.nl.html index 47ce4f25b9..c3fe2407d0 100644 --- a/public/500.nl.html +++ b/public/500.nl.html @@ -8,6 +8,6 @@

    Oeps

    De software die dit discussieforum mogelijk maakt, ondervond een onverwacht probleem. Onze excuses voor het ongemak.

    Er is gedetailleerde informatie over de fout vastgelegd, en een automatische melding gegenereerd. We zullen ernaar kijken.

    -

    Er is geen verdere actie nodig. Als de foutsituatie echter blijft bestaan, kunt u extra details verstrekken, waaronder stappen om de fout te reproduceren, door een discussietopic in de feedbackcategorie van de website te openen.

    +

    Er is geen verdere actie nodig. Als de foutsituatie echter blijft bestaan, kun je aanvullende informatie verstrekken, waaronder stappen om de fout te reproduceren, door een discussietopic te openen in de feedbackcategorie van de website.

    diff --git a/public/images/push-notifications/inline_reply.png b/public/images/push-notifications/inline_reply.png new file mode 100644 index 0000000000..bafc74baa3 Binary files /dev/null and b/public/images/push-notifications/inline_reply.png differ diff --git a/script/downsize_uploads.rb b/script/downsize_uploads.rb deleted file mode 100644 index 5b3edf885e..0000000000 --- a/script/downsize_uploads.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -require File.expand_path("../../config/environment", __FILE__) - -# Supported ENV arguments: -# -# VERBOSE=1 -# Shows debug information. -# -# INTERACTIVE=1 -# Shows debug information and pauses for input on issues. -# -# WORKER_ID/WORKER_COUNT -# When running the script on a single forum in multiple terminals. -# For example, if you want 4 concurrent scripts use WORKER_COUNT=4 -# and WORKER_ID from 0 to 3 - -MIN_IMAGE_PIXELS = 500_000 # 0.5 megapixels -DEFAULT_IMAGE_PIXELS = 1_000_000 # 1 megapixel - -MAX_IMAGE_PIXELS = [ - ARGV[0]&.to_i || DEFAULT_IMAGE_PIXELS, - MIN_IMAGE_PIXELS -].max - -ENV["VERBOSE"] = "1" if ENV["INTERACTIVE"] - -def log(*args) - puts(*args) if ENV["VERBOSE"] -end - -def process_uploads - puts "", "Downsizing images to no more than #{MAX_IMAGE_PIXELS} pixels" - - dimensions_count = 0 - downsized_count = 0 - - scope = Upload - .by_users - .with_no_non_post_relations - .where("LOWER(extension) IN ('jpg', 'jpeg', 'gif', 'png')") - - scope = scope.where(<<-SQL, MAX_IMAGE_PIXELS) - COALESCE(width, 0) = 0 OR - COALESCE(height, 0) = 0 OR - COALESCE(thumbnail_width, 0) = 0 OR - COALESCE(thumbnail_height, 0) = 0 OR - width * height > ? - SQL - - if ENV["WORKER_ID"] && ENV["WORKER_COUNT"] - scope = scope.where("uploads.id % ? = ?", ENV["WORKER_COUNT"], ENV["WORKER_ID"]) - end - - skipped = 0 - total_count = scope.count - puts "Uploads to process: #{total_count}" - - scope.find_each.with_index do |upload, index| - progress = (index * 100.0 / total_count).round(1) - - log "\n" - print "\r#{progress}% Fixed dimensions: #{dimensions_count} Downsized: #{downsized_count} Skipped: #{skipped} (upload id: #{upload.id})" - log "\n" - - path = if upload.local? - Discourse.store.path_for(upload) - else - (Discourse.store.download(upload, max_file_size_kb: 100.megabytes) rescue nil)&.path - end - - unless path - log "No image path" - skipped += 1 - next - end - - begin - w, h = FastImage.size(path, raise_on_failure: true) - rescue FastImage::UnknownImageType - log "Unknown image type" - skipped += 1 - next - rescue FastImage::SizeNotFound - log "Size not found" - skipped += 1 - next - end - - if !w || !h - log "Invalid image dimensions" - skipped += 1 - next - end - - ww, hh = ImageSizer.resize(w, h) - - if w == 0 || h == 0 || ww == 0 || hh == 0 - log "Invalid image dimensions" - skipped += 1 - next - end - - upload.attributes = { - width: w, - height: h, - thumbnail_width: ww, - thumbnail_height: hh, - filesize: File.size(path) - } - - if upload.changed? - log "Correcting the upload dimensions" - log "Before: #{upload.width_was}x#{upload.height_was} #{upload.thumbnail_width_was}x#{upload.thumbnail_height_was} (#{upload.filesize_was})" - log "After: #{w}x#{h} #{ww}x#{hh} (#{upload.filesize})" - - dimensions_count += 1 - upload.save! - end - - if w * h < MAX_IMAGE_PIXELS - log "Image size within allowed range" - skipped += 1 - next - end - - result = ShrinkUploadedImage.new( - upload: upload, - path: path, - max_pixels: MAX_IMAGE_PIXELS, - verbose: ENV["VERBOSE"], - interactive: ENV["INTERACTIVE"] - ).perform - - if result - downsized_count += 1 - else - skipped += 1 - end - end - - STDIN.beep - puts "", "Done", Time.zone.now -end - -process_uploads diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8b3ae785cc..e00f88c5ea 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -753,9 +753,9 @@ RSpec.describe ApplicationHelper do helper.request.cookies["dark_scheme_id"] = dark.id end - it "renders theme-color meta for the light scheme with media=all and another one for the dark scheme with media=(prefers-color-scheme: dark)" do + it "renders theme-color meta for the light scheme with media=(prefers-color-scheme: light) and another one for the dark scheme with media=(prefers-color-scheme: dark)" do expect(helper.discourse_theme_color_meta_tags).to eq(<<~HTML) - + HTML end diff --git a/spec/integration/discord_omniauth_spec.rb b/spec/integration/discord_omniauth_spec.rb new file mode 100644 index 0000000000..0ca80d1c96 --- /dev/null +++ b/spec/integration/discord_omniauth_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +describe 'Discord OAuth2' do + let(:access_token) { "discord_access_token_448" } + let(:client_id) { "abcdef11223344" } + let(:client_secret) { "adddcccdddd99922" } + let(:temp_code) { "discord_temp_code_544254" } + + fab!(:user1) { Fabricate(:user) } + + def setup_discord_email_stub(email, verified:) + stub_request(:get, "https://discord.com/api/users/@me") + .with( + headers: { + "Authorization" => "Bearer #{access_token}" + } + ) + .to_return( + status: 200, + body: JSON.dump( + id: "80351110224678912", + username: "Nelly", + discriminator: "1337", + avatar: "8342729096ea3675442027381ff50dfe", + verified: verified, + email: email, + flags: 64, + banner: "06c16474723fe537c283b8efa61a30c8", + accent_color: 16711680, + premium_type: 1, + public_flags: 64 + ), + headers: { + "Content-Type" => "application/json" + } + ) + end + + before do + SiteSetting.enable_discord_logins = true + SiteSetting.discord_client_id = client_id + SiteSetting.discord_secret = client_secret + + stub_request(:post, "https://discord.com/api/oauth2/token") + .with( + body: hash_including( + "client_id" => client_id, + "client_secret" => client_secret, + "code" => temp_code, + "grant_type" => "authorization_code", + "redirect_uri" => "http://test.localhost/auth/discord/callback" + ) + ) + .to_return( + status: 200, + body: Rack::Utils.build_query( + access_token: access_token, + scope: "identify emails guilds", + token_type: "Bearer", + expires_in: 604800, + refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue", + ), + headers: { + "Content-Type" => "application/x-www-form-urlencoded" + } + ) + + stub_request(:get, "https://discord.com/api/users/@me/guilds") + .with( + headers: { + "Authorization" => "Bearer #{access_token}" + } + ) + .to_return( + status: 200, + body: JSON.dump( + id: "80351110224678912", + name: "1337 Krew", + icon: "8342729096ea3675442027381ff50dfe", + owner: true, + permissions: "36953089", + features: ["COMMUNITY", "NEWS"] + ), + headers: { + "Content-Type" => "application/json" + } + ) + end + + it "doesn't sign in anyone if the email from discord is not verified" do + post "/auth/discord" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://discord.com/api/oauth2/authorize") + + setup_discord_email_stub(user1.email, verified: false) + + post "/auth/discord/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to be_blank + end + + it "signs in the user if the email from discord is verified and matches the user's email" do + post "/auth/discord" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://discord.com/api/oauth2/authorize") + + setup_discord_email_stub(user1.email, verified: true) + + post "/auth/discord/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to eq(user1.id) + end +end diff --git a/spec/integration/facebook_omniauth_spec.rb b/spec/integration/facebook_omniauth_spec.rb new file mode 100644 index 0000000000..e1bef76668 --- /dev/null +++ b/spec/integration/facebook_omniauth_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +describe 'Facebook OAuth2' do + let(:access_token) { "facebook_access_token_448" } + let(:app_id) { "432489234823984" } + let(:app_secret) { "adddcccdddd99922" } + let(:temp_code) { "facebook_temp_code_544254" } + let(:appsecret_proof) { OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, app_secret, access_token) } + + fab!(:user1) { Fabricate(:user) } + + def setup_facebook_email_stub(email:) + body = { + id: "4923489839597234", + name: "Robot Lizard", + first_name: "Robot", + last_name: "Lizard", + } + body[:email] = email if email + + stub_request(:get, "https://graph.facebook.com/v5.0/me?appsecret_proof=#{appsecret_proof}&fields=name,first_name,last_name,email") + .with( + headers: { + "Authorization" => "OAuth #{access_token}" + } + ) + .to_return( + status: 200, + body: JSON.dump(body), + headers: { + "Content-Type" => "application/json" + } + ) + end + + before do + SiteSetting.enable_facebook_logins = true + SiteSetting.facebook_app_id = app_id + SiteSetting.facebook_app_secret = app_secret + + stub_request(:post, "https://graph.facebook.com/v5.0/oauth/access_token") + .with( + body: hash_including( + "client_id" => app_id, + "client_secret" => app_secret, + "code" => temp_code, + "grant_type" => "authorization_code", + "redirect_uri" => "http://test.localhost/auth/facebook/callback" + ) + ) + .to_return( + status: 200, + body: Rack::Utils.build_query( + access_token: access_token, + scope: "email", + token_type: "Bearer", + ), + headers: { + "Content-Type" => "application/x-www-form-urlencoded" + } + ) + end + + it "signs in the user if the API response from facebook includes an email (implies it's verified) and the email matches an existing user's" do + post "/auth/facebook" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://www.facebook.com/v5.0/dialog/oauth") + + setup_facebook_email_stub(email: user1.email) + + post "/auth/facebook/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to eq(user1.id) + end + + it "doesn't sign in anyone if the API response from facebook doesn't include an email (implying the user's email on facebook isn't verified)" do + post "/auth/facebook" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://www.facebook.com/v5.0/dialog/oauth") + + setup_facebook_email_stub(email: nil) + + post "/auth/facebook/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to be_blank + end +end diff --git a/spec/integration/github_omniauth_spec.rb b/spec/integration/github_omniauth_spec.rb new file mode 100644 index 0000000000..9714526d1b --- /dev/null +++ b/spec/integration/github_omniauth_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +describe 'GitHub Oauth2' do + let(:access_token) { "github_access_token_448" } + let(:client_id) { "abcdef11223344" } + let(:client_secret) { "adddcccdddd99922" } + let(:temp_code) { "github_temp_code_544254" } + + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + def setup_github_emails_stub(emails) + stub_request(:get, "https://api.github.com/user/emails") + .with( + headers: { + "Authorization" => "Bearer #{access_token}" + } + ) + .to_return( + status: 200, + body: JSON.dump(emails), + headers: { + "Content-Type" => "application/json" + } + ) + end + + before do + SiteSetting.enable_github_logins = true + SiteSetting.github_client_id = client_id + SiteSetting.github_client_secret = client_secret + + stub_request(:post, "https://github.com/login/oauth/access_token") + .with( + body: hash_including( + "client_id" => client_id, + "client_secret" => client_secret, + "code" => temp_code, + ) + ) + .to_return( + status: 200, + body: Rack::Utils.build_query( + access_token: access_token, + scope: "user:email", + token_type: "bearer" + ), + headers: { + "Content-Type" => "application/x-www-form-urlencoded" + } + ) + + stub_request(:get, "https://api.github.com/user") + .with( + headers: { + "Authorization" => "Bearer #{access_token}" + } + ) + .to_return( + status: 200, + body: JSON.dump( + login: "octocat", + id: 1, + node_id: "MDQ6VXNlcjE=", + avatar_url: "https://github.com/images/error/octocat_happy.gif", + gravatar_id: "", + url: "https://api.github.com/users/octocat", + html_url: "https://github.com/octocat", + followers_url: "https://api.github.com/users/octocat/followers", + following_url: "https://api.github.com/users/octocat/following{/other_user}", + gists_url: "https://api.github.com/users/octocat/gists{/gist_id}", + starred_url: "https://api.github.com/users/octocat/starred{/owner}{/repo}", + subscriptions_url: "https://api.github.com/users/octocat/subscriptions", + organizations_url: "https://api.github.com/users/octocat/orgs", + repos_url: "https://api.github.com/users/octocat/repos", + events_url: "https://api.github.com/users/octocat/events{/privacy}", + received_events_url: "https://api.github.com/users/octocat/received_events", + type: "User", + site_admin: false, + name: "monalisa octocat", + company: "GitHub", + blog: "https://github.com/blog", + location: "San Francisco", + email: "octocat@github.com", + hireable: false, + bio: "There once was...", + twitter_username: "monatheoctocat", + public_repos: 2, + public_gists: 1, + followers: 20, + following: 0, + created_at: "2008-01-14T04:33:35Z", + updated_at: "2008-01-14T04:33:35Z", + private_gists: 81, + total_private_repos: 100, + owned_private_repos: 100, + disk_usage: 10000, + collaborators: 8, + two_factor_authentication: true, + plan: { + name: "Medium", + space: 400, + private_repos: 20, + collaborators: 0 + } + ), + headers: { + "Content-Type" => "application/json" + } + ) + end + + it "doesn't sign in anyone if none of the emails from github are verified" do + post "/auth/github" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://github.com/login/oauth/authorize?") + + setup_github_emails_stub( + [ + { + email: user1.email, + primary: true, + verified: false, + visibility: "private" + }, + { + email: user2.email, + primary: false, + verified: false, + visibility: "private" + } + ] + ) + + post "/auth/github/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to be_blank + end + + it "matches a non-primary email if it's verified and the primary email isn't" do + post "/auth/github" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://github.com/login/oauth/authorize?") + + setup_github_emails_stub( + [ + { + email: user1.email, + primary: true, + verified: false, + visibility: "private" + }, + { + email: user2.email, + primary: false, + verified: true, + visibility: "private" + } + ] + ) + + post "/auth/github/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to eq(user2.id) + end + + it "doesn't match a non-primary email if it's not verified" do + post "/auth/github" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://github.com/login/oauth/authorize?") + + setup_github_emails_stub( + [ + { + email: "somerandomemail@discourse.org", + primary: true, + verified: true, + visibility: "private" + }, + { + email: user2.email, + primary: false, + verified: false, + visibility: "private" + } + ] + ) + + post "/auth/github/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to be_blank + end + + it "favors the primary email over secondary emails when they're all verified" do + post "/auth/github" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://github.com/login/oauth/authorize?") + + setup_github_emails_stub( + [ + { + email: user1.email, + primary: true, + verified: true, + visibility: "private" + }, + { + email: user2.email, + primary: false, + verified: true, + visibility: "private" + } + ] + ) + + post "/auth/github/callback", params: { + state: session["omniauth.state"], + code: temp_code + } + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to eq(user1.id) + end +end diff --git a/spec/integration/twitter_omniauth_spec.rb b/spec/integration/twitter_omniauth_spec.rb new file mode 100644 index 0000000000..4e4e54793e --- /dev/null +++ b/spec/integration/twitter_omniauth_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +describe 'Twitter OAuth 1.0a' do + let(:access_token) { "twitter_access_token_448" } + let(:consumer_key) { "abcdef11223344" } + let(:consumer_secret) { "adddcccdddd99922" } + let(:oauth_token_secret) { "twitter_temp_code_544254" } + + fab!(:user1) { Fabricate(:user) } + + def setup_twitter_email_stub(email:) + body = { + contributors_enabled: true, + created_at: "Sat May 09 17:58:22 +0000 2009", + default_profile: false, + default_profile_image: false, + description: "I taught your phone that thing you like. The Mobile Partner Engineer @Twitter. ", + favourites_count: 588, + follow_request_sent: nil, + followers_count: 10625, + following: nil, + friends_count: 1181, + geo_enabled: true, + id: 38895958, + id_str: "38895958", + is_translator: false, + lang: "en", + listed_count: 190, + location: "San Francisco", + name: "Sean Cook", + notifications: nil, + profile_background_color: "1A1B1F", + profile_background_image_url: "http://a0.twimg.com/profile_background_images/495742332/purty_wood.png", + profile_background_image_url_https: "https://si0.twimg.com/profile_background_images/495742332/purty_wood.png", + profile_background_tile: true, + profile_image_url: "http://a0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", + profile_image_url_https: "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", + profile_link_color: "2FC2EF", + profile_sidebar_border_color: "181A1E", + profile_sidebar_fill_color: "252429", + profile_text_color: "666666", + profile_use_background_image: true, + protected: false, + screen_name: "theSeanCook", + show_all_inline_media: true, + statuses_count: 2609, + time_zone: "Pacific Time (US & Canada)", + url: nil, + utc_offset: -28800, + verified: true, + email: email + } + stub_request(:get, "https://api.twitter.com/1.1/account/verify_credentials.json") + .with( + query: { + include_email: true, + include_entities: false, + skip_status: true + } + ) + .to_return(status: 200, body: JSON.dump(body)) + end + + before do + SiteSetting.enable_twitter_logins = true + SiteSetting.twitter_consumer_key = consumer_key + SiteSetting.twitter_consumer_secret = consumer_secret + + stub_request(:post, "https://api.twitter.com/oauth/request_token") + .to_return( + status: 200, + body: Rack::Utils.build_query( + oauth_token: access_token, + oauth_token_secret: oauth_token_secret, + oauth_callback_confirmed: true + ), + headers: { + "Content-Type" => "application/x-www-form-urlencoded" + } + ) + stub_request(:post, "https://api.twitter.com/oauth/access_token") + .to_return( + status: 200, + body: Rack::Utils.build_query( + oauth_token: access_token, + oauth_token_secret: oauth_token_secret, + user_id: "43423432422", + screen_name: "twitterapi" + ) + ) + end + + it "signs in the user if the API response from twitter includes an email (implies it's verified) and the email matches an existing user's" do + post "/auth/twitter" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://api.twitter.com/oauth/authenticate") + + setup_twitter_email_stub(email: user1.email) + + post "/auth/twitter/callback", params: { state: session["omniauth.state"] } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to eq(user1.id) + end + + it "doesn't sign in anyone if the API response from twitter doesn't include an email (implying the user's email on twitter isn't verified)" do + post "/auth/twitter" + expect(response.status).to eq(302) + expect(response.location).to start_with("https://api.twitter.com/oauth/authenticate") + + setup_twitter_email_stub(email: nil) + + post "/auth/twitter/callback", params: { state: session["omniauth.state"] } + + expect(response.status).to eq(302) + expect(response.location).to eq("http://test.localhost/") + expect(session[:current_user_id]).to be_blank + end +end diff --git a/spec/jobs/export_user_archive_spec.rb b/spec/jobs/export_user_archive_spec.rb index 09f86a331f..2c047791b2 100644 --- a/spec/jobs/export_user_archive_spec.rb +++ b/spec/jobs/export_user_archive_spec.rb @@ -146,9 +146,13 @@ RSpec.describe Jobs::ExportUserArchive do expect(post2["is_pm"]).to eq(I18n.t("csv_export.boolean_no")) expect(post3["is_pm"]).to eq(I18n.t("csv_export.boolean_yes")) - expect(post1["post"]).to eq(normal_post.raw) - expect(post2["post"]).to eq(subsubpost.raw) - expect(post3["post"]).to eq(message_post.raw) + expect(post1["post_raw"]).to eq(normal_post.raw) + expect(post2["post_raw"]).to eq(subsubpost.raw) + expect(post3["post_raw"]).to eq(message_post.raw) + + expect(post1["post_cooked"]).to eq(normal_post.cooked) + expect(post2["post_cooked"]).to eq(subsubpost.cooked) + expect(post3["post_cooked"]).to eq(message_post.cooked) expect(post1['like_count']).to eq(1) expect(post2['like_count']).to eq(0) diff --git a/spec/jobs/jobs_base_spec.rb b/spec/jobs/jobs_base_spec.rb index 619ab0f8cd..6b267af9c3 100644 --- a/spec/jobs/jobs_base_spec.rb +++ b/spec/jobs/jobs_base_spec.rb @@ -62,7 +62,7 @@ RSpec.describe ::Jobs::Base do end it 'delegates the process call to execute' do - ::Jobs::Base.any_instance.expects(:execute).with('hello' => 'world') + ::Jobs::Base.any_instance.expects(:execute).with({ 'hello' => 'world' }) ::Jobs::Base.new.perform('hello' => 'world', 'sync_exec' => true) end diff --git a/spec/jobs/jobs_spec.rb b/spec/jobs/jobs_spec.rb index 09c4252dec..09406eb66b 100644 --- a/spec/jobs/jobs_spec.rb +++ b/spec/jobs/jobs_spec.rb @@ -106,7 +106,7 @@ RSpec.describe Jobs do end it "executes the job right away" do - Jobs::ProcessPost.any_instance.expects(:perform).with("post_id" => 1, "sync_exec" => true, "current_site_id" => "default") + Jobs::ProcessPost.any_instance.expects(:perform).with({ "post_id" => 1, "sync_exec" => true, "current_site_id" => "default" }) Jobs.enqueue(:process_post, post_id: 1) end diff --git a/spec/lib/discourse_plugin_registry_spec.rb b/spec/lib/discourse_plugin_registry_spec.rb index 162a2f051e..96f84df3df 100644 --- a/spec/lib/discourse_plugin_registry_spec.rb +++ b/spec/lib/discourse_plugin_registry_spec.rb @@ -190,7 +190,7 @@ RSpec.describe DiscoursePluginRegistry do describe '.register_archetype' do it "delegates archetypes to the Archetype component" do - Archetype.expects(:register).with('threaded', hello: 123) + Archetype.expects(:register).with('threaded', { hello: 123 }) registry_instance.register_archetype('threaded', hello: 123) end end diff --git a/spec/lib/file_store/s3_store_spec.rb b/spec/lib/file_store/s3_store_spec.rb index bff47c3ce8..0473c5ab19 100644 --- a/spec/lib/file_store/s3_store_spec.rb +++ b/spec/lib/file_store/s3_store_spec.rb @@ -31,11 +31,12 @@ RSpec.describe FileStore::S3Store do it "returns an absolute schemaless url" do s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_bucket.expects(:object).with(regexp_matches(%r{original/\d+X.*/#{upload.sha1}\.png})).returns(s3_object) - s3_object.expects(:put).with( + s3_object.expects(:put).with({ acl: "public-read", cache_control: "max-age=31556952, public, immutable", content_type: "image/png", - body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) + body: uploaded_file + }).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to match( %r{//s3-upload-bucket\.s3\.dualstack\.us-west-1\.amazonaws\.com/original/\d+X.*/#{upload.sha1}\.png} @@ -69,12 +70,13 @@ RSpec.describe FileStore::S3Store do s3_helper.expects(:s3_bucket).returns(s3_bucket) s3_bucket.expects(:object).with(regexp_matches(%r{original/\d+X.*/#{upload.sha1}\.pdf})).returns(s3_object) - s3_object.expects(:put).with( + s3_object.expects(:put).with({ acl: "private", cache_control: "max-age=31556952, public, immutable", content_type: "application/pdf", content_disposition: "attachment; filename=\"#{upload.original_filename}\"; filename*=UTF-8''#{upload.original_filename}", - body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) + body: uploaded_file + }).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to match( %r{//s3-upload-bucket\.s3\.dualstack\.us-west-1\.amazonaws\.com/original/\d+X.*/#{upload.sha1}\.pdf} @@ -86,11 +88,12 @@ RSpec.describe FileStore::S3Store do s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_bucket.expects(:object).with(regexp_matches(%r{original/\d+X.*/#{upload.sha1}\.png})).returns(s3_object).at_least_once - s3_object.expects(:put).with( + s3_object.expects(:put).with({ acl: "public-read", cache_control: "max-age=31556952, public, immutable", content_type: "image/png", - body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) + body: uploaded_file + }).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to match( %r{//s3-upload-bucket\.s3\.dualstack\.us-west-1\.amazonaws\.com/original/\d+X.*/#{upload.sha1}\.png} diff --git a/spec/lib/guardian_spec.rb b/spec/lib/guardian_spec.rb index feed9f9dac..c6c21bd4ba 100644 --- a/spec/lib/guardian_spec.rb +++ b/spec/lib/guardian_spec.rb @@ -420,6 +420,78 @@ RSpec.describe Guardian do end end + describe 'can_send_private_messages' do + fab!(:suspended_user) { Fabricate(:user, suspended_till: 1.week.from_now, suspended_at: 1.day.ago) } + + it "returns false when the user is nil" do + expect(Guardian.new(nil).can_send_private_messages?).to be_falsey + end + + it "disallows pms to other users if the user is not in the automated trust level used for personal_message_enabled_groups" do + SiteSetting.personal_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_2] + user.update!(trust_level: TrustLevel[1]) + Group.user_trust_level_change!(user.id, TrustLevel[1]) + user.reload + expect(Guardian.new(user).can_send_private_messages?).to be_falsey + user.update!(trust_level: TrustLevel[2]) + Group.user_trust_level_change!(user.id, TrustLevel[2]) + user.reload + expect(Guardian.new(user).can_send_private_messages?).to be_truthy + end + + context "when personal_message_enabled_groups does contain the user" do + let(:group) { Fabricate(:group) } + before do + SiteSetting.personal_message_enabled_groups = group.id + end + + it "returns true" do + expect(Guardian.new(user).can_send_private_messages?).to be_falsey + GroupUser.create(user: user, group: group) + user.reload + expect(Guardian.new(user).can_send_private_messages?).to be_truthy + end + end + + context "when personal_message_enabled_groups does not contain the user" do + let(:group) { Fabricate(:group) } + before do + SiteSetting.personal_message_enabled_groups = group.id + end + + it "returns false if user is not staff member" do + expect(Guardian.new(trust_level_4).can_send_private_messages?).to be_falsey + GroupUser.create(user: trust_level_4, group: group) + trust_level_4.reload + expect(Guardian.new(trust_level_4).can_send_private_messages?).to be_truthy + end + + it "returns true for staff member" do + expect(Guardian.new(moderator).can_send_private_messages?).to be_truthy + expect(Guardian.new(admin).can_send_private_messages?).to be_truthy + end + + it "returns true for bot user" do + expect(Guardian.new(Fabricate(:bot)).can_send_private_messages?).to be_truthy + end + + it "returns true for system user" do + expect(Guardian.new(Discourse.system_user).can_send_private_messages?).to be_truthy + end + end + + context "when author is silenced" do + before do + user.silenced_till = 1.year.from_now + user.save + end + + it "returns true, since there is no target user, we do that check separately" do + expect(Guardian.new(user).can_send_private_messages?).to be_truthy + end + end + end + describe 'can_reply_as_new_topic' do fab!(:topic) { Fabricate(:topic) } fab!(:private_message) { Fabricate(:private_message_topic) } @@ -2728,6 +2800,40 @@ RSpec.describe Guardian do end end + describe "#can_change_primary_group?" do + it "returns false without a logged in user" do + expect(Guardian.new(nil).can_change_primary_group?(user, group)).to eq(false) + end + + it "returns false for regular users" do + expect(Guardian.new(user).can_change_primary_group?(user, group)).to eq(false) + end + + it "returns true for admins" do + expect(Guardian.new(admin).can_change_primary_group?(user, group)).to eq(true) + end + + context "when moderators_manage_categories_and_groups site setting is enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + it "returns true for moderators" do + expect(Guardian.new(moderator).can_change_primary_group?(user, group)).to eq(true) + end + end + + context "when moderators_manage_categories_and_groups site setting is disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "returns false for moderators" do + expect(Guardian.new(moderator).can_change_primary_group?(user, group)).to eq(false) + end + end + end + describe 'can_change_trust_level?' do it 'is false without a logged in user' do diff --git a/spec/lib/onebox/json_ld_spec.rb b/spec/lib/onebox/json_ld_spec.rb index e17bc3fbbe..d668622184 100644 --- a/spec/lib/onebox/json_ld_spec.rb +++ b/spec/lib/onebox/json_ld_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Onebox::JsonLd do invalid_json = "{\"@type\":invalid-json}" doc = Nokogiri::HTML("") Discourse.expects(:warn_exception).with( - instance_of(JSON::ParserError), { message: "Error parsing JSON-LD: #{invalid_json}" } + instance_of(JSON::ParserError), message: "Error parsing JSON-LD: #{invalid_json}" ) json_ld = described_class.new(doc) diff --git a/spec/lib/s3_helper_spec.rb b/spec/lib/s3_helper_spec.rb index 45ef50a3e7..394cc6324b 100644 --- a/spec/lib/s3_helper_spec.rb +++ b/spec/lib/s3_helper_spec.rb @@ -70,7 +70,7 @@ RSpec.describe "S3Helper" do 'some' => 'testing' }.each do |bucket_name, prefix| s3_helper = S3Helper.new(bucket_name, "", client: client) - Aws::S3::Bucket.any_instance.expects(:objects).with(prefix: prefix) + Aws::S3::Bucket.any_instance.expects(:objects).with({ prefix: prefix }) s3_helper.list('testing') end end @@ -212,4 +212,14 @@ RSpec.describe "S3Helper" do expect(s3_helper.ensure_cors!([S3CorsRulesets::BACKUP_DIRECT_UPLOAD])).to eq(false) end end + + describe "#delete_objects" do + let(:s3_helper) { S3Helper.new("test-bucket", "", client: client) } + + it "works" do + # The S3::Client with `stub_responses: true` includes validation of requests. + # If the request were invalid, this spec would raise an error + s3_helper.delete_objects(["object/one.txt", "object/two.txt"]) + end + end end diff --git a/spec/lib/shrink_uploaded_image_spec.rb b/spec/lib/shrink_uploaded_image_spec.rb index 009d4a423a..e3d6fb9ae5 100644 --- a/spec/lib/shrink_uploaded_image_spec.rb +++ b/spec/lib/shrink_uploaded_image_spec.rb @@ -21,6 +21,29 @@ RSpec.describe ShrinkUploadedImage do expect(upload.filesize).to be < filesize_before end + it "updates HotlinkedMedia records when there is an upload for downsized image" do + OptimizedImage.downsize(Discourse.store.path_for(upload), "/tmp/smaller.png", "10000@", filename: upload.original_filename) + smaller_sha1 = Upload.generate_digest("/tmp/smaller.png") + smaller_upload = Fabricate(:image_upload, sha1: smaller_sha1) + + post = Fabricate(:post, raw: "") + post.link_post_uploads + post_hotlinked_media = PostHotlinkedMedia.create!( + post: post, + url: "http://example.com/images/2/2e/Longcat1.png", + upload: upload, + status: :downloaded, + ) + + ShrinkUploadedImage.new( + upload: upload, + path: Discourse.store.path_for(upload), + max_pixels: 10_000, + ).perform + + expect(post_hotlinked_media.reload.upload).to eq(smaller_upload) + end + it "returns false if the image is not used by any models" do result = ShrinkUploadedImage.new( upload: upload, diff --git a/spec/lib/site_settings/yaml_loader_spec.rb b/spec/lib/site_settings/yaml_loader_spec.rb index 38b9d16a28..8c38befb9e 100644 --- a/spec/lib/site_settings/yaml_loader_spec.rb +++ b/spec/lib/site_settings/yaml_loader_spec.rb @@ -53,14 +53,14 @@ RSpec.describe SiteSettings::YamlLoader do end it "can load client settings" do - receiver.expects(:setting).with('category1', 'title', 'Discourse', client: true) - receiver.expects(:setting).with('category2', 'tos_url', '', client: true) - receiver.expects(:setting).with('category2', 'must_approve_users', false, client: true) + receiver.expects(:setting).with('category1', 'title', 'Discourse', { client: true }) + receiver.expects(:setting).with('category2', 'tos_url', '', { client: true }) + receiver.expects(:setting).with('category2', 'must_approve_users', false, { client: true }) receiver.load_yaml(client) end it "can load enum settings" do - receiver.expects(:setting).with('email', 'default_email_digest_frequency', 7, enum: 'DigestEmailSiteSetting') + receiver.expects(:setting).with('email', 'default_email_digest_frequency', 7, { enum: 'DigestEmailSiteSetting' }) receiver.load_yaml(enum) end @@ -76,7 +76,7 @@ RSpec.describe SiteSettings::YamlLoader do end it "can load settings with locale default" do - receiver.expects(:setting).with('search', 'min_search_term_length', 3, min: 2, client: true, locale_default: { zh_CN: 2, zh_TW: 2 }) + receiver.expects(:setting).with('search', 'min_search_term_length', 3, { min: 2, client: true, locale_default: { zh_CN: 2, zh_TW: 2 } }) receiver.load_yaml(locale_default) end end diff --git a/spec/lib/stylesheet/manager_spec.rb b/spec/lib/stylesheet/manager_spec.rb index 5818d846d8..0af2b791a0 100644 --- a/spec/lib/stylesheet/manager_spec.rb +++ b/spec/lib/stylesheet/manager_spec.rb @@ -873,4 +873,44 @@ RSpec.describe Stylesheet::Manager do expect(content).to match(/# sourceMappingURL=[^\/]+\.css\.map\?__ws=test\.localhost/) end end + + describe ".fs_asset_cachebuster" do + it "returns a number in test/development mode" do + expect(Stylesheet::Manager.fs_asset_cachebuster).to match(/\A[0-9]+:[0-9]+\z/) + end + + context "with production mode enabled" do + before do + Stylesheet::Manager.stubs(:use_file_hash_for_cachebuster?).returns(true) + end + + after do + path = Stylesheet::Manager.send(:manifest_full_path) + File.delete(path) if File.exists?(path) + end + + it "returns a hash" do + cachebuster = Stylesheet::Manager.fs_asset_cachebuster + expect(cachebuster).to match(/\A[0-9]+:[0-9a-f]{40}\z/) + end + + it "caches the value on the filesystem" do + initial_cachebuster = Stylesheet::Manager.recalculate_fs_asset_cachebuster! + Stylesheet::Manager.stubs(:list_files).never + expect(Stylesheet::Manager.fs_asset_cachebuster).to eq(initial_cachebuster) + expect(File.read(Stylesheet::Manager.send(:manifest_full_path))).to eq(initial_cachebuster) + end + + it "updates the hash when a file changes" do + original_files = Stylesheet::Manager.send(:list_files) + initial_cachebuster = Stylesheet::Manager.recalculate_fs_asset_cachebuster! + + additional_file_path = "#{Rails.root}/spec/fixtures/plugins/scss_plugin/assets/stylesheets/colors.scss" + Stylesheet::Manager.stubs(:list_files).returns(original_files + [additional_file_path]) + + new_cachebuster = Stylesheet::Manager.recalculate_fs_asset_cachebuster! + expect(new_cachebuster).not_to eq(initial_cachebuster) + end + end + end end diff --git a/spec/lib/theme_store/git_importer_spec.rb b/spec/lib/theme_store/git_importer_spec.rb index 59ed357313..c6905c9575 100644 --- a/spec/lib/theme_store/git_importer_spec.rb +++ b/spec/lib/theme_store/git_importer_spec.rb @@ -1,13 +1,10 @@ - # encoding: utf-8 # frozen_string_literal: true require 'theme_store/git_importer' RSpec.describe ThemeStore::GitImporter do - describe "#import" do - let(:url) { "https://github.com/example/example.git" } let(:trailing_slash_url) { "https://github.com/example/example/" } let(:ssh_url) { "git@github.com:example/example.git" } @@ -27,7 +24,7 @@ RSpec.describe ThemeStore::GitImporter do .expects(:execute_command) .with( { "GIT_TERMINAL_PROMPT" => "0" }, - "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "https://github.com/example/example.git", @temp_folder, { timeout: 20 } + "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "https://github.com/example/example.git", @temp_folder, timeout: 20 ) importer = ThemeStore::GitImporter.new(url) @@ -39,7 +36,7 @@ RSpec.describe ThemeStore::GitImporter do .expects(:execute_command) .with( { "GIT_TERMINAL_PROMPT" => "0" }, - "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "https://github.com/example/example.git", @temp_folder, { timeout: 20 } + "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "https://github.com/example/example.git", @temp_folder, timeout: 20 ) importer = ThemeStore::GitImporter.new(trailing_slash_url) @@ -51,7 +48,7 @@ RSpec.describe ThemeStore::GitImporter do .expects(:execute_command) .with( { "GIT_SSH_COMMAND" => "ssh -i #{@ssh_folder}/id_rsa -o IdentitiesOnly=yes -o IdentityFile=#{@ssh_folder}/id_rsa -o StrictHostKeyChecking=no" }, - "git", "clone", "ssh://git@github.com/example/example.git", @temp_folder, { timeout: 20 } + "git", "clone", "ssh://git@github.com/example/example.git", @temp_folder, timeout: 20 ) importer = ThemeStore::GitImporter.new(ssh_url, private_key: "private_key") @@ -63,7 +60,7 @@ RSpec.describe ThemeStore::GitImporter do .expects(:execute_command) .with( { "GIT_TERMINAL_PROMPT" => "0" }, - "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "-b", branch, "https://github.com/example/example.git", @temp_folder, { timeout: 20 } + "git", "-c", "http.followRedirects=false", "-c", "http.curloptResolve=github.com:443:192.0.2.100", "clone", "-b", branch, "https://github.com/example/example.git", @temp_folder, timeout: 20 ) importer = ThemeStore::GitImporter.new(url, branch: branch) @@ -75,7 +72,7 @@ RSpec.describe ThemeStore::GitImporter do .expects(:execute_command) .with( { "GIT_SSH_COMMAND" => "ssh -i #{@ssh_folder}/id_rsa -o IdentitiesOnly=yes -o IdentityFile=#{@ssh_folder}/id_rsa -o StrictHostKeyChecking=no" }, - "git", "clone", "-b", branch, "ssh://git@github.com/example/example.git", @temp_folder, { timeout: 20 } + "git", "clone", "-b", branch, "ssh://git@github.com/example/example.git", @temp_folder, timeout: 20 ) importer = ThemeStore::GitImporter.new(ssh_url, private_key: "private_key", branch: branch) diff --git a/spec/models/invite_redeemer_spec.rb b/spec/models/invite_redeemer_spec.rb index e944f45903..a978a688ec 100644 --- a/spec/models/invite_redeemer_spec.rb +++ b/spec/models/invite_redeemer_spec.rb @@ -3,6 +3,83 @@ RSpec.describe InviteRedeemer do fab!(:admin) { Fabricate(:admin) } + describe "#initialize" do + fab!(:redeeming_user) { Fabricate(:user, email: "redeemer@test.com") } + + context "for invite link" do + fab!(:invite) { Fabricate(:invite, email: nil) } + + context "when an email is passed in without a redeeming user" do + it "uses that email for invite redemption" do + redeemer = described_class.new(invite: invite, email: "blah@test.com") + expect(redeemer.email).to eq("blah@test.com") + expect { redeemer.redeem }.to change { User.count } + expect(User.find_by_email(redeemer.email)).to be_present + end + end + + context "when an email is passed in with a redeeming user" do + it "uses the redeeming user's email for invite redemption" do + redeemer = described_class.new(invite: invite, email: "blah@test.com", redeeming_user: redeeming_user) + expect(redeemer.email).to eq(redeeming_user.email) + expect { redeemer.redeem }.not_to change { User.count } + end + end + + context "when an email is not passed in with a redeeming user" do + it "uses the redeeming user's email for invite redemption" do + redeemer = described_class.new(invite: invite, email: nil, redeeming_user: redeeming_user) + expect(redeemer.email).to eq(redeeming_user.email) + expect { redeemer.redeem }.not_to change { User.count } + end + end + + context "when no email and no redeeming user is passed in" do + it "raises an error" do + expect { described_class.new(invite: invite, email: nil, redeeming_user: nil) }.to raise_error(Discourse::InvalidParameters) + end + end + end + + context "for invite with email" do + fab!(:invite) { Fabricate(:invite, email: "foobar@example.com") } + + context "when an email is passed in without a redeeming user" do + it "uses that email for invite redemption" do + redeemer = described_class.new(invite: invite, email: "foobar@example.com") + expect(redeemer.email).to eq("foobar@example.com") + expect { redeemer.redeem }.to change { User.count } + expect(User.find_by_email(redeemer.email)).to be_present + end + end + + context "when an email is passed in with a redeeming user" do + it "uses the redeeming user's email for invite redemption" do + redeemer = described_class.new(invite: invite, email: "blah@test.com", redeeming_user: redeeming_user) + expect(redeemer.email).to eq(redeeming_user.email) + expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email")) + end + end + + context "when an email is not passed in with a redeeming user" do + it "uses the invite email for invite redemption" do + redeemer = described_class.new(invite: invite, email: nil, redeeming_user: redeeming_user) + expect(redeemer.email).to eq("foobar@example.com") + expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email")) + end + end + + context "when no email and no redeeming user is passed in" do + it "uses the invite email for invite redemption" do + redeemer = described_class.new(invite: invite, email: nil, redeeming_user: nil) + expect(redeemer.email).to eq("foobar@example.com") + expect { redeemer.redeem }.to change { User.count } + expect(User.find_by_email(redeemer.email)).to be_present + end + end + end + end + describe '.create_user_from_invite' do it "should be created correctly" do invite = Fabricate(:invite, email: 'walter.white@email.com') @@ -113,148 +190,199 @@ RSpec.describe InviteRedeemer do end describe "#redeem" do - fab!(:invite) { Fabricate(:invite, email: "foobar@example.com") } let(:name) { 'john snow' } let(:username) { 'kingofthenorth' } let(:password) { 'know5nOthiNG' } let(:invite_redeemer) { InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name) } - context "when must_approve_users setting is enabled" do - before do - SiteSetting.must_approve_users = true + context "with email" do + fab!(:invite) { Fabricate(:invite, email: "foobar@example.com") } + context "when must_approve_users setting is enabled" do + before do + SiteSetting.must_approve_users = true + end + + it "should redeem an invite but not approve the user when invite is created by a staff user" do + inviter = invite.invited_by + inviter.update!(admin: true) + user = invite_redeemer.redeem + + expect(user.name).to eq(name) + expect(user.username).to eq(username) + expect(user.invited_by).to eq(inviter) + expect(user.approved).to eq(false) + + expect(inviter.notifications.count).to eq(1) + end + + it "should redeem the invite but not approve the user when invite is created by a regular user" do + inviter = invite.invited_by + user = invite_redeemer.redeem + + expect(user.name).to eq(name) + expect(user.username).to eq(username) + expect(user.invited_by).to eq(inviter) + expect(user.approved).to eq(false) + + expect(inviter.notifications.count).to eq(1) + end + + it "should redeem the invite and approve the user when user email is in auto_approve_email_domains setting" do + SiteSetting.auto_approve_email_domains = "example.com" + user = invite_redeemer.redeem + + expect(user.name).to eq(name) + expect(user.username).to eq(username) + expect(user.approved).to eq(true) + expect(user.approved_by).to eq(Discourse.system_user) + end end - it "should redeem an invite but not approve the user when invite is created by a staff user" do - inviter = invite.invited_by - inviter.update!(admin: true) - user = invite_redeemer.redeem - - expect(user.name).to eq(name) - expect(user.username).to eq(username) - expect(user.invited_by).to eq(inviter) - expect(user.approved).to eq(false) - - expect(inviter.notifications.count).to eq(1) - end - - it "should redeem the invite but not approve the user when invite is created by a regular user" do + it "should redeem the invite if invited by non staff and approve if staff not required to approve" do inviter = invite.invited_by user = invite_redeemer.redeem expect(user.name).to eq(name) expect(user.username).to eq(username) expect(user.invited_by).to eq(inviter) - expect(user.approved).to eq(false) - expect(inviter.notifications.count).to eq(1) + expect(user.approved).to eq(false) end - it "should redeem the invite and approve the user when user email is in auto_approve_email_domains setting" do - SiteSetting.auto_approve_email_domains = "example.com" + it "should delete invite if invited_by user has been removed" do + invite.invited_by.destroy! + expect { invite.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "can set password" do + user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem + expect(user).to have_password + expect(user.confirm_password?(password)).to eq(true) + expect(user.approved).to eq(false) + end + + it "can set custom fields" do + required_field = Fabricate(:user_field) + optional_field = Fabricate(:user_field, required: false) + user_fields = { + required_field.id.to_s => 'value1', + optional_field.id.to_s => 'value2' + } + user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password, user_custom_fields: user_fields).redeem + + expect(user).to be_present + expect(user.custom_fields["user_field_#{required_field.id}"]).to eq('value1') + expect(user.custom_fields["user_field_#{optional_field.id}"]).to eq('value2') + end + + it "does not add user to group if inviter does not have permissions" do + group = Fabricate(:group, grant_trust_level: 2) + InvitedGroup.create(group_id: group.id, invite_id: invite.id) + user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem + + expect(user.group_users.count).to eq(0) + end + + it "adds user to group" do + group = Fabricate(:group, grant_trust_level: 2) + InvitedGroup.create(group_id: group.id, invite_id: invite.id) + group.add_owner(invite.invited_by) + + user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem + + expect(user.group_users.count).to eq(4) + expect(user.trust_level).to eq(2) + end + + it "adds an entry to the group logs when the invited user is added to a group" do + group = Fabricate(:group) + InvitedGroup.create(group_id: group.id, invite_id: invite.id) + group.add_owner(invite.invited_by) + + GroupHistory.destroy_all + + user = InviteRedeemer.new( + invite: invite, + email: invite.email, + username: username, + name: name, + password: password + ).redeem + + expect(group.reload.usernames.split(",")).to include(user.username) + expect(GroupHistory.exists?( + target_user_id: user.id, + acting_user: invite.invited_by.id, + group_id: group.id, + action: GroupHistory.actions[:add_user_to_group] + )).to eq(true) + end + + it "only allows one user to be created per invite" do user = invite_redeemer.redeem + invite.reload - expect(user.name).to eq(name) - expect(user.username).to eq(username) - expect(user.approved).to eq(true) - expect(user.approved_by).to eq(Discourse.system_user) + user.email = "john@example.com" + user.save! + + another_invite_redeemer = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name) + another_user = another_invite_redeemer.redeem + expect(another_user).to eq(nil) end - end - it "should redeem the invite if invited by non staff and approve if staff not required to approve" do - inviter = invite.invited_by - user = invite_redeemer.redeem + it "should correctly update the invite redeemed_at date" do + SiteSetting.invite_expiry_days = 2 + invite.update!(created_at: 10.days.ago) - expect(user.name).to eq(name) - expect(user.username).to eq(username) - expect(user.invited_by).to eq(inviter) - expect(inviter.notifications.count).to eq(1) - expect(user.approved).to eq(false) - end + inviter = invite.invited_by + inviter.admin = true + user = invite_redeemer.redeem + invite.reload - it "should delete invite if invited_by user has been removed" do - invite.invited_by.destroy! - expect { invite.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "can set password" do - user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem - expect(user).to have_password - expect(user.confirm_password?(password)).to eq(true) - expect(user.approved).to eq(false) - end - - it "can set custom fields" do - required_field = Fabricate(:user_field) - optional_field = Fabricate(:user_field, required: false) - user_fields = { - required_field.id.to_s => 'value1', - optional_field.id.to_s => 'value2' - } - user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password, user_custom_fields: user_fields).redeem - - expect(user).to be_present - expect(user.custom_fields["user_field_#{required_field.id}"]).to eq('value1') - expect(user.custom_fields["user_field_#{optional_field.id}"]).to eq('value2') - end - - it "does not add user to group if inviter does not have permissions" do - group = Fabricate(:group, grant_trust_level: 2) - InvitedGroup.create(group_id: group.id, invite_id: invite.id) - user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem - - expect(user.group_users.count).to eq(0) - end - - it "adds user to group" do - group = Fabricate(:group, grant_trust_level: 2) - InvitedGroup.create(group_id: group.id, invite_id: invite.id) - group.add_owner(invite.invited_by) - - user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem - - expect(user.group_users.count).to eq(4) - expect(user.trust_level).to eq(2) - end - - it "only allows one user to be created per invite" do - user = invite_redeemer.redeem - invite.reload - - user.email = "john@example.com" - user.save! - - another_invite_redeemer = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name) - another_user = another_invite_redeemer.redeem - expect(another_user).to eq(nil) - end - - it "should correctly update the invite redeemed_at date" do - SiteSetting.invite_expiry_days = 2 - invite.update!(created_at: 10.days.ago) - - inviter = invite.invited_by - inviter.admin = true - user = invite_redeemer.redeem - invite.reload - - expect(user.invited_by).to eq(inviter) - expect(inviter.notifications.count).to eq(1) - expect(invite.invited_users.first).to be_present - end - - it "raises an error if the email does not match the invite email" do - redeemer = InviteRedeemer.new(invite: invite, email: "blah@test.com", username: username, name: name) - expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email")) - end - - context "when a redeeming user is passed in" do - fab!(:redeeming_user) { Fabricate(:user, email: "foobar@example.com") } + expect(user.invited_by).to eq(inviter) + expect(inviter.notifications.count).to eq(1) + expect(invite.invited_users.first).to be_present + end it "raises an error if the email does not match the invite email" do - redeeming_user.update!(email: "foo@bar.com") - redeemer = InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user) + redeemer = InviteRedeemer.new(invite: invite, email: "blah@test.com", username: username, name: name) expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email")) end + + it "adds the user to the appropriate private topic and no others" do + topic1 = Fabricate(:private_message_topic) + topic2 = Fabricate(:private_message_topic) + TopicInvite.create(invite: invite, topic: topic1) + user = InviteRedeemer.new(invite: invite, email: invite.email, username: username, name: name, password: password).redeem + expect(TopicAllowedUser.exists?(topic: topic1, user: user)).to eq(true) + expect(TopicAllowedUser.exists?(topic: topic2, user: user)).to eq(false) + end + + context "when a redeeming user is passed in" do + fab!(:redeeming_user) { Fabricate(:user, email: "foobar@example.com") } + + it "raises an error if the email does not match the invite email" do + redeeming_user.update!(email: "foo@bar.com") + redeemer = InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user) + expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email")) + end + + it "adds the user to the appropriate private topic and no others" do + topic1 = Fabricate(:private_message_topic) + topic2 = Fabricate(:private_message_topic) + TopicInvite.create(invite: invite, topic: topic1) + InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user).redeem + expect(TopicAllowedUser.exists?(topic: topic1, user: redeeming_user)).to eq(true) + expect(TopicAllowedUser.exists?(topic: topic2, user: redeeming_user)).to eq(false) + end + + it "does not create a topic allowed user record if the invited user is already in the topic" do + topic1 = Fabricate(:private_message_topic) + TopicInvite.create(invite: invite, topic: topic1) + TopicAllowedUser.create(topic: topic1, user: redeeming_user) + expect { InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user).redeem }.not_to change { TopicAllowedUser.count } + end + end end context 'with domain' do diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index dae205cdaf..930398864d 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -1289,12 +1289,12 @@ RSpec.describe Report do end it "caches exception reports for 1 minute" do - Discourse.cache.expects(:write).with(Report.cache_key(exception_report), exception_report.as_json, { expires_in: 1.minute }) + Discourse.cache.expects(:write).with(Report.cache_key(exception_report), exception_report.as_json, expires_in: 1.minute) Report.cache(exception_report) end it "caches valid reports for 35 minutes" do - Discourse.cache.expects(:write).with(Report.cache_key(valid_report), valid_report.as_json, { expires_in: 35.minutes }) + Discourse.cache.expects(:write).with(Report.cache_key(valid_report), valid_report.as_json, expires_in: 35.minutes) Report.cache(valid_report) end end diff --git a/spec/models/reviewable_user_spec.rb b/spec/models/reviewable_user_spec.rb index 0c3440a00c..ece77650c4 100644 --- a/spec/models/reviewable_user_spec.rb +++ b/spec/models/reviewable_user_spec.rb @@ -138,7 +138,7 @@ RSpec.describe ReviewableUser, type: :model do it "optionally sends email with reject reason" do SiteSetting.must_approve_users = true - Jobs::CriticalUserEmail.any_instance.expects(:execute).with(type: :signup_after_reject, user_id: reviewable.target_id, reject_reason: "reject reason").once + Jobs::CriticalUserEmail.any_instance.expects(:execute).with({ type: :signup_after_reject, user_id: reviewable.target_id, reject_reason: "reject reason" }).once reviewable.perform(moderator, :delete_user_block, reject_reason: "reject reason", send_email: true) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ca58071bb1..b77e39ff74 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -53,7 +53,20 @@ RSpec.describe User do tag.id ) - user = Fabricate(:admin) + admin = Fabricate(:admin) + + expect(SidebarSectionLink.where(linkable_type: 'Category', user_id: admin.id).pluck(:linkable_id)).to contain_exactly( + category.id, + secured_category.id + ) + + expect(SidebarSectionLink.where(linkable_type: 'Tag', user_id: admin.id).pluck(:linkable_id)).to contain_exactly( + tag.id, + hidden_tag.id + ) + + # A user promoted to admin should get secured sidebar records + user.update(admin: true) expect(SidebarSectionLink.where(linkable_type: 'Category', user_id: user.id).pluck(:linkable_id)).to contain_exactly( category.id, @@ -1917,6 +1930,7 @@ RSpec.describe User do SiteSetting.default_other_dynamic_favicon = true SiteSetting.default_other_skip_new_user_tips = true + SiteSetting.default_hide_profile_and_presence = true SiteSetting.default_topics_automatic_unpin = false SiteSetting.default_categories_watching = category0.id.to_s @@ -1937,6 +1951,7 @@ RSpec.describe User do expect(options.enable_quoting).to eq(false) expect(options.dynamic_favicon).to eq(true) expect(options.skip_new_user_tips).to eq(true) + expect(options.hide_profile_and_presence).to eq(true) expect(options.automatically_unpin_topics).to eq(false) expect(options.new_topic_duration_minutes).to eq(-1) expect(options.auto_track_topics_after_msecs).to eq(0) diff --git a/spec/multisite/s3_store_spec.rb b/spec/multisite/s3_store_spec.rb index b10aeffb8b..675484ae94 100644 --- a/spec/multisite/s3_store_spec.rb +++ b/spec/multisite/s3_store_spec.rb @@ -198,7 +198,7 @@ RSpec.describe 'Multisite s3 uploads', type: :multisite do s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_bucket.expects(:object).with("#{upload_path}/#{path}").returns(s3_object).at_least_once - s3_object.expects(:presigned_url).with(:get, expires_in: SiteSetting.s3_presigned_get_url_expires_after_seconds) + s3_object.expects(:presigned_url).with(:get, { expires_in: SiteSetting.s3_presigned_get_url_expires_after_seconds }) upload.url = store.store_upload(uploaded_file, upload) expect(upload.url).to eq( diff --git a/spec/requests/admin/admin_controller_spec.rb b/spec/requests/admin/admin_controller_spec.rb index 5b209798e5..41d067f36d 100644 --- a/spec/requests/admin/admin_controller_spec.rb +++ b/spec/requests/admin/admin_controller_spec.rb @@ -1,19 +1,65 @@ # frozen_string_literal: true RSpec.describe Admin::AdminController do - describe '#index' do - it "needs you to be logged in" do - get "/admin.json" - expect(response.status).to eq(404) + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + + describe "#index" do + context "when unauthenticated" do + it "denies access with a 404 response" do + get "/admin.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it "should return the right response if user isn't a staff" do - sign_in(Fabricate(:user)) - get "/admin", params: { api_key: 'asdiasiduga' } - expect(response.status).to eq(404) + context "when authenticated" do + context "as an admin" do + it "permits access with a 200 response" do + sign_in(admin) + get "/admin.json" - get "/admin" - expect(response.status).to eq(404) + expect(response.status).to eq(200) + end + end + + context "as a non-admin" do + it "denies access with a 403 response" do + sign_in(moderator) + get "/admin.json" + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + end + end + + context "when user is admin with api key" do + it "permits access with a 200 response" do + api_key = Fabricate(:api_key, user: admin) + + get "/admin.json", headers: { + HTTP_API_KEY: api_key.key, + HTTP_API_USERNAME: admin.username + } + + expect(response.status).to eq(200) + end + end + + context "when user is a non-admin with api key" do + it "denies access with a 403 response" do + api_key = Fabricate(:api_key, user: moderator) + + get "/admin.json", headers: { + HTTP_API_KEY: api_key.key, + HTTP_API_USERNAME: moderator.username + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + end + end end end end diff --git a/spec/requests/admin/api_controller_spec.rb b/spec/requests/admin/api_controller_spec.rb index 454d3a5f81..ccc4db5741 100644 --- a/spec/requests/admin/api_controller_spec.rb +++ b/spec/requests/admin/api_controller_spec.rb @@ -1,47 +1,73 @@ # frozen_string_literal: true RSpec.describe Admin::ApiController do - - it "is a subclass of AdminController" do - expect(Admin::ApiController < Admin::AdminController).to eq(true) - end - fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } fab!(:key1, refind: false) { Fabricate(:api_key, description: "my key") } fab!(:key2, refind: false) { Fabricate(:api_key, user: admin) } fab!(:key3, refind: false) { Fabricate(:api_key, user: admin) } - context "as an admin" do - before do - sign_in(admin) - end + describe '#index' do + context "when logged in as an admin" do + before { sign_in(admin) } - describe '#index' do - it "succeeds" do + it "returns keys successfully" do get "/admin/api/keys.json" + expect(response.status).to eq(200) expect(response.parsed_body["keys"].length).to eq(3) end it "can paginate results" do get "/admin/api/keys.json?offset=0&limit=2" + expect(response.status).to eq(200) expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key3.id, key2.id) get "/admin/api/keys.json?offset=1&limit=2" + expect(response.status).to eq(200) expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key2.id, key1.id) get "/admin/api/keys.json?offset=2&limit=2" + expect(response.status).to eq(200) expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key1.id) end end - describe '#show' do - it "succeeds" do + shared_examples "keys inaccessible" do + it "denies keys access with a 404 response" do + get "/admin/api/keys.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["keys"]).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "keys inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "keys inaccessible" + end + end + + describe '#show' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "returns key successfully" do get "/admin/api/keys/#{key1.id}.json" + expect(response.status).to eq(200) data = response.parsed_body["key"] expect(data["id"]).to eq(key1.id) @@ -51,7 +77,33 @@ RSpec.describe Admin::ApiController do end end - describe '#update' do + shared_examples "key inaccessible" do + it "denies key access with a 404 response" do + get "/admin/api/keys/#{key1.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["key"]).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key inaccessible" + end + end + + describe '#update' do + context "when logged in as an admin" do + before { sign_in(admin) } + it "allows updating the description" do original_key = key1.key @@ -82,7 +134,44 @@ RSpec.describe Admin::ApiController do end end - describe "#destroy" do + shared_examples "key update not allowed" do + it "prevents key updates with a 404 response" do + key1.reload + original_key = key1.key + original_description = key1.description + + put "/admin/api/keys/#{key1.id}.json", params: { + key: { + description: "my new description", + key: "overridekey" + } + } + + key1.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(key1.description).to eq(original_description) + expect(key1.key).to eq(original_key) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key update not allowed" + end + end + + describe "#destroy" do + context "when logged in as an admin" do + before { sign_in(admin) } + it "works" do expect(ApiKey.exists?(key1.id)).to eq(true) @@ -96,7 +185,35 @@ RSpec.describe Admin::ApiController do end end - describe "#create" do + shared_examples "key deletion not allowed" do + it "prevents key deletion with a 404 response" do + expect(ApiKey.exists?(key1.id)).to eq(true) + + delete "/admin/api/keys/#{key1.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(ApiKey.exists?(key1.id)).to eq(true) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key deletion not allowed" + end + end + + describe "#create" do + context "when logged in as an admin" do + before { sign_in(admin) } + it "can create a master key" do post "/admin/api/keys.json", params: { key: { @@ -207,7 +324,37 @@ RSpec.describe Admin::ApiController do end end - describe "#revoke and #undo_revoke" do + shared_examples "key creation not allowed" do + it "prevents key creation with a 404 response" do + post "/admin/api/keys.json", params: { + key: { + description: "master key description" + } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["key"]).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key creation not allowed" + end + end + + describe "#revoke and #undo_revoke" do + context "when logged in as an admin" do + before { sign_in(admin) } + it "works correctly" do post "/admin/api/keys/#{key1.id}/revoke.json" expect(response.status).to eq 200 @@ -229,37 +376,79 @@ RSpec.describe Admin::ApiController do end end - describe '#scopes' do - it 'includes scopes' do - get '/admin/api/keys/scopes.json' + shared_examples "key revocation/revocation undoing not allowed" do + it "prevents revoking/un-revoking key with a 404 response" do + key1.reload + post "/admin/api/keys/#{key1.id}/revoke.json" - scopes = response.parsed_body['scopes'] + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(key1.revoked_at).to eq(nil) - expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts', 'uploads', 'global', 'badges', 'categories', 'wordpress') + post "/admin/api/keys/#{key1.id}/undo-revoke.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(key1.revoked_at).to eq(nil) end end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key revocation/revocation undoing not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key revocation/revocation undoing not allowed" + end end - context "as a moderator" do - before do - sign_in(Fabricate(:moderator)) + describe "#scopes" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "includes scopes" do + get "/admin/api/keys/scopes.json" + + scopes = response.parsed_body["scopes"] + + expect(scopes.keys).to contain_exactly( + "topics", + "users", + "email", + "posts", + "uploads", + "global", + "badges", + "categories", + "wordpress" + ) + end end - it "doesn't allow access" do - get "/admin/api/keys.json" - expect(response.status).to eq(404) + shared_examples "key scopes inaccessible" do + it "denies key scopes access with a 404 response" do + get "/admin/api/keys/scopes.json" - get "/admin/api/key/#{key1.id}.json" - expect(response.status).to eq(404) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["scopes"]).to be_nil + end + end - post "/admin/api/keys.json", params: { - key: { - description: "master key description" - } - } - expect(response.status).to eq(404) + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(ApiKey.count).to eq(3) + include_examples "key scopes inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key scopes inaccessible" end end end diff --git a/spec/requests/admin/backups_controller_spec.rb b/spec/requests/admin/backups_controller_spec.rb index f3cfdda31a..069e6f1859 100644 --- a/spec/requests/admin/backups_controller_spec.rb +++ b/spec/requests/admin/backups_controller_spec.rb @@ -2,6 +2,9 @@ RSpec.describe Admin::BackupsController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + let(:backup_filename) { "2014-02-10-065935.tar.gz" } let(:backup_filename2) { "2014-02-11-065935.tar.gz" } @@ -23,12 +26,7 @@ RSpec.describe Admin::BackupsController do end.to_h end - it "is a subclass of AdminController" do - expect(Admin::BackupsController < Admin::AdminController).to eq(true) - end - before do - sign_in(admin) SiteSetting.backup_location = BackupLocationSiteSetting::LOCAL end @@ -40,201 +38,544 @@ RSpec.describe Admin::BackupsController do end describe "#index" do - it "raises an error when backups are disabled" do - SiteSetting.enable_backups = false - get "/admin/backups.json" - expect(response.status).to eq(403) + context "when logged in as an admin" do + before { sign_in(admin) } + + it "raises an error when backups are disabled" do + SiteSetting.enable_backups = false + get "/admin/backups.json" + expect(response.status).to eq(403) + end + + context "with html format" do + it "preloads important data" do + get "/admin/backups.html" + expect(response.status).to eq(200) + + preloaded = map_preloaded + expect(preloaded["operations_status"].symbolize_keys).to eq(BackupRestore.operations_status) + expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) + end + end + + context "with json format" do + it "returns a list of all the backups" do + begin + create_backup_files(backup_filename, backup_filename2) + + get "/admin/backups.json" + expect(response.status).to eq(200) + + filenames = response.parsed_body.map { |backup| backup["filename"] } + expect(filenames).to include(backup_filename) + expect(filenames).to include(backup_filename2) + end + end + end end - context "with html format" do - it "preloads important data" do + shared_examples "backups inaccessible" do + it "denies access with a 404 response" do get "/admin/backups.html" + + expect(response.status).to eq(404) + + get "/admin/backups.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backups inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backups inaccessible" + end + end + + describe '#status' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "returns the current backups status" do + get "/admin/backups/status.json" + expect(response.body).to eq(BackupRestore.operations_status.to_json) + expect(response.status).to eq(200) + end + end + + shared_examples "status inaccessible" do + it "denies access with a 404 response" do + get "/admin/backups/status.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "status inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "status inaccessible" + end + end + + describe '#create' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "starts a backup" do + BackupRestore.expects(:backup!).with(admin.id, { publish_to_message_bus: true, with_uploads: false, client_id: "foo" }) + + post "/admin/backups.json", params: { + with_uploads: false, client_id: "foo" + } + + expect(response.status).to eq(200) + end + end + + shared_examples "backups creation not allowed" do + it "prevents backups creation with a 404 response" do + post "/admin/backups.json", params: { + with_uploads: false, + client_id: "foo" + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backups creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backups creation not allowed" + end + end + + describe '#show' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "uses send_file to transmit the backup" do + begin + token = EmailBackupToken.set(admin.id) + create_backup_files(backup_filename) + + expect do + get "/admin/backups/#{backup_filename}.json", params: { token: token } + end.to change { UserHistory.where(action: UserHistory.actions[:backup_download]).count }.by(1) + + expect(response.headers['Content-Length']).to eq("11") + expect(response.headers['Content-Disposition']).to match(/attachment; filename/) + end + end + + it "returns 422 when token is bad" do + begin + get "/admin/backups/#{backup_filename}.json", params: { token: "bad_value" } + + expect(response.status).to eq(422) + expect(response.headers['Content-Disposition']).not_to match(/attachment; filename/) + expect(response.body).to include(I18n.t("download_backup_mailer.no_token")) + end + end + + it "returns 404 when the backup does not exist" do + token = EmailBackupToken.set(admin.id) + get "/admin/backups/#{backup_filename}.json", params: { token: token } + + expect(response.status).to eq(404) + end + end + + shared_examples "backup inaccessible" do + it "denies access with a 404 response" do + begin + token = EmailBackupToken.set(admin.id) + create_backup_files(backup_filename) + + expect do + get "/admin/backups/#{backup_filename}.json", params: { token: token } + end.not_to change { UserHistory.where(action: UserHistory.actions[:backup_download]).count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.headers['Content-Disposition']).not_to match(/attachment; filename/) + end + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup inaccessible" + end + end + + describe '#destroy' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "removes the backup if found" do + begin + path = backup_path(backup_filename) + create_backup_files(backup_filename) + expect(File.exist?(path)).to eq(true) + + expect do + delete "/admin/backups/#{backup_filename}.json" + end.to change { UserHistory.where(action: UserHistory.actions[:backup_destroy]).count }.by(1) + + expect(response.status).to eq(200) + expect(File.exist?(path)).to eq(false) + end + end + + it "doesn't remove the backup if not found" do + delete "/admin/backups/#{backup_filename}.json" + expect(response.status).to eq(404) + end + end + + shared_examples "backup deletion not allowed" do + it "prevents deletion with a 404 response" do + begin + path = backup_path(backup_filename) + create_backup_files(backup_filename) + expect(File.exist?(path)).to eq(true) + + expect do + delete "/admin/backups/#{backup_filename}.json" + end.not_to change { UserHistory.where(action: UserHistory.actions[:backup_destroy]).count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(File.exist?(path)).to eq(true) + end + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup deletion not allowed" + end + end + + describe '#logs' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "preloads important data" do + get "/admin/backups/logs.html" expect(response.status).to eq(200) preloaded = map_preloaded + expect(preloaded["operations_status"].symbolize_keys).to eq(BackupRestore.operations_status) expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) end end - context "with json format" do - it "returns a list of all the backups" do - begin - create_backup_files(backup_filename, backup_filename2) + shared_examples "backup logs inaccessible" do + it "denies access with a 404 response" do + get "/admin/backups/logs.html" - get "/admin/backups.json" - expect(response.status).to eq(200) - - filenames = response.parsed_body.map { |backup| backup["filename"] } - expect(filenames).to include(backup_filename) - expect(filenames).to include(backup_filename2) - end - end - end - end - - describe '#status' do - it "returns the current backups status" do - get "/admin/backups/status.json" - expect(response.body).to eq(BackupRestore.operations_status.to_json) - expect(response.status).to eq(200) - end - end - - describe '#create' do - it "starts a backup" do - BackupRestore.expects(:backup!).with(admin.id, publish_to_message_bus: true, with_uploads: false, client_id: "foo") - - post "/admin/backups.json", params: { - with_uploads: false, client_id: "foo" - } - - expect(response.status).to eq(200) - end - end - - describe '#show' do - it "uses send_file to transmit the backup" do - begin - token = EmailBackupToken.set(admin.id) - create_backup_files(backup_filename) - - expect do - get "/admin/backups/#{backup_filename}.json", params: { token: token } - end.to change { UserHistory.where(action: UserHistory.actions[:backup_download]).count }.by(1) - - expect(response.headers['Content-Length']).to eq("11") - expect(response.headers['Content-Disposition']).to match(/attachment; filename/) + expect(response.status).to eq(404) end end - it "returns 422 when token is bad" do - begin - get "/admin/backups/#{backup_filename}.json", params: { token: "bad_value" } + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(response.status).to eq(422) - expect(response.headers['Content-Disposition']).not_to match(/attachment; filename/) - expect(response.body).to include(I18n.t("download_backup_mailer.no_token")) - end + include_examples "backup logs inaccessible" end - it "returns 404 when the backup does not exist" do - token = EmailBackupToken.set(admin.id) - get "/admin/backups/#{backup_filename}.json", params: { token: token } + context "when logged in as a non-staff user" do + before { sign_in(user) } - expect(response.status).to eq(404) - end - end - - describe '#destroy' do - it "removes the backup if found" do - begin - path = backup_path(backup_filename) - create_backup_files(backup_filename) - expect(File.exist?(path)).to eq(true) - - expect do - delete "/admin/backups/#{backup_filename}.json" - end.to change { UserHistory.where(action: UserHistory.actions[:backup_destroy]).count }.by(1) - - expect(response.status).to eq(200) - expect(File.exist?(path)).to eq(false) - end - end - - it "doesn't remove the backup if not found" do - delete "/admin/backups/#{backup_filename}.json" - expect(response.status).to eq(404) - end - end - - describe '#logs' do - it "preloads important data" do - get "/admin/backups/logs.html" - expect(response.status).to eq(200) - - preloaded = map_preloaded - - expect(preloaded["operations_status"].symbolize_keys).to eq(BackupRestore.operations_status) - expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) + include_examples "backup logs inaccessible" end end describe '#restore' do - it "starts a restore" do - BackupRestore.expects(:restore!).with(admin.id, filename: backup_filename, publish_to_message_bus: true, client_id: "foo") + context "when logged in as an admin" do + before { sign_in(admin) } - post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" } + it "starts a restore" do + BackupRestore.expects(:restore!).with(admin.id, { filename: backup_filename, publish_to_message_bus: true, client_id: "foo" }) - expect(response.status).to eq(200) + post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" } + + expect(response.status).to eq(200) + end + end + + shared_examples "backup restoration not allowed" do + it "prevents restoration with a 404 response" do + post "/admin/backups/#{backup_filename}/restore.json", params: { client_id: "foo" } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup restoration not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup restoration not allowed" end end describe '#readonly' do - it "enables readonly mode" do - expect(Discourse.readonly_mode?).to eq(false) + context "when logged in as an admin" do + before { sign_in(admin) } - expect { put "/admin/backups/readonly.json", params: { enable: true } } - .to change { UserHistory.where(action: UserHistory.actions[:change_readonly_mode], new_value: "t").count }.by(1) + it "enables readonly mode" do + expect(Discourse.readonly_mode?).to eq(false) - expect(Discourse.readonly_mode?).to eq(true) - expect(response.status).to eq(200) + expect { put "/admin/backups/readonly.json", params: { enable: true } } + .to change { UserHistory.where(action: UserHistory.actions[:change_readonly_mode], new_value: "t").count }.by(1) + + expect(Discourse.readonly_mode?).to eq(true) + expect(response.status).to eq(200) + end + + it "disables readonly mode" do + Discourse.enable_readonly_mode(Discourse::USER_READONLY_MODE_KEY) + expect(Discourse.readonly_mode?).to eq(true) + + expect { put "/admin/backups/readonly.json", params: { enable: false } } + .to change { UserHistory.where(action: UserHistory.actions[:change_readonly_mode], new_value: "f").count }.by(1) + + expect(response.status).to eq(200) + expect(Discourse.readonly_mode?).to eq(false) + end end - it "disables readonly mode" do - Discourse.enable_readonly_mode(Discourse::USER_READONLY_MODE_KEY) - expect(Discourse.readonly_mode?).to eq(true) + shared_examples "enabling readonly mode not allowed" do + it "prevents enabling readonly mode with a 404 response" do + expect(Discourse.readonly_mode?).to eq(false) - expect { put "/admin/backups/readonly.json", params: { enable: false } } - .to change { UserHistory.where(action: UserHistory.actions[:change_readonly_mode], new_value: "f").count }.by(1) + expect do + put "/admin/backups/readonly.json", params: { enable: true } + end.not_to change { UserHistory.where(action: UserHistory.actions[:change_readonly_mode], new_value: "t").count } - expect(response.status).to eq(200) - expect(Discourse.readonly_mode?).to eq(false) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(Discourse.readonly_mode?).to eq(false) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "enabling readonly mode not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "enabling readonly mode not allowed" end end describe "#upload_backup_chunk" do - describe "when filename contains invalid characters" do - it "should raise an error" do - ['灰色.tar.gz', '; echo \'haha\'.tar.gz'].each do |invalid_filename| - described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + context "when logged in as an admin" do + before { sign_in(admin) } - post "/admin/backups/upload", params: { - resumableFilename: invalid_filename, + describe "when filename contains invalid characters" do + it "should raise an error" do + ['灰色.tar.gz', '; echo \'haha\'.tar.gz'].each do |invalid_filename| + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + + post "/admin/backups/upload", params: { + resumableFilename: invalid_filename, + resumableTotalSize: 1, + resumableIdentifier: 'test' + } + + expect(response.status).to eq(415) + expect(response.body).to eq(I18n.t('backup.invalid_filename')) + end + end + end + + describe "when resumableIdentifier is invalid" do + it "should raise an error" do + filename = 'test_site-0123456789.tar.gz' + @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] + + post "/admin/backups/upload.json", params: { + resumableFilename: filename, resumableTotalSize: 1, - resumableIdentifier: 'test' + resumableIdentifier: '../test', + resumableChunkNumber: '1', + resumableChunkSize: '1', + resumableCurrentChunkSize: '1', + file: fixture_file_upload(Tempfile.new) } - expect(response.status).to eq(415) - expect(response.body).to eq(I18n.t('backup.invalid_filename')) + expect(response.status).to eq(400) + end + end + + describe "when filename is valid" do + it "should upload the file successfully" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + + filename = 'test_Site-0123456789.tar.gz' + + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: 1, + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '1', + resumableCurrentChunkSize: '1', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 1 + }, at: 5.seconds.from_now) + + expect(response.status).to eq(200) + expect(response.body).to eq("") + end + end + + describe "completing an upload by enqueuing backup_chunks_merger" do + let(:filename) { 'test_Site-0123456789.tar.gz' } + + it "works with a single chunk" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + + # 2MB file, 2MB chunks = 1x 2MB chunk + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '2097152', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 1 + }, at: 5.seconds.from_now) + end + + it "works with multiple chunks when the final chunk is chunk_size + remainder" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).twice.returns(true) + + # 5MB file, 2MB chunks = 1x 2MB chunk + 1x 3MB chunk with resumable.js + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '2', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '3145728', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 2 + }, at: 5.seconds.from_now) + end + + it "works with multiple chunks when the final chunk is just the remaninder" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).times(3).returns(true) + + # 5MB file, 2MB chunks = 2x 2MB chunk + 1x 1MB chunk with uppy.js + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '2', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '3', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '1048576', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 3 + }, at: 5.seconds.from_now) end end end - describe "when resumableIdentifier is invalid" do - it "should raise an error" do - filename = 'test_site-0123456789.tar.gz' - @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] - - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: 1, - resumableIdentifier: '../test', - resumableChunkNumber: '1', - resumableChunkSize: '1', - resumableCurrentChunkSize: '1', - file: fixture_file_upload(Tempfile.new) - } - - expect(response.status).to eq(400) - end - end - - describe "when filename is valid" do - it "should upload the file successfully" do + shared_examples "uploading backup chunk not allowed" do + it "prevents uploading of backup chunk with a 404 response" do freeze_time - described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) - filename = 'test_Site-0123456789.tar.gz' post "/admin/backups/upload.json", params: { @@ -246,172 +587,206 @@ RSpec.describe Admin::BackupsController do resumableCurrentChunkSize: '1', file: fixture_file_upload(Tempfile.new) } - expect_job_enqueued(job: :backup_chunks_merger, args: { + + expect_not_enqueued_with(job: :backup_chunks_merger, args: { filename: filename, identifier: 'test', chunks: 1 }, at: 5.seconds.from_now) - expect(response.status).to eq(200) - expect(response.body).to eq("") + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end - describe "completing an upload by enqueuing backup_chunks_merger" do - let(:filename) { 'test_Site-0123456789.tar.gz' } + context "when logged in as a moderator" do + before { sign_in(moderator) } - it "works with a single chunk" do - freeze_time - described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + include_examples "uploading backup chunk not allowed" + end - # 2MB file, 2MB chunks = 1x 2MB chunk - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '2097152', - resumableIdentifier: 'test', - resumableChunkNumber: '1', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '2097152', - file: fixture_file_upload(Tempfile.new) - } - expect_job_enqueued(job: :backup_chunks_merger, args: { - filename: filename, identifier: 'test', chunks: 1 - }, at: 5.seconds.from_now) - end + context "when logged in as a non-staff user" do + before { sign_in(user) } - it "works with multiple chunks when the final chunk is chunk_size + remainder" do - freeze_time - described_class.any_instance.expects(:has_enough_space_on_disk?).twice.returns(true) - - # 5MB file, 2MB chunks = 1x 2MB chunk + 1x 3MB chunk with resumable.js - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '5242880', - resumableIdentifier: 'test', - resumableChunkNumber: '1', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '2097152', - file: fixture_file_upload(Tempfile.new) - } - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '5242880', - resumableIdentifier: 'test', - resumableChunkNumber: '2', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '3145728', - file: fixture_file_upload(Tempfile.new) - } - expect_job_enqueued(job: :backup_chunks_merger, args: { - filename: filename, identifier: 'test', chunks: 2 - }, at: 5.seconds.from_now) - end - - it "works with multiple chunks when the final chunk is just the remaninder" do - freeze_time - described_class.any_instance.expects(:has_enough_space_on_disk?).times(3).returns(true) - - # 5MB file, 2MB chunks = 2x 2MB chunk + 1x 1MB chunk with uppy.js - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '5242880', - resumableIdentifier: 'test', - resumableChunkNumber: '1', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '2097152', - file: fixture_file_upload(Tempfile.new) - } - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '5242880', - resumableIdentifier: 'test', - resumableChunkNumber: '2', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '2097152', - file: fixture_file_upload(Tempfile.new) - } - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: '5242880', - resumableIdentifier: 'test', - resumableChunkNumber: '3', - resumableChunkSize: '2097152', - resumableCurrentChunkSize: '1048576', - file: fixture_file_upload(Tempfile.new) - } - expect_job_enqueued(job: :backup_chunks_merger, args: { - filename: filename, identifier: 'test', chunks: 3 - }, at: 5.seconds.from_now) - end + include_examples "uploading backup chunk not allowed" end end describe "#check_backup_chunk" do - describe "when resumableIdentifier is invalid" do - it "should raise an error" do - get "/admin/backups/upload", params: { - resumableIdentifier: "../some_file", - resumableFilename: "test_site-0123456789.tar.gz", - resumableChunkNumber: '1', - resumableCurrentChunkSize: '1' - } + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(400) + describe "when resumableIdentifier is invalid" do + it "should raise an error" do + get "/admin/backups/upload", params: { + resumableidentifier: "../some_file", + resumablefilename: "test_site-0123456789.tar.gz", + resumablechunknumber: '1', + resumablecurrentchunksize: '1' + } + + expect(response.status).to eq(400) + end end end + + shared_examples "checking backup chunk not allowed" do + it "denies access with a 404 response" do + get "/admin/backups/upload", params: { + resumableidentifier: "../some_file", + resumablefilename: "test_site-0123456789.tar.gz", + resumablechunknumber: '1', + resumablecurrentchunksize: '1' + } + + expect(response.status).to eq(404) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "checking backup chunk not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "checking backup chunk not allowed" + end end describe '#rollback' do - it 'should rollback the restore' do - BackupRestore.expects(:rollback!) + context "when logged in as an admin" do + before { sign_in(admin) } - post "/admin/backups/rollback.json" + it 'should rollback the restore' do + BackupRestore.expects(:rollback!) - expect(response.status).to eq(200) + post "/admin/backups/rollback.json" + + expect(response.status).to eq(200) + end + + it 'should not allow rollback via a GET request' do + get "/admin/backups/rollback.json" + expect(response.status).to eq(404) + end end - it 'should not allow rollback via a GET request' do - get "/admin/backups/rollback.json" - expect(response.status).to eq(404) + shared_examples "backup rollback not allowed" do + it "prevents rollbacks with a 404 response" do + post "/admin/backups/rollback.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup rollback not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup rollback not allowed" end end describe '#cancel' do - it "should cancel an backup" do - BackupRestore.expects(:cancel!) + context "when logged in as an admin" do + before { sign_in(admin) } - delete "/admin/backups/cancel.json" + it "should cancel an backup" do + BackupRestore.expects(:cancel!) - expect(response.status).to eq(200) + delete "/admin/backups/cancel.json" + + expect(response.status).to eq(200) + end + + it 'should not allow cancel via a GET request' do + get "/admin/backups/cancel.json" + expect(response.status).to eq(404) + end end - it 'should not allow cancel via a GET request' do - get "/admin/backups/cancel.json" - expect(response.status).to eq(404) + shared_examples "backup cancellation not allowed" do + it "prevents cancellation with a 404 response" do + delete "/admin/backups/cancel.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup cancellation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup cancellation not allowed" end end describe "#email" do - it "enqueues email job" do + context "when logged in as an admin" do + before { sign_in(admin) } - # might as well test this here if we really want www.example.com - SiteSetting.force_hostname = "www.example.com" + it "enqueues email job" do - create_backup_files(backup_filename) + # might as well test this here if we really want www.example.com + SiteSetting.force_hostname = "www.example.com" - expect { + create_backup_files(backup_filename) + + expect { + put "/admin/backups/#{backup_filename}.json" + }.to change { Jobs::DownloadBackupEmail.jobs.size }.by(1) + + job_args = Jobs::DownloadBackupEmail.jobs.last["args"].first + expect(job_args["user_id"]).to eq(admin.id) + expect(job_args["backup_file_path"]).to eq("http://www.example.com/admin/backups/#{backup_filename}") + + expect(response.status).to eq(200) + end + + it "returns 404 when the backup does not exist" do put "/admin/backups/#{backup_filename}.json" - }.to change { Jobs::DownloadBackupEmail.jobs.size }.by(1) - job_args = Jobs::DownloadBackupEmail.jobs.last["args"].first - expect(job_args["user_id"]).to eq(admin.id) - expect(job_args["backup_file_path"]).to eq("http://www.example.com/admin/backups/#{backup_filename}") - - expect(response.status).to eq(200) + expect(response).to be_not_found + end end - it "returns 404 when the backup does not exist" do - put "/admin/backups/#{backup_filename}.json" + shared_examples "backup emails not allowed" do + it "prevents sending backup emails with a 404 response" do + SiteSetting.force_hostname = "www.example.com" + create_backup_files(backup_filename) - expect(response).to be_not_found + expect do + put "/admin/backups/#{backup_filename}.json" + end.not_to change { Jobs::DownloadBackupEmail.jobs.size } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "backup emails not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "backup emails not allowed" end end @@ -432,12 +807,8 @@ RSpec.describe Admin::BackupsController do ) end - context "when the user is not admin" do - before do - admin.update(admin: false) - end - - it "errors with invalid access error" do + shared_examples "multipart uploads not allowed" do + it "prevents multipart uploads with a 404 response" do post "/admin/backups/create-multipart.json", params: { file_name: "test.tar.gz", upload_type: upload_type, @@ -447,7 +818,21 @@ RSpec.describe Admin::BackupsController do end end + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "multipart uploads not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "multipart uploads not allowed" + end + context "when the user is admin" do + before { sign_in(admin) } + def stub_create_multipart_backup_request BackupRestore::S3BackupStore.any_instance.stubs(:temporary_upload_path).returns( "temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz" diff --git a/spec/requests/admin/badges_controller_spec.rb b/spec/requests/admin/badges_controller_spec.rb index 71368be5a3..96f4f9294b 100644 --- a/spec/requests/admin/badges_controller_spec.rb +++ b/spec/requests/admin/badges_controller_spec.rb @@ -1,22 +1,47 @@ # frozen_string_literal: true RSpec.describe Admin::BadgesController do - context "while logged in as an admin" do - fab!(:admin) { Fabricate(:admin) } - fab!(:badge) { Fabricate(:badge) } + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user, email: 'user1@test.com', username: 'username1') } + fab!(:badge) { Fabricate(:badge) } - before do - sign_in(admin) - end + describe '#index' do + context "when logged in as an admin" do + before { sign_in(admin) } - describe '#index' do - it 'returns badge index' do + it "returns badge index" do get "/admin/badges.json" expect(response.status).to eq(200) end end - describe '#preview' do + shared_examples "badges inaccessible" do + it "denies access to badges with a 404 response" do + get "/admin/badges.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badges inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badges inaccessible" + end + end + + describe '#preview' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'allows preview enable_badge_sql is enabled' do SiteSetting.enable_badge_sql = true @@ -39,7 +64,36 @@ RSpec.describe Admin::BadgesController do end end - describe '#create' do + shared_examples "badge preview inaccessible" do + it "denies access to badge preview with a 404 response" do + SiteSetting.enable_badge_sql = true + + post "/admin/badges/preview.json", params: { + sql: 'select id as user_id, created_at granted_at from users' + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge preview inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge preview inaccessible" + end + end + + describe '#create' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'can create badges correctly' do SiteSetting.enable_badge_sql = true @@ -56,7 +110,36 @@ RSpec.describe Admin::BadgesController do end end - describe '#save_badge_groupings' do + shared_examples "badge creation not allowed" do + it "prevents badge creation with a 404 response" do + SiteSetting.enable_badge_sql = true + + post "/admin/badges.json", params: { + name: 'test', query: 'select 1 as user_id, null as granted_at', badge_type_id: 1 + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge creation not allowed" + end + end + + describe '#save_badge_groupings' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'can save badge groupings' do groupings = BadgeGrouping.all.order(:position).to_a groupings << BadgeGrouping.new(name: 'Test 1') @@ -78,7 +161,41 @@ RSpec.describe Admin::BadgesController do end end - describe '#badge_types' do + shared_examples "badge grouping creation not allowed" do + it "prevents creation of badge groupings with a 404 response" do + groupings = BadgeGrouping.all.order(:position).to_a + groupings << BadgeGrouping.new(name: "Test 1") + groupings << BadgeGrouping.new(name: "Test 2") + + groupings.shuffle! + + names = groupings.map { |g| g.name } + ids = groupings.map { |g| g.id.to_s } + + post "/admin/badges/badge_groupings.json", params: { ids: ids, names: names } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge grouping creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge grouping creation not allowed" + end + end + + describe '#badge_types' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'returns JSON' do get "/admin/badges/types.json" @@ -87,7 +204,32 @@ RSpec.describe Admin::BadgesController do end end - describe '#destroy' do + shared_examples "badge types inaccessible" do + it "denies access to badge types with a 404 response" do + get "/admin/badges/types.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge types inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge types inaccessible" + end + end + + describe '#destroy' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'deletes the badge' do delete "/admin/badges/#{badge.id}.json" expect(response.status).to eq(200) @@ -96,7 +238,33 @@ RSpec.describe Admin::BadgesController do end end - describe '#update' do + shared_examples "badge deletion not allowed" do + it "prevents deletion of badges with a 404 response" do + delete "/admin/badges/#{badge.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(Badge.where(id: badge.id).exists?).to eq(true) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge deletion not allowed" + end + end + + describe '#update' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'does not update the name of system badges' do editor_badge = Badge.find(Badge::Editor) editor_badge_name = editor_badge.name @@ -180,8 +348,49 @@ RSpec.describe Admin::BadgesController do end end - describe '#mass_award' do - fab!(:user) { Fabricate(:user, email: 'user1@test.com', username: 'username1') } + shared_examples "badge update not allowed" do + it "prevents badge update with a 404 response" do + SiteSetting.enable_badge_sql = true + + sql = "select id user_id, created_at granted_at from users" + image = Fabricate(:upload) + + put "/admin/badges/#{badge.id}.json", params: { + name: "123456", + query: sql, + badge_type_id: badge.badge_type_id, + allow_title: false, + multiple_grant: false, + enabled: true, + image_upload_id: image.id, + icon: "fa-rocket", + } + + badge.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(badge.name).not_to eq('123456') + expect(badge.query).not_to eq(sql) + expect(badge.icon).not_to eq("fa-rocket") + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "badge update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "badge update not allowed" + end + end + + describe '#mass_award' do + context "when logged in as an admin" do + before { sign_in(admin) } it 'does nothing when there is no file' do post "/admin/badges/award/#{badge.id}.json", params: { file: '' } @@ -359,5 +568,29 @@ RSpec.describe Admin::BadgesController do end end end + + shared_examples "mass badge award not allowed" do + it "prevents mass badge award with a 404 response" do + file = file_from_fixtures('user_emails.csv', 'csv') + + post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(UserBadge.where(user: user, badge: badge).count).to eq(0) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "mass badge award not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "mass badge award not allowed" + end end end diff --git a/spec/requests/admin/color_schemes_controller_spec.rb b/spec/requests/admin/color_schemes_controller_spec.rb index b45517bbf5..b5a539ac26 100644 --- a/spec/requests/admin/color_schemes_controller_spec.rb +++ b/spec/requests/admin/color_schemes_controller_spec.rb @@ -1,26 +1,23 @@ # frozen_string_literal: true RSpec.describe Admin::ColorSchemesController do - it "is a subclass of AdminController" do - expect(described_class < Admin::AdminController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - context "while logged in as an admin" do - fab!(:admin) { Fabricate(:admin) } - let(:valid_params) { { color_scheme: { - name: 'Such Design', - colors: [ - { name: 'primary', hex: 'FFBB00' }, - { name: 'secondary', hex: '888888' } - ] - } - } } + let(:valid_params) { { color_scheme: { + name: 'Such Design', + colors: [ + { name: 'primary', hex: 'FFBB00' }, + { name: 'secondary', hex: '888888' } + ] + } + } } - before do - sign_in(admin) - end + describe "#index" do + context "when logged in as an admin" do + before { sign_in(admin) } - describe "#index" do it "returns JSON" do scheme_name = Fabricate(:color_scheme).name get "/admin/color_schemes.json" @@ -36,7 +33,32 @@ RSpec.describe Admin::ColorSchemesController do end end - describe "#create" do + shared_examples "color schemes inaccessible" do + it "denies access with a 404 response" do + get "/admin/color_schemes.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "color schemes inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "color schemes inaccessible" + end + end + + describe "#create" do + context "when logged in as an admin" do + before { sign_in(admin) } + it "returns JSON" do post "/admin/color_schemes.json", params: valid_params @@ -55,8 +77,38 @@ RSpec.describe Admin::ColorSchemesController do end end - describe "#update" do - fab!(:existing) { Fabricate(:color_scheme) } + shared_examples "color scheme creation not allowed" do + it "prevents creation with a 404 response" do + params = valid_params + params[:color_scheme][:colors][0][:hex] = 'cool color please' + + expect do + post "/admin/color_schemes.json", params: valid_params + end.not_to change { ColorScheme.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "color scheme creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "color scheme creation not allowed" + end + end + + describe "#update" do + fab!(:existing) { Fabricate(:color_scheme) } + + context "when logged in as an admin" do + before { sign_in(admin) } it "returns success" do put "/admin/color_schemes/#{existing.id}.json", params: valid_params @@ -84,8 +136,33 @@ RSpec.describe Admin::ColorSchemesController do end end - describe "#destroy" do - fab!(:existing) { Fabricate(:color_scheme) } + shared_examples "color scheme update not allowed" do + it "prevents update with a 404 response" do + put "/admin/color_schemes/#{existing.id}.json", params: valid_params + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "color scheme update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "color scheme update not allowed" + end + end + + describe "#destroy" do + fab!(:existing) { Fabricate(:color_scheme) } + + context "when logged in as an admin" do + before { sign_in(admin) } it "returns success" do expect { @@ -94,5 +171,26 @@ RSpec.describe Admin::ColorSchemesController do expect(response.status).to eq(200) end end + + shared_examples "color scheme deletion not allowed" do + it "prevents deletion with a 404 response" do + delete "/admin/color_schemes/#{existing.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "color scheme deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "color scheme deletion not allowed" + end end end diff --git a/spec/requests/admin/dashboard_controller_spec.rb b/spec/requests/admin/dashboard_controller_spec.rb index 6f9e34881a..219a2855ea 100644 --- a/spec/requests/admin/dashboard_controller_spec.rb +++ b/spec/requests/admin/dashboard_controller_spec.rb @@ -1,60 +1,116 @@ # frozen_string_literal: true RSpec.describe Admin::DashboardController do + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + before do AdminDashboardData.stubs(:fetch_cached_stats).returns(reports: []) Jobs::VersionCheck.any_instance.stubs(:execute).returns(true) end - it "is a subclass of StaffController" do - expect(Admin::DashboardController < Admin::StaffController).to eq(true) + def populate_new_features + sample_features = [ + { + "id" => "1", + "emoji" => "🤾", + "title" => "Cool Beans", + "description" => "Now beans are included", + "created_at" => Time.zone.now - 40.minutes + }, + { + "id" => "2", + "emoji" => "🙈", + "title" => "Fancy Legumes", + "description" => "Legumes too!", + "created_at" => Time.zone.now - 20.minutes + } + ] + + Discourse.redis.set('new_features', MultiJson.dump(sample_features)) end - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } + describe '#index' do + shared_examples "version info present" do + it "returns discourse version info" do + get "/admin/dashboard.json" - def populate_new_features - sample_features = [ - { "id" => "1", "emoji" => "🤾", "title" => "Cool Beans", "description" => "Now beans are included", "created_at" => Time.zone.now - 40.minutes }, - { "id" => "2", "emoji" => "🙈", "title" => "Fancy Legumes", "description" => "Legumes too!", "created_at" => Time.zone.now - 20.minutes } - ] - - Discourse.redis.set('new_features', MultiJson.dump(sample_features)) + expect(response.status).to eq(200) + expect(response.parsed_body["version_check"]).to be_present + end end - before do - sign_in(admin) + shared_examples "version info absent" do + before do + SiteSetting.version_checks = false + end + + it "does not return discourse version info" do + get "/admin/dashboard.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["version_check"]).not_to be_present + end end - describe '#index' do - context 'when version checking is enabled' do + context "when logged in as an admin" do + before { sign_in(admin) } + + context "when version checking is enabled" do before do SiteSetting.version_checks = true end - it 'returns discourse version info' do - get "/admin/dashboard.json" - - expect(response.status).to eq(200) - expect(response.parsed_body['version_check']).to be_present - end + include_examples "version info present" end - context 'when version checking is disabled' do + context "when version checking is disabled" do before do SiteSetting.version_checks = false end - it 'does not return discourse version info' do - get "/admin/dashboard.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json['version_check']).not_to be_present - end + include_examples "version info absent" end end - describe '#problems' do + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "when version checking is enabled" do + before do + SiteSetting.version_checks = true + end + + include_examples "version info present" + end + + context "when version checking is disabled" do + before do + SiteSetting.version_checks = false + end + + include_examples "version info absent" + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/dashboard.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe '#problems' do + context "when logged in as an admin" do + before { sign_in(admin) } + context 'when there are no problems' do before do AdminDashboardData.stubs(:fetch_problems).returns([]) @@ -85,8 +141,40 @@ RSpec.describe Admin::DashboardController do end end - describe '#new_features' do + context "when logged in as a moderator" do before do + sign_in(moderator) + AdminDashboardData + .stubs(:fetch_problems) + .returns(['Not enough awesome', 'Too much sass']) + end + + it 'returns a list of problems' do + get "/admin/dashboard/problems.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['problems'].size).to eq(2) + expect(json['problems']).to contain_exactly('Not enough awesome', 'Too much sass') + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/dashboard/problems.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe '#new_features' do + context "when logged in as an admin" do + before do + sign_in(admin) Discourse.redis.del "new_features_last_seen_user_#{admin.id}" Discourse.redis.del "new_features" end @@ -131,7 +219,39 @@ RSpec.describe Admin::DashboardController do end end - describe '#mark_new_features_as_seen' do + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it 'includes new features when available' do + populate_new_features + + get "/admin/dashboard/new-features.json" + + json = response.parsed_body + + expect(json['new_features'].length).to eq(2) + expect(json['new_features'][0]["emoji"]).to eq("🙈") + expect(json['new_features'][0]["title"]).to eq("Fancy Legumes") + expect(json['has_unseen_features']).to eq(true) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/dashboard/new-features.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe '#mark_new_features_as_seen' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'resets last seen for a given user' do populate_new_features put "/admin/dashboard/mark-new-features-as-seen.json" @@ -141,5 +261,30 @@ RSpec.describe Admin::DashboardController do expect(DiscourseUpdates.has_unseen_features?(admin.id)).to eq(false) end end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "resets last seen for moderator" do + populate_new_features + + put "/admin/dashboard/mark-new-features-as-seen.json" + + expect(response.status).to eq(200) + expect(DiscourseUpdates.new_features_last_seen(moderator.id)).not_to eq(nil) + expect(DiscourseUpdates.has_unseen_features?(moderator.id)).to eq(false) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents marking new feature as seen with a 404 response" do + put "/admin/dashboard/mark-new-features-as-seen.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end end end diff --git a/spec/requests/admin/email_controller_spec.rb b/spec/requests/admin/email_controller_spec.rb index 3801250af4..0f0b7acdf9 100644 --- a/spec/requests/admin/email_controller_spec.rb +++ b/spec/requests/admin/email_controller_spec.rb @@ -2,33 +2,52 @@ RSpec.describe Admin::EmailController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } fab!(:email_log) { Fabricate(:email_log) } - before do - sign_in(admin) - end - - it "is a subclass of AdminController" do - expect(Admin::EmailController < Admin::AdminController).to eq(true) - end - describe '#index' do - before do - Admin::EmailController.any_instance - .expects(:action_mailer_settings) - .returns( - username: 'username', - password: 'secret' - ) + context "when logged in as an admin" do + before do + sign_in(admin) + Admin::EmailController.any_instance + .expects(:action_mailer_settings) + .returns( + username: 'username', + password: 'secret' + ) + end + + it 'does not include the password in the response' do + get "/admin/email.json" + mail_settings = response.parsed_body['settings'] + + expect( + mail_settings.select { |setting| setting['name'] == 'password' } + ).to be_empty + end end - it 'does not include the password in the response' do - get "/admin/email.json" - mail_settings = response.parsed_body['settings'] + shared_examples "email settings inaccessible" do + it "denies access with a 404 response" do + get "/admin/email.json" - expect( - mail_settings.select { |setting| setting['name'] == 'password' } - ).to be_empty + expect(response.status).to eq(404) + expect(response.parsed_body["settings"]).to be_nil + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email settings inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email settings inaccessible" end end @@ -40,40 +59,61 @@ RSpec.describe Admin::EmailController do Fabricate(:post_reply_key, post: post, user: email_log.user) end - it "should return the right response" do - email_log - get "/admin/email/sent.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - log = response.parsed_body.first - expect(log["id"]).to eq(email_log.id) - expect(log["reply_key"]).to eq(nil) + it "should return the right response" do + email_log + get "/admin/email/sent.json" - post_reply_key + expect(response.status).to eq(200) + log = response.parsed_body.first + expect(log["id"]).to eq(email_log.id) + expect(log["reply_key"]).to eq(nil) - get "/admin/email/sent.json" + post_reply_key - expect(response.status).to eq(200) - log = response.parsed_body.first - expect(log["id"]).to eq(email_log.id) - expect(log["reply_key"]).to eq(post_reply_key.reply_key) - end + get "/admin/email/sent.json" - it 'should be able to filter by reply key' do - email_log_2 = Fabricate(:email_log, post: post) + expect(response.status).to eq(200) + log = response.parsed_body.first + expect(log["id"]).to eq(email_log.id) + expect(log["reply_key"]).to eq(post_reply_key.reply_key) + end - post_reply_key_2 = Fabricate(:post_reply_key, - post: post, - user: email_log_2.user, - reply_key: "2d447423-c625-4fb9-8717-ff04ac60eee8" - ) + it 'should be able to filter by reply key' do + email_log_2 = Fabricate(:email_log, post: post) + + post_reply_key_2 = Fabricate(:post_reply_key, + post: post, + user: email_log_2.user, + reply_key: "2d447423-c625-4fb9-8717-ff04ac60eee8" + ) + + [ + "17ff04", + "2d447423c6254fb98717ff04ac60eee8" + ].each do |reply_key| + get "/admin/email/sent.json", params: { + reply_key: reply_key + } + + expect(response.status).to eq(200) + + logs = response.parsed_body + + expect(logs.size).to eq(1) + expect(logs.first["reply_key"]).to eq(post_reply_key_2.reply_key) + end + end + + it 'should be able to filter by smtp_transaction_response' do + email_log_2 = Fabricate(:email_log, smtp_transaction_response: <<~RESPONSE) + 250 Ok: queued as pYoKuQ1aUG5vdpgh-k2K11qcpF4C1ZQ5qmvmmNW25SM=@mailhog.example + RESPONSE - [ - "17ff04", - "2d447423c6254fb98717ff04ac60eee8" - ].each do |reply_key| get "/admin/email/sent.json", params: { - reply_key: reply_key + smtp_transaction_response: "pYoKu" } expect(response.status).to eq(200) @@ -81,219 +121,448 @@ RSpec.describe Admin::EmailController do logs = response.parsed_body expect(logs.size).to eq(1) - expect(logs.first["reply_key"]).to eq(post_reply_key_2.reply_key) + expect(logs.first["smtp_transaction_response"]).to eq(email_log_2.smtp_transaction_response) end end - it 'should be able to filter by smtp_transaction_response' do - email_log_2 = Fabricate(:email_log, smtp_transaction_response: <<~RESPONSE) - 250 Ok: queued as pYoKuQ1aUG5vdpgh-k2K11qcpF4C1ZQ5qmvmmNW25SM=@mailhog.example - RESPONSE + shared_examples "sent emails inaccessible" do + it "denies access with a 404 response" do + get "/admin/email/sent.json" - get "/admin/email/sent.json", params: { - smtp_transaction_response: "pYoKu" - } + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end - expect(response.status).to eq(200) + context "when logged in as a moderator" do + before { sign_in(moderator) } - logs = response.parsed_body + include_examples "sent emails inaccessible" + end - expect(logs.size).to eq(1) - expect(logs.first["smtp_transaction_response"]).to eq(email_log_2.smtp_transaction_response) + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "sent emails inaccessible" end end describe '#skipped' do - fab!(:user) { Fabricate(:user) } + # fab!(:user) { Fabricate(:user) } fab!(:log1) { Fabricate(:skipped_email_log, user: user, created_at: 20.minutes.ago) } fab!(:log2) { Fabricate(:skipped_email_log, created_at: 10.minutes.ago) } - it "succeeds" do - get "/admin/email/skipped.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - - logs = response.parsed_body - - expect(logs.first["id"]).to eq(log2.id) - expect(logs.last["id"]).to eq(log1.id) - end - - describe 'when filtered by username' do - it 'should return the right response' do - get "/admin/email/skipped.json", params: { - user: user.username - } + it "succeeds" do + get "/admin/email/skipped.json" expect(response.status).to eq(200) logs = response.parsed_body - expect(logs.count).to eq(1) - expect(logs.first["id"]).to eq(log1.id) + expect(logs.first["id"]).to eq(log2.id) + expect(logs.last["id"]).to eq(log1.id) end + + context "when filtered by username" do + it 'should return the right response' do + get "/admin/email/skipped.json", params: { + user: user.username + } + + expect(response.status).to eq(200) + + logs = response.parsed_body + + expect(logs.count).to eq(1) + expect(logs.first["id"]).to eq(log1.id) + end + end + end + + shared_examples "skipped emails inaccessible" do + it "denies access with a 404 response" do + get "/admin/email/skipped.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "skipped emails inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "skipped emails inaccessible" end end describe '#test' do - it 'raises an error without the email parameter' do - post "/admin/email/test.json" - expect(response.status).to eq(400) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'raises an error without the email parameter' do + post "/admin/email/test.json" + expect(response.status).to eq(400) + end + + context 'with an email address' do + it 'enqueues a test email job' do + post "/admin/email/test.json", params: { email_address: 'eviltrout@test.domain' } + + expect(response.status).to eq(200) + expect(ActionMailer::Base.deliveries.map(&:to).flatten).to include('eviltrout@test.domain') + end + end + + context 'with SiteSetting.disable_emails' do + fab!(:eviltrout) { Fabricate(:evil_trout) } + fab!(:admin) { Fabricate(:admin) } + + it 'bypasses disable when setting is "yes"' do + SiteSetting.disable_emails = 'yes' + post "/admin/email/test.json", params: { email_address: admin.email } + + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( + admin.email + ) + + incoming = response.parsed_body + expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) + end + + it 'bypasses disable when setting is "non-staff"' do + SiteSetting.disable_emails = 'non-staff' + + post "/admin/email/test.json", params: { email_address: eviltrout.email } + + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( + eviltrout.email + ) + + incoming = response.parsed_body + expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) + end + + it 'works when setting is "no"' do + SiteSetting.disable_emails = 'no' + + post "/admin/email/test.json", params: { email_address: eviltrout.email } + + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( + eviltrout.email + ) + + incoming = response.parsed_body + expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) + end + end end - context 'with an email address' do - it 'enqueues a test email job' do + shared_examples "email tests not allowed" do + it "prevents email tests with a 404 response" do post "/admin/email/test.json", params: { email_address: 'eviltrout@test.domain' } - expect(response.status).to eq(200) - expect(ActionMailer::Base.deliveries.map(&:to).flatten).to include('eviltrout@test.domain') + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end - context 'with SiteSetting.disable_emails' do - fab!(:eviltrout) { Fabricate(:evil_trout) } - fab!(:admin) { Fabricate(:admin) } + context "when logged in as a moderator" do + before { sign_in(moderator) } - it 'bypasses disable when setting is "yes"' do - SiteSetting.disable_emails = 'yes' - post "/admin/email/test.json", params: { email_address: admin.email } + include_examples "email tests not allowed" + end - expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( - admin.email - ) + context "when logged in as a non-staff user" do + before { sign_in(user) } - incoming = response.parsed_body - expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) - end - - it 'bypasses disable when setting is "non-staff"' do - SiteSetting.disable_emails = 'non-staff' - - post "/admin/email/test.json", params: { email_address: eviltrout.email } - - expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( - eviltrout.email - ) - - incoming = response.parsed_body - expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) - end - - it 'works when setting is "no"' do - SiteSetting.disable_emails = 'no' - - post "/admin/email/test.json", params: { email_address: eviltrout.email } - - expect(ActionMailer::Base.deliveries.first.to).to contain_exactly( - eviltrout.email - ) - - incoming = response.parsed_body - expect(incoming['sent_test_email_message']).to eq(I18n.t("admin.email.sent_test")) - end + include_examples "email tests not allowed" end end describe '#preview_digest' do - it 'raises an error without the last_seen_at parameter' do - get "/admin/email/preview-digest.json" - expect(response.status).to eq(400) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'raises an error without the last_seen_at parameter' do + get "/admin/email/preview-digest.json" + expect(response.status).to eq(400) + end + + it "returns the right response when username is invalid" do + get "/admin/email/preview-digest.json", params: { + last_seen_at: 1.week.ago, username: "somerandomeusername" + } + + expect(response.status).to eq(400) + end + + it "previews the digest" do + get "/admin/email/preview-digest.json", params: { + last_seen_at: 1.week.ago, username: admin.username + } + expect(response.status).to eq(200) + end end - it "returns the right response when username is invalid" do - get "/admin/email/preview-digest.json", params: { - last_seen_at: 1.week.ago, username: "somerandomeusername" - } + shared_examples "preview digest inaccessible" do + it "denies access with a 404 response" do + get "/admin/email/preview-digest.json", params: { + last_seen_at: 1.week.ago, username: moderator.username + } - expect(response.status).to eq(400) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it "previews the digest" do - get "/admin/email/preview-digest.json", params: { - last_seen_at: 1.week.ago, username: admin.username - } - expect(response.status).to eq(200) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "preview digest inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "preview digest inaccessible" end end describe '#handle_mail' do - it "returns a bad request if neither email parameter is present" do - post "/admin/email/handle_mail.json" - expect(response.status).to eq(400) - expect(response.body).to include("param is missing") - end + context "when logged in as an admin" do + before { sign_in(admin) } - it 'should enqueue the right job, and show a deprecation warning (email_encoded param should be used)' do - expect_enqueued_with( - job: :process_email, - args: { mail: email('cc'), retry_on_rate_limit: true, source: :handle_mail } - ) do - post "/admin/email/handle_mail.json", params: { email: email('cc') } + it "returns a bad request if neither email parameter is present" do + post "/admin/email/handle_mail.json" + expect(response.status).to eq(400) + expect(response.body).to include("param is missing") + end + + it 'should enqueue the right job, and show a deprecation warning (email_encoded param should be used)' do + expect_enqueued_with( + job: :process_email, + args: { mail: email('cc'), retry_on_rate_limit: true, source: :handle_mail } + ) do + post "/admin/email/handle_mail.json", params: { email: email('cc') } + end + expect(response.status).to eq(200) + expect(response.body).to eq("warning: the email parameter is deprecated. all POST requests to this route should be sent with a base64 strict encoded email_encoded parameter instead. email has been received and is queued for processing") + end + + it 'should enqueue the right job, decoding the raw email param' do + expect_enqueued_with( + job: :process_email, + args: { mail: email('cc'), retry_on_rate_limit: true, source: :handle_mail } + ) do + post "/admin/email/handle_mail.json", params: { email_encoded: Base64.strict_encode64(email('cc')) } + end + expect(response.status).to eq(200) + expect(response.body).to eq("email has been received and is queued for processing") + end + + it "retries enqueueing with forced UTF-8 encoding when encountering Encoding::UndefinedConversionError" do + post "/admin/email/handle_mail.json", params: { email_encoded: Base64.strict_encode64(email('encoding_undefined_conversion')) } + expect(response.status).to eq(200) + expect(response.body).to eq("email has been received and is queued for processing") end - expect(response.status).to eq(200) - expect(response.body).to eq("warning: the email parameter is deprecated. all POST requests to this route should be sent with a base64 strict encoded email_encoded parameter instead. email has been received and is queued for processing") end - it 'should enqueue the right job, decoding the raw email param' do - expect_enqueued_with( - job: :process_email, - args: { mail: email('cc'), retry_on_rate_limit: true, source: :handle_mail } - ) do + shared_examples "email handling not allowed" do + it "prevents email handling with a 404 response" do post "/admin/email/handle_mail.json", params: { email_encoded: Base64.strict_encode64(email('cc')) } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end - expect(response.status).to eq(200) - expect(response.body).to eq("email has been received and is queued for processing") end - it "retries enqueueing with forced UTF-8 encoding when encountering Encoding::UndefinedConversionError" do - post "/admin/email/handle_mail.json", params: { email_encoded: Base64.strict_encode64(email('encoding_undefined_conversion')) } - expect(response.status).to eq(200) - expect(response.body).to eq("email has been received and is queued for processing") + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email handling not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email handling not allowed" end end describe '#rejected' do - it 'should provide a string for a blank error' do - Fabricate(:incoming_email, error: "") - get "/admin/email/rejected.json" - expect(response.status).to eq(200) - rejected = response.parsed_body - expect(rejected.first['error']).to eq(I18n.t("emails.incoming.unrecognized_error")) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should provide a string for a blank error' do + Fabricate(:incoming_email, error: "") + get "/admin/email/rejected.json" + expect(response.status).to eq(200) + rejected = response.parsed_body + expect(rejected.first['error']).to eq(I18n.t("emails.incoming.unrecognized_error")) + end + end + + shared_examples "rejected emails inaccessible" do + it "denies access with a 404 response" do + get "/admin/email/rejected.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "rejected emails inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "rejected emails inaccessible" end end describe '#incoming' do - it 'should provide a string for a blank error' do - incoming_email = Fabricate(:incoming_email, error: "") - get "/admin/email/incoming/#{incoming_email.id}.json" - expect(response.status).to eq(200) - incoming = response.parsed_body - expect(incoming['error']).to eq(I18n.t("emails.incoming.unrecognized_error")) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should provide a string for a blank error' do + incoming_email = Fabricate(:incoming_email, error: "") + get "/admin/email/incoming/#{incoming_email.id}.json" + expect(response.status).to eq(200) + incoming = response.parsed_body + expect(incoming['error']).to eq(I18n.t("emails.incoming.unrecognized_error")) + end + end + + shared_examples "incoming emails inaccessible" do + it "denies access with a 404 response" do + incoming_email = Fabricate(:incoming_email, error: "") + + get "/admin/email/incoming/#{incoming_email.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "incoming emails inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "incoming emails inaccessible" end end describe '#incoming_from_bounced' do - it 'raises an error when the email log entry does not exist' do - get "/admin/email/incoming_from_bounced/12345.json" - expect(response.status).to eq(404) + context "when logged in as an admin" do + before { sign_in(admin) } - json = response.parsed_body - expect(json["errors"]).to include("Discourse::InvalidParameters") + it 'raises an error when the email log entry does not exist' do + get "/admin/email/incoming_from_bounced/12345.json" + expect(response.status).to eq(404) + + json = response.parsed_body + expect(json["errors"]).to include("Discourse::InvalidParameters") + end + + it 'raises an error when the email log entry is not marked as bounced' do + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(404) + + json = response.parsed_body + expect(json["errors"]).to include("Discourse::InvalidParameters") + end + + context 'when bounced email log entry exists' do + fab!(:email_log) { Fabricate(:email_log, bounced: true, bounce_key: SecureRandom.hex) } + let(:error_message) { "Email::Receiver::BouncedEmailError" } + + it 'returns an incoming email sent to the reply_by_email_address' do + SiteSetting.reply_by_email_address = "replies+%{reply_key}@example.com" + + Fabricate(:incoming_email, + is_bounce: true, + error: error_message, + to_addresses: Email::Sender.bounce_address(email_log.bounce_key) + ) + + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json["error"]).to eq(error_message) + end + + it 'returns an incoming email sent to the notification_email address' do + Fabricate(:incoming_email, + is_bounce: true, + error: error_message, + to_addresses: SiteSetting.notification_email.sub("@", "+verp-#{email_log.bounce_key}@") + ) + + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json["error"]).to eq(error_message) + end + + it 'returns an incoming email sent to the notification_email address' do + SiteSetting.reply_by_email_address = "replies+%{reply_key}@subdomain.example.com" + Fabricate(:incoming_email, + is_bounce: true, + error: error_message, + to_addresses: "subdomain+verp-#{email_log.bounce_key}@example.com" + ) + + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json["error"]).to eq(error_message) + end + + it 'raises an error if the bounce_key is blank' do + email_log.update(bounce_key: nil) + + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(404) + + json = response.parsed_body + expect(json["errors"]).to include("Discourse::InvalidParameters") + end + + it 'raises an error if there is no incoming email' do + get "/admin/email/incoming_from_bounced/#{email_log.id}.json" + expect(response.status).to eq(404) + + json = response.parsed_body + expect(json["errors"]).to include("Discourse::NotFound") + end + end end - it 'raises an error when the email log entry is not marked as bounced' do - get "/admin/email/incoming_from_bounced/#{email_log.id}.json" - expect(response.status).to eq(404) - - json = response.parsed_body - expect(json["errors"]).to include("Discourse::InvalidParameters") - end - - context 'when bounced email log entry exists' do - fab!(:email_log) { Fabricate(:email_log, bounced: true, bounce_key: SecureRandom.hex) } - let(:error_message) { "Email::Receiver::BouncedEmailError" } - - it 'returns an incoming email sent to the reply_by_email_address' do + shared_examples "bounced incoming emails inaccessible" do + it "denies access with a 404 response" do + email_log = Fabricate(:email_log, bounced: true, bounce_key: SecureRandom.hex) + error_message = "Email::Receiver::BouncedEmailError" SiteSetting.reply_by_email_address = "replies+%{reply_key}@example.com" Fabricate(:incoming_email, @@ -303,82 +572,75 @@ RSpec.describe Admin::EmailController do ) get "/admin/email/incoming_from_bounced/#{email_log.id}.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["error"]).to eq(error_message) - end - - it 'returns an incoming email sent to the notification_email address' do - Fabricate(:incoming_email, - is_bounce: true, - error: error_message, - to_addresses: SiteSetting.notification_email.sub("@", "+verp-#{email_log.bounce_key}@") - ) - - get "/admin/email/incoming_from_bounced/#{email_log.id}.json" - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json["error"]).to eq(error_message) - end - - it 'returns an incoming email sent to the notification_email address' do - SiteSetting.reply_by_email_address = "replies+%{reply_key}@subdomain.example.com" - Fabricate(:incoming_email, - is_bounce: true, - error: error_message, - to_addresses: "subdomain+verp-#{email_log.bounce_key}@example.com" - ) - - get "/admin/email/incoming_from_bounced/#{email_log.id}.json" - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json["error"]).to eq(error_message) - end - - it 'raises an error if the bounce_key is blank' do - email_log.update(bounce_key: nil) - - get "/admin/email/incoming_from_bounced/#{email_log.id}.json" expect(response.status).to eq(404) - - json = response.parsed_body - expect(json["errors"]).to include("Discourse::InvalidParameters") + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end + end - it 'raises an error if there is no incoming email' do - get "/admin/email/incoming_from_bounced/#{email_log.id}.json" - expect(response.status).to eq(404) + context "when logged in as a moderator" do + before { sign_in(moderator) } - json = response.parsed_body - expect(json["errors"]).to include("Discourse::NotFound") - end + include_examples "bounced incoming emails inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "bounced incoming emails inaccessible" end end describe '#advanced_test' do - it 'should ...' do - email = <<~EMAIL - From: "somebody" - To: someone@example.com - Date: Mon, 3 Dec 2018 00:00:00 -0000 - Subject: This is some subject - Content-Type: text/plain; charset="UTF-8" + let(:email) do + <<~EMAIL + From: "somebody" + To: someone@example.com + Date: Mon, 3 Dec 2018 00:00:00 -0000 + Subject: This is some subject + Content-Type: text/plain; charset="UTF-8" - Hello, this is a test! + Hello, this is a test! - --- + --- - This part should be elided. - EMAIL - post "/admin/email/advanced-test.json", params: { email: email } - expect(response.status).to eq(200) - incoming = response.parsed_body - expect(incoming['format']).to eq(1) - expect(incoming['text']).to eq("Hello, this is a test!") - expect(incoming['elided']).to eq("---\n\nThis part should be elided.") + This part should be elided. + EMAIL + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should ...' do + post "/admin/email/advanced-test.json", params: { email: email } + + expect(response.status).to eq(200) + incoming = response.parsed_body + expect(incoming['format']).to eq(1) + expect(incoming['text']).to eq("Hello, this is a test!") + expect(incoming['elided']).to eq("---\n\nThis part should be elided.") + end + end + + shared_examples "advanced email tests not allowed" do + it "prevents advanced email tests with a 404 response" do + post "/admin/email/advanced-test.json", params: { email: email } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "advanced email tests not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "advanced email tests not allowed" end end end diff --git a/spec/requests/admin/email_styles_controller_spec.rb b/spec/requests/admin/email_styles_controller_spec.rb index 6f857ef320..a7727ffb58 100644 --- a/spec/requests/admin/email_styles_controller_spec.rb +++ b/spec/requests/admin/email_styles_controller_spec.rb @@ -2,41 +2,61 @@ RSpec.describe Admin::EmailStylesController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") } let(:default_css) { "" } - before do - sign_in(admin) - end - after do SiteSetting.remove_override!(:email_custom_template) SiteSetting.remove_override!(:email_custom_css) end - it "is a subclass of AdminController" do - expect(Admin::EmailStylesController < Admin::AdminController).to eq(true) - end - describe 'show' do - it 'returns default values' do - get '/admin/customize/email_style.json' - expect(response.status).to eq(200) + context "when logged in as an admin" do + before { sign_in(admin) } - json = response.parsed_body['email_style'] - expect(json['html']).to eq(default_html) - expect(json['css']).to eq(default_css) + it 'returns default values' do + get '/admin/customize/email_style.json' + expect(response.status).to eq(200) + + json = response.parsed_body['email_style'] + expect(json['html']).to eq(default_html) + expect(json['css']).to eq(default_css) + end + + it 'returns customized values' do + SiteSetting.email_custom_template = "For you: %{email_content}" + SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" + get '/admin/customize/email_style.json' + expect(response.status).to eq(200) + + json = response.parsed_body['email_style'] + expect(json['html']).to eq("For you: %{email_content}") + expect(json['css']).to eq(".user-name { font-size: 24px; }") + end end - it 'returns customized values' do - SiteSetting.email_custom_template = "For you: %{email_content}" - SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" - get '/admin/customize/email_style.json' - expect(response.status).to eq(200) + shared_examples "email styles inaccessible" do + it "denies access with a 404 response" do + get '/admin/customize/email_style.json' - json = response.parsed_body['email_style'] - expect(json['html']).to eq("For you: %{email_content}") - expect(json['css']).to eq(".user-name { font-size: 24px; }") + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email styles inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email styles inaccessible" end end @@ -48,26 +68,51 @@ RSpec.describe Admin::EmailStylesController do } end - it 'changes the settings' do - SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" - put '/admin/customize/email_style.json', params: { email_style: valid_params } - expect(response.status).to eq(200) - expect(SiteSetting.email_custom_template).to eq(valid_params[:html]) - expect(SiteSetting.email_custom_css).to eq(valid_params[:css]) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'changes the settings' do + SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" + put '/admin/customize/email_style.json', params: { email_style: valid_params } + expect(response.status).to eq(200) + expect(SiteSetting.email_custom_template).to eq(valid_params[:html]) + expect(SiteSetting.email_custom_css).to eq(valid_params[:css]) + end + + it 'reports errors' do + put '/admin/customize/email_style.json', params: { + email_style: valid_params.merge(html: 'No email content') + } + expect(response.status).to eq(422) + json = response.parsed_body + expect(json['errors']).to include( + I18n.t( + 'email_style.html_missing_placeholder', + placeholder: '%{email_content}' + ) + ) + end end - it 'reports errors' do - put '/admin/customize/email_style.json', params: { - email_style: valid_params.merge(html: 'No email content') - } - expect(response.status).to eq(422) - json = response.parsed_body - expect(json['errors']).to include( - I18n.t( - 'email_style.html_missing_placeholder', - placeholder: '%{email_content}' - ) - ) + shared_examples "email style update not allowed" do + it "denies access with a 404 response" do + put '/admin/customize/email_style.json', params: { email_style: valid_params } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email style update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email style update not allowed" end end end diff --git a/spec/requests/admin/email_templates_controller_spec.rb b/spec/requests/admin/email_templates_controller_spec.rb index acdb55bd56..ec6fc3d315 100644 --- a/spec/requests/admin/email_templates_controller_spec.rb +++ b/spec/requests/admin/email_templates_controller_spec.rb @@ -18,90 +18,70 @@ RSpec.describe Admin::EmailTemplatesController do I18n.reload! end - it "is a subclass of AdminController" do - expect(Admin::EmailTemplatesController < Admin::AdminController).to eq(true) - end - describe "#index" do - it "raises an error if you aren't logged in" do - get '/admin/customize/email_templates.json' - expect(response.status).to eq(404) + context "when logged in as an admin" do + before { sign_in(admin) } + + it "should work if you are an admin" do + get '/admin/customize/email_templates.json' + + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json['email_templates']).to be_present + end + + it 'returns overridden = true if subject or body has translation_overrides record' do + put '/admin/customize/email_templates/user_notifications.admin_login', params: { + email_template: { subject: original_subject, body: original_body } + }, headers: headers + expect(response.status).to eq(200) + + get '/admin/customize/email_templates.json' + expect(response.status).to eq(200) + templates = response.parsed_body['email_templates'] + template = templates.find { |t| t['id'] == 'user_notifications.admin_login' } + expect(template['can_revert']).to eq(true) + + TranslationOverride.destroy_all + + get '/admin/customize/email_templates.json' + expect(response.status).to eq(200) + templates = response.parsed_body['email_templates'] + template = templates.find { |t| t['id'] == 'user_notifications.admin_login' } + expect(template['can_revert']).to eq(false) + end end - it "raises an error if you aren't an admin" do - sign_in(user) - get '/admin/customize/email_templates.json' - expect(response.status).to eq(404) + shared_examples "email templates inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/email_templates.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it "raises an error if you are a moderator" do - sign_in(moderator) - get "/admin/customize/email_templates.json" - expect(response.status).to eq(404) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email templates inaccessible" end - it "should work if you are an admin" do - sign_in(admin) - get '/admin/customize/email_templates.json' + context "when logged in as a non-staff user" do + before { sign_in(user) } - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json['email_templates']).to be_present + include_examples "email templates inaccessible" end - it 'returns overridden = true if subject or body has translation_overrides record' do - sign_in(admin) - - put '/admin/customize/email_templates/user_notifications.admin_login', params: { - email_template: { subject: original_subject, body: original_body } - }, headers: headers - expect(response.status).to eq(200) - - get '/admin/customize/email_templates.json' - expect(response.status).to eq(200) - templates = response.parsed_body['email_templates'] - template = templates.find { |t| t['id'] == 'user_notifications.admin_login' } - expect(template['can_revert']).to eq(true) - - TranslationOverride.destroy_all - - get '/admin/customize/email_templates.json' - expect(response.status).to eq(200) - templates = response.parsed_body['email_templates'] - template = templates.find { |t| t['id'] == 'user_notifications.admin_login' } - expect(template['can_revert']).to eq(false) + context "when not logged in" do + include_examples "email templates inaccessible" end end describe "#update" do - it "raises an error if you aren't logged in" do - put '/admin/customize/email_templates/some_id', params: { - email_template: { subject: 'Subject', body: 'Body' } - }, headers: headers - expect(response.status).to eq(404) - end - - it "raises an error if you aren't an admin" do - sign_in(user) - put '/admin/customize/email_templates/some_id', params: { - email_template: { subject: 'Subject', body: 'Body' } - }, headers: headers - expect(response.status).to eq(404) - end - - it "raises an error if you are a moderator" do - sign_in(moderator) - put "/admin/customize/email_templates/some_id", params: { - email_template: { subject: "Subject", body: "Body" } - }, headers: headers - expect(response.status).to eq(404) - end - - context "when logged in as admin" do - before do - sign_in(admin) - end + context "when logged in as an admin" do + before { sign_in(admin) } it "returns 'not found' when an unknown email template id is used" do put '/admin/customize/email_templates/non_existent_template', params: { @@ -273,30 +253,37 @@ RSpec.describe Admin::EmailTemplatesController do end end + shared_examples "email template update not allowed" do + it "prevents updates with a 404 response" do + put "/admin/customize/email_templates/some_id", params: { + email_template: { subject: 'Subject', body: 'Body' } + }, headers: headers + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email template update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email template update not allowed" + end + + context "when not logged in" do + include_examples "email template update not allowed" + end end describe "#revert" do - it "raises an error if you aren't logged in" do - delete '/admin/customize/email_templates/some_id', headers: headers - expect(response.status).to eq(404) - end - - it "raises an error if you aren't an admin" do - sign_in(user) - delete '/admin/customize/email_templates/some_id', headers: headers - expect(response.status).to eq(404) - end - - it "raises an error if you are a moderator" do - sign_in(moderator) - delete "/admin/customize/email_templates/some_id", headers: headers - expect(response.status).to eq(404) - end - - context "when logged in as admin" do - before do - sign_in(admin) - end + context "when logged in as an admin" do + before { sign_in(admin) } it "returns 'not found' when an unknown email template id is used" do delete '/admin/customize/email_templates/non_existent_template', headers: headers @@ -364,6 +351,30 @@ RSpec.describe Admin::EmailTemplatesController do end end + shared_examples "email template reversal not allowed" do + it "prevents reversals with a 404 response" do + delete "/admin/customize/email_templates/some_id", headers: headers + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "email template reversal not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "email template reversal not allowed" + end + + context "when not logged in" do + include_examples "email template reversal not allowed" + end end it "uses only existing email templates" do diff --git a/spec/requests/admin/embeddable_hosts_controller_spec.rb b/spec/requests/admin/embeddable_hosts_controller_spec.rb index 2d6dbea801..cd3ab6689f 100644 --- a/spec/requests/admin/embeddable_hosts_controller_spec.rb +++ b/spec/requests/admin/embeddable_hosts_controller_spec.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true RSpec.describe Admin::EmbeddableHostsController do - it "is a subclass of AdminController" do - expect(Admin::EmbeddableHostsController < Admin::AdminController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:embeddable_host) { Fabricate(:embeddable_host) } - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } - fab!(:embeddable_host) { Fabricate(:embeddable_host) } + describe '#create' do + context "when logged in as an admin" do + before { sign_in(admin) } - before do - sign_in(admin) - end - - describe '#create' do it "logs embeddable host create" do post "/admin/embeddable_hosts.json", params: { embeddable_host: { host: "test.com" } @@ -25,7 +21,34 @@ RSpec.describe Admin::EmbeddableHostsController do end end - describe '#update' do + shared_examples "embeddable host creation not allowed" do + it "prevents embeddable host creation with a 404 response" do + post "/admin/embeddable_hosts.json", params: { + embeddable_host: { host: "test.com" } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "embeddable host creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "embeddable host creation not allowed" + end + end + + describe '#update' do + context "when logged in as an admin" do + before { sign_in(admin) } + it "logs embeddable host update" do category = Fabricate(:category) @@ -41,11 +64,39 @@ RSpec.describe Admin::EmbeddableHostsController do new_value: "category_id: #{category.id}, class_name: test-class, host: test.com").exists? expect(history_exists).to eq(true) - end end - describe '#destroy' do + shared_examples "embeddable host update not allowed" do + it "prevents updates with a 404 response" do + category = Fabricate(:category) + + put "/admin/embeddable_hosts/#{embeddable_host.id}.json", params: { + embeddable_host: { host: "test.com", class_name: "test-class", category_id: category.id } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "embeddable host update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "embeddable host update not allowed" + end + end + + describe '#destroy' do + context "when logged in as an admin" do + before { sign_in(admin) } + it "logs embeddable host destroy" do delete "/admin/embeddable_hosts/#{embeddable_host.id}.json", params: {} @@ -53,5 +104,26 @@ RSpec.describe Admin::EmbeddableHostsController do expect(UserHistory.where(acting_user_id: admin.id, action: UserHistory.actions[:embeddable_host_destroy]).exists?).to eq(true) end end + + shared_examples "embeddable host deletion not allowed" do + it "prevents deletion with a 404 response" do + delete "/admin/embeddable_hosts/#{embeddable_host.id}.json", params: {} + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "embeddable host deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "embeddable host deletion not allowed" + end end end diff --git a/spec/requests/admin/embedding_controller_spec.rb b/spec/requests/admin/embedding_controller_spec.rb index 722ba050af..7f28786b83 100644 --- a/spec/requests/admin/embedding_controller_spec.rb +++ b/spec/requests/admin/embedding_controller_spec.rb @@ -1,7 +1,87 @@ # frozen_string_literal: true RSpec.describe Admin::EmbeddingController do - it "is a subclass of AdminController" do - expect(Admin::EmbeddingController < Admin::AdminController).to eq(true) + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + + describe "#show" do + context 'when logged in as an admin' do + before { sign_in(admin) } + + it "returns embedding" do + get "/admin/customize/embedding.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["embedding"]).to be_present + end + end + + shared_examples "embedding accessible" do + it "returns embedding" do + get "/admin/customize/embedding.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "embedding accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "embedding accessible" + end + end + + describe "#update" do + context 'when logged in as an admin' do + before { sign_in(admin) } + + it "updates embedding" do + put "/admin/customize/embedding.json", params: { + embedding: { + embed_by_username: "system", + embed_post_limit: 200 + } + } + + expect(response.status).to eq(200) + expect(response.parsed_body["embedding"]["embed_by_username"]).to eq("system") + expect(response.parsed_body["embedding"]["embed_post_limit"]).to eq(200) + end + end + + shared_examples "embedding updates not allowed" do + it "prevents updates with a 404 response" do + put "/admin/customize/embedding.json", params: { + embedding: { + embed_by_username: "system", + embed_post_limit: 200 + } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["embedding"]).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "embedding updates not allowed" + end + + context "when logged in as a moderator" do + before { sign_in(user) } + + include_examples "embedding updates not allowed" + end end end diff --git a/spec/requests/admin/emojis_controller_spec.rb b/spec/requests/admin/emojis_controller_spec.rb index fe7773a378..b6849347cf 100644 --- a/spec/requests/admin/emojis_controller_spec.rb +++ b/spec/requests/admin/emojis_controller_spec.rb @@ -2,139 +2,219 @@ RSpec.describe Admin::EmojisController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } fab!(:upload) { Fabricate(:upload) } - before do - sign_in(admin) - end - describe '#index' do - it "returns a list of custom emojis" do - CustomEmoji.create!(name: 'osama-test-emoji', upload: upload) - Emoji.clear_cache + context "when logged in as an admin" do + before { sign_in(admin) } - get "/admin/customize/emojis.json" - expect(response.status).to eq(200) + it "returns a list of custom emojis" do + CustomEmoji.create!(name: 'osama-test-emoji', upload: upload) + Emoji.clear_cache - json = response.parsed_body - expect(json[0]["name"]).to eq("osama-test-emoji") - expect(json[0]["url"]).to eq(upload.url) + get "/admin/customize/emojis.json" + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json[0]["name"]).to eq("osama-test-emoji") + expect(json[0]["url"]).to eq(upload.url) + end + end + + shared_examples "custom emojis inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/emojis.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "custom emojis inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "custom emojis inaccessible" end end describe "#create" do - describe 'when upload is invalid' do - it 'should publish the right error' do + context "when logged in as an admin" do + before { sign_in(admin) } - post "/admin/customize/emojis.json", params: { - name: 'test', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/fake.jpg") - } + context 'when upload is invalid' do + it 'should publish the right error' do - expect(response.status).to eq(422) - parsed = response.parsed_body - expect(parsed["errors"]).to eq([I18n.t('upload.images.size_not_found')]) + post "/admin/customize/emojis.json", params: { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/fake.jpg") + } + + expect(response.status).to eq(422) + parsed = response.parsed_body + expect(parsed["errors"]).to eq([I18n.t('upload.images.size_not_found')]) + end end - end - describe 'when emoji name already exists' do - it 'should publish the right error' do - CustomEmoji.create!(name: 'test', upload: upload) + context 'when emoji name already exists' do + it 'should publish the right error' do + CustomEmoji.create!(name: 'test', upload: upload) + + post "/admin/customize/emojis.json", params: { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } + + expect(response.status).to eq(422) + parsed = response.parsed_body + expect(parsed["errors"]).to eq([ + "Name #{I18n.t('activerecord.errors.models.custom_emoji.attributes.name.taken')}" + ]) + end + end + + it 'should allow an admin to add a custom emoji' do + Emoji.expects(:clear_cache) post "/admin/customize/emojis.json", params: { name: 'test', file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") } - expect(response.status).to eq(422) - parsed = response.parsed_body - expect(parsed["errors"]).to eq([ - "Name #{I18n.t('activerecord.errors.models.custom_emoji.attributes.name.taken')}" - ]) + custom_emoji = CustomEmoji.last + upload = custom_emoji.upload + + expect(upload.original_filename).to eq('logo.png') + + data = response.parsed_body + expect(response.status).to eq(200) + expect(data["errors"]).to eq(nil) + expect(data["name"]).to eq(custom_emoji.name) + expect(data["url"]).to eq(upload.url) + expect(custom_emoji.group).to eq(nil) + end + + it 'should allow an admin to add a custom emoji with a custom group' do + Emoji.expects(:clear_cache) + + post "/admin/customize/emojis.json", params: { + name: 'test', + group: 'Foo', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } + + custom_emoji = CustomEmoji.last + + data = response.parsed_body + expect(response.status).to eq(200) + expect(custom_emoji.group).to eq("foo") + end + + it 'should fix up the emoji name' do + Emoji.expects(:clear_cache).times(3) + + post "/admin/customize/emojis.json", params: { + name: 'test.png', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } + + custom_emoji = CustomEmoji.last + upload = custom_emoji.upload + + expect(upload.original_filename).to eq('logo.png') + expect(custom_emoji.name).to eq("test") + expect(response.status).to eq(200) + + post "/admin/customize/emojis.json", params: { + name: 'st&#* onk$', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } + + custom_emoji = CustomEmoji.last + expect(custom_emoji.name).to eq("st_onk_") + expect(response.status).to eq(200) + + post "/admin/customize/emojis.json", params: { + name: 'PaRTYpaRrot', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } + + custom_emoji = CustomEmoji.last + expect(custom_emoji.name).to eq("partyparrot") + expect(response.status).to eq(200) end end - it 'should allow an admin to add a custom emoji' do - Emoji.expects(:clear_cache) + shared_examples "custom emoji creation not allowed" do + it "prevents creation with a 404 response" do + post "/admin/customize/emojis.json", params: { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + } - post "/admin/customize/emojis.json", params: { - name: 'test', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") - } - - custom_emoji = CustomEmoji.last - upload = custom_emoji.upload - - expect(upload.original_filename).to eq('logo.png') - - data = response.parsed_body - expect(response.status).to eq(200) - expect(data["errors"]).to eq(nil) - expect(data["name"]).to eq(custom_emoji.name) - expect(data["url"]).to eq(upload.url) - expect(custom_emoji.group).to eq(nil) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it 'should allow an admin to add a custom emoji with a custom group' do - Emoji.expects(:clear_cache) + context "when logged in as a moderator" do + before { sign_in(moderator) } - post "/admin/customize/emojis.json", params: { - name: 'test', - group: 'Foo', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") - } - - custom_emoji = CustomEmoji.last - - data = response.parsed_body - expect(response.status).to eq(200) - expect(custom_emoji.group).to eq("foo") + include_examples "custom emoji creation not allowed" end - it 'should fix up the emoji name' do - Emoji.expects(:clear_cache).times(3) + context "when logged in as a non-staff user" do + before { sign_in(user) } - post "/admin/customize/emojis.json", params: { - name: 'test.png', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") - } - - custom_emoji = CustomEmoji.last - upload = custom_emoji.upload - - expect(upload.original_filename).to eq('logo.png') - expect(custom_emoji.name).to eq("test") - expect(response.status).to eq(200) - - post "/admin/customize/emojis.json", params: { - name: 'st&#* onk$', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") - } - - custom_emoji = CustomEmoji.last - expect(custom_emoji.name).to eq("st_onk_") - expect(response.status).to eq(200) - - post "/admin/customize/emojis.json", params: { - name: 'PaRTYpaRrot', - file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") - } - - custom_emoji = CustomEmoji.last - expect(custom_emoji.name).to eq("partyparrot") - expect(response.status).to eq(200) + include_examples "custom emoji creation not allowed" end end describe '#destroy' do - it 'should allow an admin to delete a custom emoji' do - custom_emoji = CustomEmoji.create!(name: 'test', upload: upload) - Emoji.clear_cache + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should allow an admin to delete a custom emoji' do + custom_emoji = CustomEmoji.create!(name: 'test', upload: upload) + Emoji.clear_cache + + expect do + delete "/admin/customize/emojis/#{custom_emoji.name}.json", + params: { name: 'test' } + end.to change { CustomEmoji.count }.by(-1) + end + end + + shared_examples "custom emoji deletion not allowed" do + it "prevents deletion with a 404 response" do + custom_emoji = CustomEmoji.create!(name: 'test', upload: upload) + Emoji.clear_cache - expect do delete "/admin/customize/emojis/#{custom_emoji.name}.json", params: { name: 'test' } - end.to change { CustomEmoji.count }.by(-1) + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "custom emoji deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "custom emoji deletion not allowed" end end end diff --git a/spec/requests/admin/groups_controller_spec.rb b/spec/requests/admin/groups_controller_spec.rb index 89ff772ea0..bba0f375d1 100644 --- a/spec/requests/admin/groups_controller_spec.rb +++ b/spec/requests/admin/groups_controller_spec.rb @@ -2,17 +2,10 @@ RSpec.describe Admin::GroupsController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } fab!(:group) { Fabricate(:group) } - it 'is a subclass of StaffController' do - expect(Admin::UsersController < Admin::StaffController).to eq(true) - end - - before do - sign_in(admin) - end - describe '#create' do let(:group_params) do { @@ -27,359 +20,147 @@ RSpec.describe Admin::GroupsController do } end - it 'should work' do - post "/admin/groups.json", params: group_params + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - - group = Group.last - - expect(group.name).to eq('testing') - expect(group.users).to contain_exactly(admin, user) - expect(group.allow_membership_requests).to eq(true) - expect(group.membership_request_template).to eq('Testing') - expect(group.members_visibility_level).to eq(Group.visibility_levels[:staff]) - end - - context "with custom_fields" do - before do - plugin = Plugin::Instance.new - plugin.register_editable_group_custom_field :test - end - - after do - DiscoursePluginRegistry.reset! - end - - it "only updates allowed user fields" do - params = group_params - params[:group].merge!(custom_fields: { test: :hello1, test2: :hello2 }) - - post "/admin/groups.json", params: params - - group = Group.last - - expect(response.status).to eq(200) - expect(group.custom_fields['test']).to eq('hello1') - expect(group.custom_fields['test2']).to be_blank - end - - it "is secure when there are no registered editable fields" do - DiscoursePluginRegistry.reset! - params = group_params - params[:group].merge!(custom_fields: { test: :hello1, test2: :hello2 }) - - post "/admin/groups.json", params: params - - group = Group.last - - expect(response.status).to eq(200) - expect(group.custom_fields['test']).to be_blank - expect(group.custom_fields['test2']).to be_blank - end - end - - context 'with Group.plugin_permitted_params' do - after do - DiscoursePluginRegistry.reset! - end - - it 'filter unpermitted params' do - params = group_params - params[:group].merge!(allow_unknown_sender_topic_replies: true) - - post "/admin/groups.json", params: params - expect(Group.last.allow_unknown_sender_topic_replies).to eq(false) - end - - it 'allows plugin to allow custom params' do - params = group_params - params[:group].merge!(allow_unknown_sender_topic_replies: true) - - plugin = Plugin::Instance.new - plugin.register_group_param :allow_unknown_sender_topic_replies - - post "/admin/groups.json", params: params - expect(Group.last.allow_unknown_sender_topic_replies).to eq(true) - end - end - end - - describe '#add_owners' do - it 'should work' do - put "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: [user.username, admin.username].join(",") - } - } - - expect(response.status).to eq(200) - - response_body = response.parsed_body - - expect(response_body["usernames"]).to contain_exactly(user.username, admin.username) - - expect(group.group_users.where(owner: true).map(&:user)) - .to contain_exactly(user, admin) - end - - it 'returns not-found error when there is no group' do - group.destroy! - - put "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: user.username - } - } - - expect(response.status).to eq(404) - end - - it 'does not allow adding owners to an automatic group' do - group.update!(automatic: true) - - expect do - put "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: user.username - } - } - end.to_not change { group.group_users.count } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) - end - - it 'does not notify users when the param is not present' do - put "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: user.username - } - } - expect(response.status).to eq(200) - - topic = Topic.find_by( - title: I18n.t("system_messages.user_added_to_group_as_owner.subject_template", group_name: group.name), - archetype: "private_message" - ) - expect(topic.nil?).to eq(true) - end - - it 'notifies users when the param is present' do - put "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: user.username, - notify_users: true - } - } - expect(response.status).to eq(200) - - topic = Topic.find_by( - title: I18n.t("system_messages.user_added_to_group_as_owner.subject_template", group_name: group.name), - archetype: "private_message" - ) - expect(topic.nil?).to eq(false) - expect(topic.topic_users.map(&:user_id)).to include(-1, user.id) - end - end - - describe '#remove_owner' do - let(:user2) { Fabricate(:user) } - let(:user3) { Fabricate(:user) } - - it 'should work' do - group.add_owner(user) - - delete "/admin/groups/#{group.id}/owners.json", params: { - user_id: user.id - } - - expect(response.status).to eq(200) - expect(group.group_users.where(owner: true)).to eq([]) - end - - it 'should work with multiple users' do - group.add_owner(user) - group.add_owner(user3) - - delete "/admin/groups/#{group.id}/owners.json", params: { - group: { - usernames: "#{user.username},#{user2.username},#{user3.username}" - } - } - - expect(response.status).to eq(200) - expect(group.group_users.where(owner: true)).to eq([]) - end - - it 'returns not-found error when there is no group' do - group.destroy! - - delete "/admin/groups/#{group.id}/owners.json", params: { - user_id: user.id - } - - expect(response.status).to eq(404) - end - - it 'does not allow removing owners from an automatic group' do - group.update!(automatic: true) - - delete "/admin/groups/#{group.id}/owners.json", params: { - user_id: user.id - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) - end - end - - describe "#set_primary" do - let(:user2) { Fabricate(:user) } - let(:user3) { Fabricate(:user) } - - it 'sets with multiple users' do - user2.update!(primary_group_id: group.id) - - put "/admin/groups/#{group.id}/primary.json", params: { - group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, - primary: "true" - } - - expect(response.status).to eq(200) - expect(User.where(primary_group_id: group.id).size).to eq(3) - end - - it 'unsets with multiple users' do - user.update!(primary_group_id: group.id) - user3.update!(primary_group_id: group.id) - - put "/admin/groups/#{group.id}/primary.json", params: { - group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, - primary: "false" - } - - expect(response.status).to eq(200) - expect(User.where(primary_group_id: group.id).size).to eq(0) - end - end - - describe "#destroy" do - it 'should return the right response for an invalid group_id' do - max_id = Group.maximum(:id).to_i - delete "/admin/groups/#{max_id + 1}.json" - expect(response.status).to eq(404) - end - - it 'logs when a group is destroyed' do - delete "/admin/groups/#{group.id}.json" - - history = UserHistory.where(acting_user: admin).last - - expect(history).to be_present - expect(history.details).to include("name: #{group.name}") - expect(history.details).to include("id: #{group.id}") - end - - it 'logs the grant_trust_level attribute' do - trust_level = TrustLevel[4] - group.update!(grant_trust_level: trust_level) - delete "/admin/groups/#{group.id}.json" - - history = UserHistory.where(acting_user: admin).last - - expect(history).to be_present - expect(history.details).to include("grant_trust_level: #{trust_level}") - expect(history.details).to include("name: #{group.name}") - end - - describe 'when group is automatic' do - it "returns the right response" do - group.update!(automatic: true) - - delete "/admin/groups/#{group.id}.json" - - expect(response.status).to eq(422) - expect(Group.find(group.id)).to eq(group) - end - end - - describe 'for a non automatic group' do - it "returns the right response" do - delete "/admin/groups/#{group.id}.json" - - expect(response.status).to eq(200) - expect(Group.find_by(id: group.id)).to eq(nil) - end - end - end - - describe '#automatic_membership_count' do - it 'returns count of users whose emails match the domain' do - Fabricate(:user, email: 'user1@somedomain.org') - Fabricate(:user, email: 'user1@somedomain.com') - Fabricate(:user, email: 'user1@notsomedomain.com') - group = Fabricate(:group) - - put "/admin/groups/automatic_membership_count.json", params: { - automatic_membership_email_domains: 'somedomain.org|somedomain.com', - id: group.id - } - expect(response.status).to eq(200) - expect(response.parsed_body["user_count"]).to eq(2) - end - - it "doesn't responde with 500 if domain is invalid" do - group = Fabricate(:group) - - put "/admin/groups/automatic_membership_count.json", params: { - automatic_membership_email_domains: '@somedomain.org|@somedomain.com', - id: group.id - } - expect(response.status).to eq(200) - expect(response.parsed_body["user_count"]).to eq(0) - end - end - - context "when moderators_manage_categories_and_groups is enabled" do - let(:group_params) do - { - group: { - name: 'testing-as-moderator', - usernames: [admin.username, user.username].join(","), - owner_usernames: [user.username].join(","), - allow_membership_requests: true, - membership_request_template: 'Testing', - members_visibility_level: Group.visibility_levels[:staff] - } - } - end - - before do - SiteSetting.moderators_manage_categories_and_groups = true - end - - context "when the user is a moderator" do - before do - user.update!(moderator: true) - sign_in(user) - end - - it 'should allow groups to be created' do + it 'should work' do post "/admin/groups.json", params: group_params expect(response.status).to eq(200) group = Group.last - expect(group.name).to eq('testing-as-moderator') + expect(group.name).to eq('testing') expect(group.users).to contain_exactly(admin, user) expect(group.allow_membership_requests).to eq(true) expect(group.membership_request_template).to eq('Testing') expect(group.members_visibility_level).to eq(Group.visibility_levels[:staff]) end - it 'should allow group owners to be added' do + context "with custom_fields" do + before do + plugin = Plugin::Instance.new + plugin.register_editable_group_custom_field :test + end + + after do + DiscoursePluginRegistry.reset! + end + + it "only updates allowed user fields" do + params = group_params + params[:group].merge!(custom_fields: { test: :hello1, test2: :hello2 }) + + post "/admin/groups.json", params: params + + group = Group.last + + expect(response.status).to eq(200) + expect(group.custom_fields['test']).to eq('hello1') + expect(group.custom_fields['test2']).to be_blank + end + + it "is secure when there are no registered editable fields" do + DiscoursePluginRegistry.reset! + params = group_params + params[:group].merge!(custom_fields: { test: :hello1, test2: :hello2 }) + + post "/admin/groups.json", params: params + + group = Group.last + + expect(response.status).to eq(200) + expect(group.custom_fields['test']).to be_blank + expect(group.custom_fields['test2']).to be_blank + end + end + + context 'with Group.plugin_permitted_params' do + after do + DiscoursePluginRegistry.reset! + end + + it 'filter unpermitted params' do + params = group_params + params[:group].merge!(allow_unknown_sender_topic_replies: true) + + post "/admin/groups.json", params: params + expect(Group.last.allow_unknown_sender_topic_replies).to eq(false) + end + + it 'allows plugin to allow custom params' do + params = group_params + params[:group].merge!(allow_unknown_sender_topic_replies: true) + + plugin = Plugin::Instance.new + plugin.register_group_param :allow_unknown_sender_topic_replies + + post "/admin/groups.json", params: params + expect(Group.last.allow_unknown_sender_topic_replies).to eq(true) + end + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + it "creates group" do + expect do + post "/admin/groups.json", params: group_params + end.to change { Group.count }.by(1) + + expect(response.status).to eq(200) + + group = Group.last + + expect(group.name).to eq('testing') + expect(group.users).to contain_exactly(admin, user) + expect(group.allow_membership_requests).to eq(true) + expect(group.membership_request_template).to eq('Testing') + expect(group.members_visibility_level).to eq(Group.visibility_levels[:staff]) + end + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "prevents creation with a 403 response" do + expect do + post "/admin/groups.json", params: group_params + end.to_not change { Group.count } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + end + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents creation with a 404 response" do + expect do + post "/admin/groups.json", params: group_params + end.to_not change { Group.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe '#add_owners' do + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should work' do put "/admin/groups/#{group.id}/owners.json", params: { group: { usernames: [user.username, admin.username].join(",") @@ -396,7 +177,140 @@ RSpec.describe Admin::GroupsController do .to contain_exactly(user, admin) end - it 'should allow groups owners to be removed' do + it 'returns not-found error when there is no group' do + group.destroy! + + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username + } + } + + expect(response.status).to eq(404) + end + + it 'does not allow adding owners to an automatic group' do + group.update!(automatic: true) + + expect do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username + } + } + end.to_not change { group.group_users.count } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + end + + it 'does not notify users when the param is not present' do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username + } + } + expect(response.status).to eq(200) + + topic = Topic.find_by( + title: I18n.t("system_messages.user_added_to_group_as_owner.subject_template", group_name: group.name), + archetype: "private_message" + ) + expect(topic.nil?).to eq(true) + end + + it 'notifies users when the param is present' do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username, + notify_users: true + } + } + expect(response.status).to eq(200) + + topic = Topic.find_by( + title: I18n.t("system_messages.user_added_to_group_as_owner.subject_template", group_name: group.name), + archetype: "private_message" + ) + expect(topic.nil?).to eq(false) + expect(topic.topic_users.map(&:user_id)).to include(-1, user.id) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + it "adds owners" do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: [user.username, admin.username, moderator.username].join(",") + } + } + + response_body = response.parsed_body + + expect(response.status).to eq(200) + expect(response_body["usernames"]).to contain_exactly( + user.username, + admin.username, + moderator.username + ) + expect(group.group_users.where(owner: true).map(&:user)) + .to contain_exactly(user, admin, moderator) + end + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "prevents adding of owners with a 403 response" do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: [user.username, admin.username, moderator.username].join(",") + } + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(group.group_users.where(owner: true).map(&:user)) + .to be_empty + end + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents adding of owners with a 404 response" do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: [user.username, admin.username].join(",") + } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(group.group_users.where(owner: true).map(&:user)) + .to be_empty + end + end + end + + describe '#remove_owner' do + let(:user2) { Fabricate(:user) } + let(:user3) { Fabricate(:user) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should work' do group.add_owner(user) delete "/admin/groups/#{group.id}/owners.json", params: { @@ -406,32 +320,23 @@ RSpec.describe Admin::GroupsController do expect(response.status).to eq(200) expect(group.group_users.where(owner: true)).to eq([]) end - end - context "when the user is not a moderator or admin" do - before do - user.update!(moderator: false, admin: false) - sign_in(user) - end + it 'should work with multiple users' do + group.add_owner(user) + group.add_owner(user3) - it 'should not allow groups to be created' do - post "/admin/groups.json", params: group_params - - expect(response.status).to eq(404) - end - - it 'should not allow group owners to be added' do - put "/admin/groups/#{group.id}/owners.json", params: { + delete "/admin/groups/#{group.id}/owners.json", params: { group: { - usernames: [user.username, admin.username].join(",") + usernames: "#{user.username},#{user2.username},#{user3.username}" } } - expect(response.status).to eq(404) + expect(response.status).to eq(200) + expect(group.group_users.where(owner: true)).to eq([]) end - it 'should not allow groups owners to be removed' do - group.add_owner(user) + it 'returns not-found error when there is no group' do + group.destroy! delete "/admin/groups/#{group.id}/owners.json", params: { user_id: user.id @@ -439,6 +344,324 @@ RSpec.describe Admin::GroupsController do expect(response.status).to eq(404) end + + it 'does not allow removing owners from an automatic group' do + group.update!(automatic: true) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + it "removes owner" do + group.add_owner(user) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(200) + expect(group.group_users.where(owner: true)).to eq([]) + end + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "prevents owner removal with a 403 response" do + group.add_owner(user) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly(user) + end + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents owner removal with a 404 response" do + group.add_owner(user) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly(user) + end + end + end + + describe "#set_primary" do + let(:user2) { Fabricate(:user) } + let(:user3) { Fabricate(:user) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'sets with multiple users' do + user2.update!(primary_group_id: group.id) + + put "/admin/groups/#{group.id}/primary.json", params: { + group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, + primary: "true" + } + + expect(response.status).to eq(200) + expect(User.where(primary_group_id: group.id).size).to eq(3) + end + + it 'unsets with multiple users' do + user.update!(primary_group_id: group.id) + user3.update!(primary_group_id: group.id) + + put "/admin/groups/#{group.id}/primary.json", params: { + group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, + primary: "false" + } + + expect(response.status).to eq(200) + expect(User.where(primary_group_id: group.id).size).to eq(0) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + it "sets multiple primary users" do + user2.update!(primary_group_id: group.id) + + put "/admin/groups/#{group.id}/primary.json", params: { + group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, + primary: "true" + } + + expect(response.status).to eq(200) + expect(User.where(primary_group_id: group.id).size).to eq(3) + end + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "prevents setting of primary group with a 403 response" do + user2.update!(primary_group_id: group.id) + + put "/admin/groups/#{group.id}/primary.json", params: { + group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, + primary: "true" + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(User.where(primary_group_id: group.id).size).to eq(1) + end + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents setting of primary user with a 404 response" do + user2.update!(primary_group_id: group.id) + + put "/admin/groups/#{group.id}/primary.json", params: { + group: { usernames: "#{user.username},#{user2.username},#{user3.username}" }, + primary: "true" + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(User.where(primary_group_id: group.id).size).to eq(1) + end + end + end + + describe "#destroy" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should return the right response for an invalid group_id' do + max_id = Group.maximum(:id).to_i + delete "/admin/groups/#{max_id + 1}.json" + expect(response.status).to eq(404) + end + + it 'logs when a group is destroyed' do + delete "/admin/groups/#{group.id}.json" + + history = UserHistory.where(acting_user: admin).last + + expect(history).to be_present + expect(history.details).to include("name: #{group.name}") + expect(history.details).to include("id: #{group.id}") + end + + it 'logs the grant_trust_level attribute' do + trust_level = TrustLevel[4] + group.update!(grant_trust_level: trust_level) + delete "/admin/groups/#{group.id}.json" + + history = UserHistory.where(acting_user: admin).last + + expect(history).to be_present + expect(history.details).to include("grant_trust_level: #{trust_level}") + expect(history.details).to include("name: #{group.name}") + end + + context "when group is automatic" do + it "returns the right response" do + group.update!(automatic: true) + + delete "/admin/groups/#{group.id}.json" + + expect(response.status).to eq(422) + expect(Group.find(group.id)).to eq(group) + end + end + + context "when group is not automatic" do + it "returns the right response" do + delete "/admin/groups/#{group.id}.json" + + expect(response.status).to eq(200) + expect(Group.find_by(id: group.id)).to eq(nil) + end + end + end + + shared_examples "group deletion not allowed" do + it "prevents deletion with a 404 response" do + expect do + delete "/admin/groups/#{group.id}.json" + end.not_to change { Group.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + include_examples "group deletion not allowed" + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + include_examples "group deletion not allowed" + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "group deletion not allowed" + end + end + + describe '#automatic_membership_count' do + context "when logged in as admin" do + before { sign_in(admin) } + + it 'returns count of users whose emails match the domain' do + Fabricate(:user, email: 'user1@somedomain.org') + Fabricate(:user, email: 'user1@somedomain.com') + Fabricate(:user, email: 'user1@notsomedomain.com') + group = Fabricate(:group) + + put "/admin/groups/automatic_membership_count.json", params: { + automatic_membership_email_domains: 'somedomain.org|somedomain.com', + id: group.id + } + expect(response.status).to eq(200) + expect(response.parsed_body["user_count"]).to eq(2) + end + + it "doesn't responde with 500 if domain is invalid" do + group = Fabricate(:group) + + put "/admin/groups/automatic_membership_count.json", params: { + automatic_membership_email_domains: '@somedomain.org|@somedomain.com', + id: group.id + } + expect(response.status).to eq(200) + expect(response.parsed_body["user_count"]).to eq(0) + end + end + + shared_examples "automatic membership count inaccessible" do + it "denies access with a 404 response" do + put "/admin/groups/automatic_membership_count.json", params: { + automatic_membership_email_domains: 'somedomain.org|somedomain.com', + id: group.id + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + include_examples "automatic membership count inaccessible" + end + + context "with moderators_manage_categories_and_groups disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + include_examples "automatic membership count inaccessible" + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "automatic membership count inaccessible" end end end diff --git a/spec/requests/admin/impersonate_controller_spec.rb b/spec/requests/admin/impersonate_controller_spec.rb index a95527a4c1..b60edd3c5a 100644 --- a/spec/requests/admin/impersonate_controller_spec.rb +++ b/spec/requests/admin/impersonate_controller_spec.rb @@ -1,28 +1,48 @@ # frozen_string_literal: true RSpec.describe Admin::ImpersonateController do + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:another_admin) { Fabricate(:admin) } - it "is a subclass of AdminController" do - expect(Admin::ImpersonateController < Admin::AdminController).to eq(true) - end + describe '#index' do + context "when logged in as an admin" do + before { sign_in(admin) } - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } - fab!(:user) { Fabricate(:user) } - fab!(:another_admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end - - describe '#index' do it 'returns success' do get "/admin/impersonate.json" + expect(response.status).to eq(200) end end - describe '#create' do + shared_examples "impersonation inaccessible" do + it "denies access with a 404 response" do + get "/admin/impersonate.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "impersonation inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "impersonation inaccessible" + end + end + + describe '#create' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'requires a username_or_email parameter' do post "/admin/impersonate.json" expect(response.status).to eq(400) @@ -58,5 +78,32 @@ RSpec.describe Admin::ImpersonateController do end end end + + shared_examples "impersonation not allowed" do + it "prevents impersonation with a with 404 response" do + expect do + post "/admin/impersonate.json", params: { username_or_email: user.username } + end.not_to change { UserHistory.where(action: UserHistory.actions[:impersonate]).count } + + expect(response.status).to eq(404) + expect(session[:current_user_id]).to eq(current_user.id) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "impersonation not allowed" do + let(:current_user) { moderator } + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "impersonation not allowed" do + let(:current_user) { user } + end + end end end diff --git a/spec/requests/admin/permalinks_controller_spec.rb b/spec/requests/admin/permalinks_controller_spec.rb index 56cc9ec5bd..8ef11d4e33 100644 --- a/spec/requests/admin/permalinks_controller_spec.rb +++ b/spec/requests/admin/permalinks_controller_spec.rb @@ -1,109 +1,160 @@ # frozen_string_literal: true RSpec.describe Admin::PermalinksController do - - it "is a subclass of AdminController" do - expect(Admin::PermalinksController < Admin::AdminController).to eq(true) - end - fab!(:admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } describe '#index' do - it 'filters url' do - Fabricate(:permalink, url: "/forum/23") - Fabricate(:permalink, url: "/forum/98") - Fabricate(:permalink, url: "/discuss/topic/45") - Fabricate(:permalink, url: "/discuss/topic/76") + context "when logged in as an admin" do + before { sign_in(admin) } - get "/admin/permalinks.json", params: { filter: "topic" } + it 'filters url' do + Fabricate(:permalink, url: "/forum/23") + Fabricate(:permalink, url: "/forum/98") + Fabricate(:permalink, url: "/discuss/topic/45") + Fabricate(:permalink, url: "/discuss/topic/76") - expect(response.status).to eq(200) - result = response.parsed_body - expect(result.length).to eq(2) + get "/admin/permalinks.json", params: { filter: "topic" } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result.length).to eq(2) + end + + it 'filters external url' do + Fabricate(:permalink, external_url: "http://google.com") + Fabricate(:permalink, external_url: "http://wikipedia.org") + Fabricate(:permalink, external_url: "http://www.discourse.org") + Fabricate(:permalink, external_url: "http://try.discourse.org") + + get "/admin/permalinks.json", params: { filter: "discourse" } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result.length).to eq(2) + end + + it 'filters url and external url both' do + Fabricate(:permalink, url: "/forum/23", external_url: "http://google.com") + Fabricate(:permalink, url: "/discourse/98", external_url: "http://wikipedia.org") + Fabricate(:permalink, url: "/discuss/topic/45", external_url: "http://discourse.org") + Fabricate(:permalink, url: "/discuss/topic/76", external_url: "http://try.discourse.org") + + get "/admin/permalinks.json", params: { filter: "discourse" } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result.length).to eq(3) + end end - it 'filters external url' do - Fabricate(:permalink, external_url: "http://google.com") - Fabricate(:permalink, external_url: "http://wikipedia.org") - Fabricate(:permalink, external_url: "http://www.discourse.org") - Fabricate(:permalink, external_url: "http://try.discourse.org") + shared_examples "permalinks inaccessible" do + it "denies access with a 404 response" do + get "/admin/permalinks.json", params: { filter: "topic" } - get "/admin/permalinks.json", params: { filter: "discourse" } - - expect(response.status).to eq(200) - result = response.parsed_body - expect(result.length).to eq(2) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it 'filters url and external url both' do - Fabricate(:permalink, url: "/forum/23", external_url: "http://google.com") - Fabricate(:permalink, url: "/discourse/98", external_url: "http://wikipedia.org") - Fabricate(:permalink, url: "/discuss/topic/45", external_url: "http://discourse.org") - Fabricate(:permalink, url: "/discuss/topic/76", external_url: "http://try.discourse.org") + context "when logged in as a moderator" do + before { sign_in(moderator) } - get "/admin/permalinks.json", params: { filter: "discourse" } + include_examples "permalinks inaccessible" + end - expect(response.status).to eq(200) - result = response.parsed_body - expect(result.length).to eq(3) + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "permalinks inaccessible" end end describe "#create" do - it "works for topics" do - topic = Fabricate(:topic) + context "when logged in as an admin" do + before { sign_in(admin) } - post "/admin/permalinks.json", params: { - url: "/topics/771", - permalink_type: "topic_id", - permalink_type_value: topic.id - } + it "works for topics" do + topic = Fabricate(:topic) - expect(response.status).to eq(200) - expect(Permalink.last).to have_attributes(url: "topics/771", topic_id: topic.id, post_id: nil, category_id: nil, tag_id: nil) + post "/admin/permalinks.json", params: { + url: "/topics/771", + permalink_type: "topic_id", + permalink_type_value: topic.id + } + + expect(response.status).to eq(200) + expect(Permalink.last).to have_attributes(url: "topics/771", topic_id: topic.id, post_id: nil, category_id: nil, tag_id: nil) + end + + it "works for posts" do + some_post = Fabricate(:post) + + post "/admin/permalinks.json", params: { + url: "/topics/771/8291", + permalink_type: "post_id", + permalink_type_value: some_post.id + } + + expect(response.status).to eq(200) + expect(Permalink.last).to have_attributes(url: "topics/771/8291", topic_id: nil, post_id: some_post.id, category_id: nil, tag_id: nil) + end + + it "works for categories" do + category = Fabricate(:category) + + post "/admin/permalinks.json", params: { + url: "/forums/11", + permalink_type: "category_id", + permalink_type_value: category.id + } + + expect(response.status).to eq(200) + expect(Permalink.last).to have_attributes(url: "forums/11", topic_id: nil, post_id: nil, category_id: category.id, tag_id: nil) + end + + it "works for tags" do + tag = Fabricate(:tag) + + post "/admin/permalinks.json", params: { + url: "/forums/12", + permalink_type: "tag_name", + permalink_type_value: tag.name + } + + expect(response.status).to eq(200) + expect(Permalink.last).to have_attributes(url: "forums/12", topic_id: nil, post_id: nil, category_id: nil, tag_id: tag.id) + end end - it "works for posts" do - some_post = Fabricate(:post) + shared_examples "permalink creation not allowed" do + it "prevents creation with a 404 response" do + topic = Fabricate(:topic) - post "/admin/permalinks.json", params: { - url: "/topics/771/8291", - permalink_type: "post_id", - permalink_type_value: some_post.id - } + expect do + post "/admin/permalinks.json", params: { + url: "/topics/771", + permalink_type: "topic_id", + permalink_type_value: topic.id + } + end.not_to change { Permalink.count } - expect(response.status).to eq(200) - expect(Permalink.last).to have_attributes(url: "topics/771/8291", topic_id: nil, post_id: some_post.id, category_id: nil, tag_id: nil) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it "works for categories" do - category = Fabricate(:category) + context "when logged in as a moderator" do + before { sign_in(moderator) } - post "/admin/permalinks.json", params: { - url: "/forums/11", - permalink_type: "category_id", - permalink_type_value: category.id - } - - expect(response.status).to eq(200) - expect(Permalink.last).to have_attributes(url: "forums/11", topic_id: nil, post_id: nil, category_id: category.id, tag_id: nil) + include_examples "permalink creation not allowed" end - it "works for tags" do - tag = Fabricate(:tag) + context "when logged in as a non-staff user" do + before { sign_in(user) } - post "/admin/permalinks.json", params: { - url: "/forums/12", - permalink_type: "tag_name", - permalink_type_value: tag.name - } - - expect(response.status).to eq(200) - expect(Permalink.last).to have_attributes(url: "forums/12", topic_id: nil, post_id: nil, category_id: nil, tag_id: tag.id) + include_examples "permalink creation not allowed" end end end diff --git a/spec/requests/admin/plugins_controller_spec.rb b/spec/requests/admin/plugins_controller_spec.rb index fe83c428f6..de2b822503 100644 --- a/spec/requests/admin/plugins_controller_spec.rb +++ b/spec/requests/admin/plugins_controller_spec.rb @@ -1,20 +1,42 @@ # frozen_string_literal: true RSpec.describe Admin::PluginsController do + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - it "is a subclass of StaffController" do - expect(Admin::PluginsController < Admin::StaffController).to eq(true) - end + describe "#index" do + context "while logged in as an admin" do + before { sign_in(admin) } - context "while logged in as an admin" do - before do - sign_in(Fabricate(:admin)) + it "returns plugins" do + get "/admin/plugins.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.has_key?('plugins')).to eq(true) + end end - it 'should return JSON' do - get "/admin/plugins.json" - expect(response.status).to eq(200) - expect(response.parsed_body.has_key?('plugins')).to eq(true) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "returns plugins" do + get "/admin/plugins.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.has_key?('plugins')).to eq(true) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/plugins.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/reports_controller_spec.rb b/spec/requests/admin/reports_controller_spec.rb index 41c21946fb..71d5f52cb8 100644 --- a/spec/requests/admin/reports_controller_spec.rb +++ b/spec/requests/admin/reports_controller_spec.rb @@ -1,19 +1,14 @@ # frozen_string_literal: true RSpec.describe Admin::ReportsController do - it "is a subclass of StaffController" do - expect(Admin::ReportsController < Admin::StaffController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } - fab!(:user) { Fabricate(:user) } + describe '#bulk' do + context "when logged in as an admin" do + before { sign_in(admin) } - before do - sign_in(admin) - end - - describe '#bulk' do context "with valid params" do it "renders the reports as JSON" do Fabricate(:topic) @@ -66,7 +61,45 @@ RSpec.describe Admin::ReportsController do end end - describe '#show' do + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "returns report" do + Fabricate(:topic) + + get "/admin/reports/bulk.json", params: { + reports: { + topics: { limit: 10 }, + likes: { limit: 10 } + } + } + + expect(response.status).to eq(200) + expect(response.parsed_body["reports"].count).to eq(2) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/reports/bulk.json", params: { + reports: { + topics: { limit: 10 }, + not_found: { limit: 10 } + } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe '#show' do + context "when logged in as an admin" do + before { sign_in(admin) } + context "with invalid id form" do let(:invalid_id) { "!!&asdfasdf" } @@ -131,5 +164,29 @@ RSpec.describe Admin::ReportsController do end end end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "returns report" do + Fabricate(:topic) + + get "/admin/reports/topics.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["report"]["total"]).to eq(1) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/reports/topics.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end end end diff --git a/spec/requests/admin/robots_txt_controller_spec.rb b/spec/requests/admin/robots_txt_controller_spec.rb index f39b369d3e..fd9464c3ad 100644 --- a/spec/requests/admin/robots_txt_controller_spec.rb +++ b/spec/requests/admin/robots_txt_controller_spec.rb @@ -1,31 +1,123 @@ # frozen_string_literal: true RSpec.describe Admin::RobotsTxtController do - it "is a subclass of AdminController" do - expect(described_class < Admin::AdminController).to eq(true) - end - fab!(:admin) { Fabricate(:admin) } fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } - context "when logged in as a non-admin user" do - shared_examples "access_forbidden" do - it "can't see #show" do + describe "#show" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "returns default content if there are no overrides" do get "/admin/customize/robots.json" - expect(response.status).to eq(404) + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["robots_txt"]).to be_present + expect(json["overridden"]).to eq(false) end - it "can't perform #update" do - put "/admin/customize/robots.json", params: { robots_txt: "adasdasd" } + it "returns overridden content if there are overrides" do + SiteSetting.overridden_robots_txt = "something" + get "/admin/customize/robots.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["robots_txt"]).to eq("something") + expect(json["overridden"]).to eq(true) + end + end + + shared_examples "robot.txt inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/robots.json" + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "robot.txt inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "robot.txt inaccessible" + end + end + + describe "#update" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "overrides the site's default robots.txt" do + put "/admin/customize/robots.json", params: { robots_txt: "new_content" } + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["robots_txt"]).to eq("new_content") + expect(json["overridden"]).to eq(true) + expect(SiteSetting.overridden_robots_txt).to eq("new_content") + + get "/robots.txt" + expect(response.body).to include("new_content") + end + + it "requires `robots_txt` param to be present" do + SiteSetting.overridden_robots_txt = "overridden_content" + put "/admin/customize/robots.json", params: { robots_txt: "" } + expect(response.status).to eq(400) + end + end + + shared_examples "robot.txt update not allowed" do + it "prevents updates with a 404 response" do + put "/admin/customize/robots.json", params: { robots_txt: "adasdasd" } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(SiteSetting.overridden_robots_txt).to eq("") end + end - it "can't perform #reset" do + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "robot.txt update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "robot.txt update not allowed" + end + end + + describe "#reset" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "resets robots.txt file to the default version" do SiteSetting.overridden_robots_txt = "overridden_content" delete "/admin/customize/robots.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["robots_txt"]).not_to include("overridden_content") + expect(json["overridden"]).to eq(false) + expect(SiteSetting.overridden_robots_txt).to eq("") + end + end + + shared_examples "robot.txt reset not allowed" do + it "prevents resets with a 404 response" do + SiteSetting.overridden_robots_txt = "overridden_content" + + delete "/admin/customize/robots.json" + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(SiteSetting.overridden_robots_txt).to eq("overridden_content") end end @@ -33,70 +125,13 @@ RSpec.describe Admin::RobotsTxtController do context "when logged in as a moderator" do before { sign_in(moderator) } - include_examples "access_forbidden" + include_examples "robot.txt reset not allowed" end - context "when logged in as non-staff user" do - before { sign_in(user) } + context "when logged in as a non-staff user" do + before { sign_in(user) } - include_examples "access_forbidden" - end - end - - describe "#show" do - before { sign_in(admin) } - - it "returns default content if there are no overrides" do - get "/admin/customize/robots.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["robots_txt"]).to be_present - expect(json["overridden"]).to eq(false) - end - - it "returns overridden content if there are overrides" do - SiteSetting.overridden_robots_txt = "something" - get "/admin/customize/robots.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["robots_txt"]).to eq("something") - expect(json["overridden"]).to eq(true) - end - end - - describe "#update" do - before { sign_in(admin) } - - it "overrides the site's default robots.txt" do - put "/admin/customize/robots.json", params: { robots_txt: "new_content" } - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["robots_txt"]).to eq("new_content") - expect(json["overridden"]).to eq(true) - expect(SiteSetting.overridden_robots_txt).to eq("new_content") - - get "/robots.txt" - expect(response.body).to include("new_content") - end - - it "requires `robots_txt` param to be present" do - SiteSetting.overridden_robots_txt = "overridden_content" - put "/admin/customize/robots.json", params: { robots_txt: "" } - expect(response.status).to eq(400) - end - end - - describe "#reset" do - before { sign_in(admin) } - - it "resets robots.txt file to the default version" do - SiteSetting.overridden_robots_txt = "overridden_content" - delete "/admin/customize/robots.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["robots_txt"]).not_to include("overridden_content") - expect(json["overridden"]).to eq(false) - expect(SiteSetting.overridden_robots_txt).to eq("") + include_examples "robot.txt reset not allowed" end end end diff --git a/spec/requests/admin/screened_emails_controller_spec.rb b/spec/requests/admin/screened_emails_controller_spec.rb index 8d9fc48270..da2b177d50 100644 --- a/spec/requests/admin/screened_emails_controller_spec.rb +++ b/spec/requests/admin/screened_emails_controller_spec.rb @@ -1,21 +1,78 @@ # frozen_string_literal: true RSpec.describe Admin::ScreenedEmailsController do - it "is a subclass of StaffController" do - expect(Admin::ScreenedEmailsController < Admin::StaffController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:screened_email) { Fabricate(:screened_email) } describe '#index' do - before do - sign_in(Fabricate(:admin)) + shared_examples "screened emails accessible" do + it "returns screened emails" do + get "/admin/logs/screened_emails.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json.size).to eq(1) + end end - it 'returns JSON' do - Fabricate(:screened_email) - get "/admin/logs/screened_emails.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json.size).to eq(1) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "screened emails accessible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "screened emails accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/logs/screened_emails.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe "#destroy" do + shared_examples "screened email deletion possible" do + it "deletes screened email" do + expect do + delete "/admin/logs/screened_emails/#{screened_email.id}.json" + end.to change { ScreenedEmail.count }.by(-1) + + expect(response.status).to eq(200) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "screened email deletion possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "screened email deletion possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents deletion with a 404 response" do + delete "/admin/logs/screened_emails/#{screened_email.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/screened_ip_addresses_controller_spec.rb b/spec/requests/admin/screened_ip_addresses_controller_spec.rb index f6f2a06880..3073b50979 100644 --- a/spec/requests/admin/screened_ip_addresses_controller_spec.rb +++ b/spec/requests/admin/screened_ip_addresses_controller_spec.rb @@ -1,47 +1,65 @@ # frozen_string_literal: true RSpec.describe Admin::ScreenedIpAddressesController do - - it "is a subclass of StaffController" do - expect(Admin::ScreenedIpAddressesController < Admin::StaffController).to eq(true) - end - fab!(:admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } describe '#index' do - it 'filters screened ip addresses' do - Fabricate(:screened_ip_address, ip_address: "1.2.3.4") - Fabricate(:screened_ip_address, ip_address: "1.2.3.5") - Fabricate(:screened_ip_address, ip_address: "1.2.3.6") - Fabricate(:screened_ip_address, ip_address: "4.5.6.7") - Fabricate(:screened_ip_address, ip_address: "5.0.0.0/8") + shared_examples "screened ip addresses accessible" do + it 'filters screened ip addresses' do + Fabricate(:screened_ip_address, ip_address: "1.2.3.4") + Fabricate(:screened_ip_address, ip_address: "1.2.3.5") + Fabricate(:screened_ip_address, ip_address: "1.2.3.6") + Fabricate(:screened_ip_address, ip_address: "4.5.6.7") + Fabricate(:screened_ip_address, ip_address: "5.0.0.0/8") - get "/admin/logs/screened_ip_addresses.json", params: { filter: "1.2.*" } + get "/admin/logs/screened_ip_addresses.json", params: { filter: "1.2.*" } - expect(response.status).to eq(200) - expect(response.parsed_body.map { |record| record["ip_address"] }) - .to contain_exactly("1.2.3.4", "1.2.3.5", "1.2.3.6") + expect(response.status).to eq(200) + expect(response.parsed_body.map { |record| record["ip_address"] }) + .to contain_exactly("1.2.3.4", "1.2.3.5", "1.2.3.6") - get "/admin/logs/screened_ip_addresses.json", params: { filter: "4.5.6.7" } + get "/admin/logs/screened_ip_addresses.json", params: { filter: "4.5.6.7" } - expect(response.status).to eq(200) - expect(response.parsed_body.map { |record| record["ip_address"] }) - .to contain_exactly("4.5.6.7") + expect(response.status).to eq(200) + expect(response.parsed_body.map { |record| record["ip_address"] }) + .to contain_exactly("4.5.6.7") - get "/admin/logs/screened_ip_addresses.json", params: { filter: "5.0.0.1" } + get "/admin/logs/screened_ip_addresses.json", params: { filter: "5.0.0.1" } - expect(response.status).to eq(200) - expect(response.parsed_body.map { |record| record["ip_address"] }) - .to contain_exactly("5.0.0.0/8") + expect(response.status).to eq(200) + expect(response.parsed_body.map { |record| record["ip_address"] }) + .to contain_exactly("5.0.0.0/8") - get "/admin/logs/screened_ip_addresses.json", params: { filter: "6.0.0.1" } + get "/admin/logs/screened_ip_addresses.json", params: { filter: "6.0.0.1" } - expect(response.status).to eq(200) - expect(response.parsed_body).to be_blank + expect(response.status).to eq(200) + expect(response.parsed_body).to be_blank + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "screened ip addresses accessible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "screened ip addresses accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/logs/screened_ip_addresses.json", params: { filter: "1.2.*" } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/screened_urls_controller_spec.rb b/spec/requests/admin/screened_urls_controller_spec.rb index a4d5cb2d17..c7db4dba0b 100644 --- a/spec/requests/admin/screened_urls_controller_spec.rb +++ b/spec/requests/admin/screened_urls_controller_spec.rb @@ -1,21 +1,43 @@ # frozen_string_literal: true RSpec.describe Admin::ScreenedUrlsController do - it "is a subclass of StaffController" do - expect(Admin::ScreenedUrlsController < Admin::StaffController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:screened_url) { Fabricate(:screened_url) } describe '#index' do - before do - sign_in(Fabricate(:admin)) + shared_examples "screened urls accessible" do + it "returns screened urls" do + get "/admin/logs/screened_urls.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json.size).to eq(1) + end end - it 'returns JSON' do - Fabricate(:screened_url) - get "/admin/logs/screened_urls.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json.size).to eq(1) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "screened urls accessible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "screened urls accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/logs/screened_urls.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/search_logs_spec.rb b/spec/requests/admin/search_logs_spec.rb index 7692d528d7..caab13f579 100644 --- a/spec/requests/admin/search_logs_spec.rb +++ b/spec/requests/admin/search_logs_spec.rb @@ -6,99 +6,89 @@ RSpec.describe Admin::SearchLogsController do fab!(:user) { Fabricate(:user) } before do - SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1') + SearchLog.log(term: "ruby", search_type: :header, ip_address: "127.0.0.1") end after do SearchLog.clear_debounce_cache! end - it "is a subclass of StaffController" do - expect(Admin::SearchLogsController < Admin::StaffController).to eq(true) - end - describe "#index" do - it "raises an error if you aren't logged in" do - get '/admin/logs/search_logs.json' - expect(response.status).to eq(404) + shared_examples "search logs accessible" do + it "returns search logs" do + get '/admin/logs/search_logs.json' + + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json[0]["term"]).to eq("ruby") + expect(json[0]["searches"]).to eq(1) + expect(json[0]["ctr"]).to eq(0) + end end - it "raises an error if you aren't an admin" do - sign_in(user) - get '/admin/logs/search_logs.json' - expect(response.status).to eq(404) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "search logs accessible" end - it "should work if you are an admin" do - sign_in(admin) - get '/admin/logs/search_logs.json' + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json[0]['term']).to eq('ruby') - expect(json[0]['searches']).to eq(1) - expect(json[0]['ctr']).to eq(0) + include_examples "search logs accessible" end - it "should work if you are a moderator" do - sign_in(moderator) - get "/admin/logs/search_logs.json" + context "when logged in as a non-staff user" do + before { sign_in(user) } - expect(response.status).to eq(200) + it "denies access with a 404 response" do + get "/admin/logs/search_logs.json" - json = response.parsed_body - expect(json[0]["term"]).to eq("ruby") - expect(json[0]["searches"]).to eq(1) - expect(json[0]["ctr"]).to eq(0) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end describe "#term" do - it "raises an error if you aren't logged in" do - get '/admin/logs/search_logs/term.json', params: { - term: "ruby" - } + shared_examples "search log term accessible" do + it "returns search log term" do + get '/admin/logs/search_logs/term.json', params: { + term: "ruby" + } - expect(response.status).to eq(404) + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json['term']['type']).to eq('search_log_term') + expect(json['term']['search_result']).to be_present + end end - it "raises an error if you aren't an admin" do - sign_in(user) + context "when logged in as an admin" do + before { sign_in(admin) } - get '/admin/logs/search_logs/term.json', params: { - term: "ruby" - } - - expect(response.status).to eq(404) + include_examples "search log term accessible" end - it "should work if you are an admin" do - sign_in(admin) + context "when logged in as a moderator" do + before { sign_in(moderator) } - get '/admin/logs/search_logs/term.json', params: { - term: "ruby" - } - - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json['term']['type']).to eq('search_log_term') - expect(json['term']['search_result']).to be_present + include_examples "search log term accessible" end - it "should work if you are a moderator" do - sign_in(moderator) + context "when logged in as a non-staff user" do + before { sign_in(user) } - get "/admin/logs/search_logs/term.json", params: { - term: "ruby" - } + it "denies access with a 404 response" do + get "/admin/logs/search_logs/term.json", params: { + term: "ruby" + } - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json["term"]["type"]).to eq("search_log_term") - expect(json["term"]["search_result"]).to be_present + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/site_settings_controller_spec.rb b/spec/requests/admin/site_settings_controller_spec.rb index 8837fadf49..64fc9b6c6b 100644 --- a/spec/requests/admin/site_settings_controller_spec.rb +++ b/spec/requests/admin/site_settings_controller_spec.rb @@ -1,21 +1,17 @@ # frozen_string_literal: true RSpec.describe Admin::SiteSettingsController do + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - it "is a subclass of AdminController" do - expect(Admin::SiteSettingsController < Admin::AdminController).to eq(true) - end + describe "#index" do + context "when logged in as an admin" do + before { sign_in(admin) } - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end - - describe '#index' do - it 'returns valid info' do + it "returns valid info" do get "/admin/site_settings.json" + expect(response.status).to eq(200) json = response.parsed_body expect(json["site_settings"].length).to be > 100 @@ -28,12 +24,205 @@ RSpec.describe Admin::SiteSettingsController do end end - describe '#update' do - before do - SiteSetting.setting(:test_setting, "default") - SiteSetting.setting(:test_upload, "", type: :upload) - SiteSetting.refresh! + shared_examples "site settings inaccessible" do + it "denies access with a 404 response" do + get "/admin/site_settings.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "site settings inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "site settings inaccessible" + end + end + + describe "#user_count" do + fab!(:staged_user) { Fabricate(:staged) } + let(:tracking) { NotificationLevels.all[:tracking] } + + before do + SiteSetting.setting(:test_setting, "default") + SiteSetting.setting(:test_upload, "", type: :upload) + SiteSetting.refresh! + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'should return correct user count for default categories change' do + category_id = Fabricate(:category).id + + put "/admin/site_settings/default_categories_watching/user_count.json", params: { + default_categories_watching: category_id + } + + expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count) + + CategoryUser.create!(category_id: category_id, notification_level: tracking, user: user) + + put "/admin/site_settings/default_categories_watching/user_count.json", params: { + default_categories_watching: category_id + } + + expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count - 1) + + SiteSetting.setting(:default_categories_watching, "") + end + + it 'should return correct user count for default tags change' do + tag = Fabricate(:tag) + + put "/admin/site_settings/default_tags_watching/user_count.json", params: { + default_tags_watching: tag.name + } + + expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count) + + TagUser.create!(tag_id: tag.id, notification_level: tracking, user: user) + + put "/admin/site_settings/default_tags_watching/user_count.json", params: { + default_tags_watching: tag.name + } + + expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count - 1) + + SiteSetting.setting(:default_tags_watching, "") + end + + context "for sidebar defaults" do + it 'returns the right count for the default_sidebar_categories site setting' do + category = Fabricate(:category) + + put "/admin/site_settings/default_sidebar_categories/user_count.json", params: { + default_sidebar_categories: "#{category.id}" + } + + expect(response.status).to eq(200) + expect(response.parsed_body["user_count"]).to eq(User.real.not_staged.count) + end + + it 'returns the right count for the default_sidebar_tags site setting' do + tag = Fabricate(:tag) + + put "/admin/site_settings/default_sidebar_tags/user_count.json", params: { + default_sidebar_tags: "#{tag.name}" + } + + expect(response.status).to eq(200) + expect(response.parsed_body["user_count"]).to eq(User.real.not_staged.count) + end + end + + context "with user options" do + def expect_user_count(site_setting_name:, user_setting_name:, current_site_setting_value:, new_site_setting_value:, + current_user_setting_value: nil, new_user_setting_value: nil) + + current_user_setting_value ||= current_site_setting_value + new_user_setting_value ||= new_site_setting_value + + SiteSetting.public_send("#{site_setting_name}=", current_site_setting_value) + UserOption.human_users.update_all(user_setting_name => current_user_setting_value) + user_count = User.human_users.count + + # Correctly counts users when all of them have default value + put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { + site_setting_name => new_site_setting_value + } + expect(response.parsed_body["user_count"]).to eq(user_count) + + # Correctly counts users when one of them already has new value + user.user_option.update!(user_setting_name => new_user_setting_value) + put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { + site_setting_name => new_site_setting_value + } + expect(response.parsed_body["user_count"]).to eq(user_count - 1) + + # Correctly counts users when site setting value has been changed + SiteSetting.public_send("#{site_setting_name}=", new_site_setting_value) + put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { + site_setting_name => current_site_setting_value + } + expect(response.parsed_body["user_count"]).to eq(1) + end + + it "should return correct user count for boolean setting" do + expect_user_count( + site_setting_name: "default_other_external_links_in_new_tab", + user_setting_name: "external_links_in_new_tab", + current_site_setting_value: false, + new_site_setting_value: true + ) + end + + it "should return correct user count for 'text_size_key'" do + expect_user_count( + site_setting_name: "default_text_size", + user_setting_name: "text_size_key", + current_site_setting_value: "normal", + new_site_setting_value: "larger", + current_user_setting_value: UserOption.text_sizes[:normal], + new_user_setting_value: UserOption.text_sizes[:larger] + ) + end + + it "should return correct user count for 'title_count_mode_key'" do + expect_user_count( + site_setting_name: "default_title_count_mode", + user_setting_name: "title_count_mode_key", + current_site_setting_value: "notifications", + new_site_setting_value: "contextual", + current_user_setting_value: UserOption.title_count_modes[:notifications], + new_user_setting_value: UserOption.title_count_modes[:contextual] + ) + end + end + end + + shared_examples "user counts inaccessible" do + it "denies access with a 404 response" do + category_id = Fabricate(:category).id + + put "/admin/site_settings/default_categories_watching/user_count.json", params: { + default_categories_watching: category_id + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user counts inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user counts inaccessible" + end + end + + describe '#update' do + before do + SiteSetting.setting(:test_setting, "default") + SiteSetting.setting(:test_upload, "", type: :upload) + SiteSetting.refresh! + end + + context "when logged in as an admin" do + before { sign_in(admin) } it 'sets the value when the param is present' do put "/admin/site_settings/test_setting.json", params: { @@ -70,7 +259,7 @@ RSpec.describe Admin::SiteSettingsController do expect(SiteSetting.test_setting).to eq('') end - describe 'default user options' do + context "with default user options" do let!(:user1) { Fabricate(:user) } let!(:user2) { Fabricate(:user) } @@ -154,7 +343,7 @@ RSpec.describe Admin::SiteSettingsController do end end - describe 'default categories' do + context "with default categories" do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } fab!(:staged_user) { Fabricate(:staged) } @@ -219,7 +408,7 @@ RSpec.describe Admin::SiteSettingsController do end end - describe 'default tags' do + context "with default tags" do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } fab!(:staged_user) { Fabricate(:staged) } @@ -258,141 +447,7 @@ RSpec.describe Admin::SiteSettingsController do end end - describe '#user_count' do - fab!(:user) { Fabricate(:user) } - fab!(:staged_user) { Fabricate(:staged) } - let(:tracking) { NotificationLevels.all[:tracking] } - - it 'should return correct user count for default categories change' do - category_id = Fabricate(:category).id - - put "/admin/site_settings/default_categories_watching/user_count.json", params: { - default_categories_watching: category_id - } - - expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count) - - CategoryUser.create!(category_id: category_id, notification_level: tracking, user: user) - - put "/admin/site_settings/default_categories_watching/user_count.json", params: { - default_categories_watching: category_id - } - - expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count - 1) - - SiteSetting.setting(:default_categories_watching, "") - end - - it 'should return correct user count for default tags change' do - tag = Fabricate(:tag) - - put "/admin/site_settings/default_tags_watching/user_count.json", params: { - default_tags_watching: tag.name - } - - expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count) - - TagUser.create!(tag_id: tag.id, notification_level: tracking, user: user) - - put "/admin/site_settings/default_tags_watching/user_count.json", params: { - default_tags_watching: tag.name - } - - expect(response.parsed_body["user_count"]).to eq(User.real.where(staged: false).count - 1) - - SiteSetting.setting(:default_tags_watching, "") - end - - context "for sidebar defaults" do - it 'returns the right count for the default_sidebar_categories site setting' do - category = Fabricate(:category) - - put "/admin/site_settings/default_sidebar_categories/user_count.json", params: { - default_sidebar_categories: "#{category.id}" - } - - expect(response.status).to eq(200) - expect(response.parsed_body["user_count"]).to eq(User.real.not_staged.count) - end - - it 'returns the right count for the default_sidebar_tags site setting' do - tag = Fabricate(:tag) - - put "/admin/site_settings/default_sidebar_tags/user_count.json", params: { - default_sidebar_tags: "#{tag.name}" - } - - expect(response.status).to eq(200) - expect(response.parsed_body["user_count"]).to eq(User.real.not_staged.count) - end - end - - context "with user options" do - def expect_user_count(site_setting_name:, user_setting_name:, current_site_setting_value:, new_site_setting_value:, - current_user_setting_value: nil, new_user_setting_value: nil) - - current_user_setting_value ||= current_site_setting_value - new_user_setting_value ||= new_site_setting_value - - SiteSetting.public_send("#{site_setting_name}=", current_site_setting_value) - UserOption.human_users.update_all(user_setting_name => current_user_setting_value) - user_count = User.human_users.count - - # Correctly counts users when all of them have default value - put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { - site_setting_name => new_site_setting_value - } - expect(response.parsed_body["user_count"]).to eq(user_count) - - # Correctly counts users when one of them already has new value - user.user_option.update!(user_setting_name => new_user_setting_value) - put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { - site_setting_name => new_site_setting_value - } - expect(response.parsed_body["user_count"]).to eq(user_count - 1) - - # Correctly counts users when site setting value has been changed - SiteSetting.public_send("#{site_setting_name}=", new_site_setting_value) - put "/admin/site_settings/#{site_setting_name}/user_count.json", params: { - site_setting_name => current_site_setting_value - } - expect(response.parsed_body["user_count"]).to eq(1) - end - - it "should return correct user count for boolean setting" do - expect_user_count( - site_setting_name: "default_other_external_links_in_new_tab", - user_setting_name: "external_links_in_new_tab", - current_site_setting_value: false, - new_site_setting_value: true - ) - end - - it "should return correct user count for 'text_size_key'" do - expect_user_count( - site_setting_name: "default_text_size", - user_setting_name: "text_size_key", - current_site_setting_value: "normal", - new_site_setting_value: "larger", - current_user_setting_value: UserOption.text_sizes[:normal], - new_user_setting_value: UserOption.text_sizes[:larger] - ) - end - - it "should return correct user count for 'title_count_mode_key'" do - expect_user_count( - site_setting_name: "default_title_count_mode", - user_setting_name: "title_count_mode_key", - current_site_setting_value: "notifications", - new_site_setting_value: "contextual", - current_user_setting_value: UserOption.title_count_modes[:notifications], - new_user_setting_value: UserOption.title_count_modes[:contextual] - ) - end - end - end - - describe 'upload site settings' do + context "with upload site settings" do it 'can remove the site setting' do SiteSetting.test_upload = Fabricate(:upload) @@ -467,5 +522,28 @@ RSpec.describe Admin::SiteSettingsController do expect(response.status).to eq(422) end end + + shared_examples "site setting update not allowed" do + it "prevents updates with a 404 response" do + put "/admin/site_settings/test_setting.json", params: { + test_setting: 'hello' + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "site setting update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "site setting update not allowed" + end end end diff --git a/spec/requests/admin/site_texts_controller_spec.rb b/spec/requests/admin/site_texts_controller_spec.rb index 579e1c5675..de85e4826c 100644 --- a/spec/requests/admin/site_texts_controller_spec.rb +++ b/spec/requests/admin/site_texts_controller_spec.rb @@ -2,6 +2,7 @@ RSpec.describe Admin::SiteTextsController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } let(:default_locale) { I18n.locale } @@ -10,40 +11,10 @@ RSpec.describe Admin::SiteTextsController do I18n.reload! end - it "is a subclass of AdminController" do - expect(Admin::SiteTextsController < Admin::AdminController).to eq(true) - end + describe '#index' do + context "when logged in as an admin" do + before { sign_in(admin) } - context "when not logged in as an admin" do - it "raises an error if you aren't logged in" do - put '/admin/customize/site_texts/some_key.json', params: { - site_text: { value: 'foo' }, locale: default_locale - } - - expect(response.status).to eq(404) - end - - it "raises an error if you aren't an admin" do - sign_in(user) - - put "/admin/customize/site_texts/some_key.json", params: { - site_text: { value: 'foo' }, locale: default_locale - } - expect(response.status).to eq(404) - - put "/admin/customize/reseed.json", params: { - category_ids: [], topic_ids: [] - } - expect(response.status).to eq(404) - end - end - - context "when logged in as admin" do - before do - sign_in(admin) - end - - describe '#index' do it 'returns json' do get "/admin/customize/site_texts.json", params: { q: 'title', locale: default_locale } expect(response.status).to eq(200) @@ -232,7 +203,32 @@ RSpec.describe Admin::SiteTextsController do end end - describe '#show' do + shared_examples "site texts inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/site_texts.json", params: { q: 'title', locale: default_locale } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "site texts inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "site texts inaccessible" + end + end + + describe '#show' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'returns a site text for a key that exists' do get "/admin/customize/site_texts/js.topic.list.json", params: { locale: default_locale } expect(response.status).to eq(200) @@ -373,7 +369,32 @@ RSpec.describe Admin::SiteTextsController do end end - describe '#update & #revert' do + shared_examples "site text inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/site_texts/js.topic.list.json", params: { locale: default_locale } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "site text inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "site text inaccessible" + end + end + + describe '#update & #revert' do + context "when logged in as an admin" do + before { sign_in(admin) } + it "returns 'not found' when an unknown key is used" do put '/admin/customize/site_texts/some_key.json', params: { site_text: { value: 'foo', locale: default_locale } @@ -551,26 +572,53 @@ RSpec.describe Admin::SiteTextsController do end end - context "when reseeding" do - before do - staff_category = Fabricate( - :category, - name: "Staff EN", - user: Discourse.system_user - ) - SiteSetting.staff_category_id = staff_category.id + shared_examples "site text update not allowed" do + it "prevents updates with a 404 response" do + put "/admin/customize/site_texts/js.emoji_picker.animals_%26_nature.json", params: { + site_text: { value: 'foo', locale: default_locale } + } - guidelines_topic = Fabricate( - :topic, - title: "The English Guidelines", - category: @staff_category, - user: Discourse.system_user - ) - Fabricate(:post, topic: guidelines_topic, user: Discourse.system_user) - SiteSetting.guidelines_topic_id = guidelines_topic.id + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "site text update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "site text update not allowed" + end + end + + context "when reseeding" do + before do + staff_category = Fabricate( + :category, + name: "Staff EN", + user: Discourse.system_user + ) + SiteSetting.staff_category_id = staff_category.id + + guidelines_topic = Fabricate( + :topic, + title: "The English Guidelines", + category: @staff_category, + user: Discourse.system_user + ) + Fabricate(:post, topic: guidelines_topic, user: Discourse.system_user) + SiteSetting.guidelines_topic_id = guidelines_topic.id + end + + describe '#get_reseed_options' do + context "when logged in as an admin" do + before { sign_in(admin) } - describe '#get_reseed_options' do it 'returns correct json' do get "/admin/customize/reseed.json" expect(response.status).to eq(200) @@ -587,7 +635,32 @@ RSpec.describe Admin::SiteTextsController do end end - describe '#reseed' do + shared_examples "reseed options inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/reseed.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "reseed options inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "reseed options inaccessible" + end + end + + describe '#reseed' do + context "when logged in as an admin" do + before { sign_in(admin) } + it 'reseeds categories and topics' do SiteSetting.default_locale = :de @@ -601,6 +674,30 @@ RSpec.describe Admin::SiteTextsController do expect(Topic.find(SiteSetting.guidelines_topic_id).title).to eq(I18n.t("guidelines_topic.title", locale: :de)) end end + + shared_examples "reseed not allowed" do + it "prevents reseeds with a 404 response" do + post "/admin/customize/reseed.json", params: { + category_ids: ["staff_category_id"], + topic_ids: ["guidelines_topic_id"] + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "reseed not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "reseed not allowed" + end end end end diff --git a/spec/requests/admin/staff_action_logs_controller_spec.rb b/spec/requests/admin/staff_action_logs_controller_spec.rb index e59ae0fca6..873fa8218d 100644 --- a/spec/requests/admin/staff_action_logs_controller_spec.rb +++ b/spec/requests/admin/staff_action_logs_controller_spec.rb @@ -1,99 +1,146 @@ # frozen_string_literal: true RSpec.describe Admin::StaffActionLogsController do - it "is a subclass of StaffController" do - expect(Admin::StaffActionLogsController < Admin::StaffController).to eq(true) - end - fab!(:admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } describe '#index' do - it 'generates logs' do - topic = Fabricate(:topic) - StaffActionLogger.new(Discourse.system_user).log_topic_delete_recover(topic, "delete_topic") + shared_examples "staff action logs accessible" do + it 'returns logs' do + topic = Fabricate(:topic) + StaffActionLogger.new(Discourse.system_user).log_topic_delete_recover(topic, "delete_topic") - get "/admin/logs/staff_action_logs.json", params: { action_id: UserHistory.actions[:delete_topic] } - - json = response.parsed_body - expect(response.status).to eq(200) - - expect(json["staff_action_logs"].length).to eq(1) - expect(json["staff_action_logs"][0]["action_name"]).to eq("delete_topic") - - expect(json["extras"]["user_history_actions"]).to include( - "id" => 'delete_topic', "action_id" => UserHistory.actions[:delete_topic] - ) - end - - it 'generates logs with pages' do - 1.upto(4).each do |idx| - StaffActionLogger.new(Discourse.system_user).log_site_setting_change("title", "value #{idx - 1}", "value #{idx}") - end - - get "/admin/logs/staff_action_logs.json", params: { limit: 3 } - - json = response.parsed_body - expect(response.status).to eq(200) - expect(json["staff_action_logs"].length).to eq(3) - expect(json["staff_action_logs"][0]["new_value"]).to eq("value 4") - - get "/admin/logs/staff_action_logs.json", params: { limit: 3, page: 1 } - - json = response.parsed_body - expect(response.status).to eq(200) - expect(json["staff_action_logs"].length).to eq(1) - expect(json["staff_action_logs"][0]["new_value"]).to eq("value 1") - end - - context 'when staff actions are extended' do - let(:plugin_extended_action) { :confirmed_ham } - before { UserHistory.stubs(:staff_actions).returns([plugin_extended_action]) } - after { UserHistory.unstub(:staff_actions) } - - it 'Uses the custom_staff id' do - get "/admin/logs/staff_action_logs.json", params: {} + get "/admin/logs/staff_action_logs.json", params: { action_id: UserHistory.actions[:delete_topic] } json = response.parsed_body - action = json['extras']['user_history_actions'].first + expect(response.status).to eq(200) - expect(action['id']).to eq plugin_extended_action.to_s - expect(action['action_id']).to eq UserHistory.actions[:custom_staff] + expect(json["staff_action_logs"].length).to eq(1) + expect(json["staff_action_logs"][0]["action_name"]).to eq("delete_topic") + + expect(json["extras"]["user_history_actions"]).to include( + "id" => 'delete_topic', "action_id" => UserHistory.actions[:delete_topic] + ) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "staff action logs accessible" + + it 'generates logs with pages' do + 1.upto(4).each do |idx| + StaffActionLogger.new(Discourse.system_user).log_site_setting_change("title", "value #{idx - 1}", "value #{idx}") + end + + get "/admin/logs/staff_action_logs.json", params: { limit: 3 } + + json = response.parsed_body + expect(response.status).to eq(200) + expect(json["staff_action_logs"].length).to eq(3) + expect(json["staff_action_logs"][0]["new_value"]).to eq("value 4") + + get "/admin/logs/staff_action_logs.json", params: { limit: 3, page: 1 } + + json = response.parsed_body + expect(response.status).to eq(200) + expect(json["staff_action_logs"].length).to eq(1) + expect(json["staff_action_logs"][0]["new_value"]).to eq("value 1") + end + + context 'when staff actions are extended' do + let(:plugin_extended_action) { :confirmed_ham } + before { UserHistory.stubs(:staff_actions).returns([plugin_extended_action]) } + after { UserHistory.unstub(:staff_actions) } + + it 'Uses the custom_staff id' do + get "/admin/logs/staff_action_logs.json", params: {} + + json = response.parsed_body + action = json['extras']['user_history_actions'].first + + expect(action['id']).to eq plugin_extended_action.to_s + expect(action['action_id']).to eq UserHistory.actions[:custom_staff] + end + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "staff action logs accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/logs/staff_action_logs.json", params: { action_id: UserHistory.actions[:delete_topic] } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end end describe '#diff' do - it 'can generate diffs for theme changes' do - theme = Fabricate(:theme) - theme.set_field(target: :mobile, name: :scss, value: 'body {.up}') - theme.set_field(target: :common, name: :scss, value: 'omit-dupe') + shared_examples "theme diffs accessible" do + it 'generates diffs for theme changes' do + theme = Fabricate(:theme) + theme.set_field(target: :mobile, name: :scss, value: 'body {.up}') + theme.set_field(target: :common, name: :scss, value: 'omit-dupe') - original_json = ThemeSerializer.new(theme, root: false).to_json + original_json = ThemeSerializer.new(theme, root: false).to_json - theme.set_field(target: :mobile, name: :scss, value: 'body {.down}') + theme.set_field(target: :mobile, name: :scss, value: 'body {.down}') - record = StaffActionLogger.new(Discourse.system_user) - .log_theme_change(original_json, theme) + record = StaffActionLogger.new(Discourse.system_user) + .log_theme_change(original_json, theme) - get "/admin/logs/staff_action_logs/#{record.id}/diff.json" - expect(response.status).to eq(200) + get "/admin/logs/staff_action_logs/#{record.id}/diff.json" + expect(response.status).to eq(200) - parsed = response.parsed_body - expect(parsed["side_by_side"]).to include("up") - expect(parsed["side_by_side"]).to include("down") + parsed = response.parsed_body + expect(parsed["side_by_side"]).to include("up") + expect(parsed["side_by_side"]).to include("down") - expect(parsed["side_by_side"]).not_to include("omit-dupe") + expect(parsed["side_by_side"]).not_to include("omit-dupe") + end end - it 'is not erroring when current value is empty' do - theme = Fabricate(:theme) - StaffActionLogger.new(admin).log_theme_destroy(theme) - get "/admin/logs/staff_action_logs/#{UserHistory.last.id}/diff.json" - expect(response.status).to eq(200) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "theme diffs accessible" + + it 'is not erroring when current value is empty' do + theme = Fabricate(:theme) + StaffActionLogger.new(admin).log_theme_destroy(theme) + get "/admin/logs/staff_action_logs/#{UserHistory.last.id}/diff.json" + expect(response.status).to eq(200) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme diffs accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + theme = Fabricate(:theme) + StaffActionLogger.new(admin).log_theme_destroy(theme) + + get "/admin/logs/staff_action_logs/#{UserHistory.last.id}/diff.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end end diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index f61a50ecd9..9d2585cfa5 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -2,14 +2,8 @@ RSpec.describe Admin::ThemesController do fab!(:admin) { Fabricate(:admin) } - - it "is a subclass of AdminController" do - expect(Admin::ThemesController < Admin::AdminController).to eq(true) - end - - before do - sign_in(admin) - end + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } let! :repo do setup_git_repo( @@ -28,13 +22,38 @@ RSpec.describe Admin::ThemesController do end describe '#generate_key_pair' do - it 'can generate key pairs' do - post "/admin/themes/generate_key_pair.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["private_key"]).to eq(nil) - expect(json["public_key"]).to include("ssh-rsa ") - expect(Discourse.redis.get("ssh_key_#{json["public_key"]}")).not_to eq(nil) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'can generate key pairs' do + post "/admin/themes/generate_key_pair.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["private_key"]).to eq(nil) + expect(json["public_key"]).to include("ssh-rsa ") + expect(Discourse.redis.get("ssh_key_#{json["public_key"]}")).not_to eq(nil) + end + end + + shared_examples "key pair generation not allowed" do + it "prevents key pair generation with a 404 response" do + post "/admin/themes/generate_key_pair.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "key pair generation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "key pair generation not allowed" end end @@ -45,64 +64,116 @@ RSpec.describe Admin::ThemesController do Rack::Test::UploadedFile.new(file) end - it 'can create a theme upload' do - post "/admin/themes/upload_asset.json", params: { file: upload } - expect(response.status).to eq(201) + context "when logged in as an admin" do + before { sign_in(admin) } - upload = Upload.find_by(original_filename: filename) - - expect(upload.id).not_to be_nil - expect(response.parsed_body["upload_id"]).to eq(upload.id) - end - - context "when trying to upload an existing file" do - let(:uploaded_file) { Upload.find_by(original_filename: filename) } - let(:response_json) { response.parsed_body } - - before do + it 'can create a theme upload' do post "/admin/themes/upload_asset.json", params: { file: upload } expect(response.status).to eq(201) + + upload = Upload.find_by(original_filename: filename) + + expect(upload.id).not_to be_nil + expect(response.parsed_body["upload_id"]).to eq(upload.id) end - it "reuses the original upload" do - expect(response.status).to eq(201) - expect(response_json["upload_id"]).to eq(uploaded_file.id) + context "when trying to upload an existing file" do + let(:uploaded_file) { Upload.find_by(original_filename: filename) } + let(:response_json) { response.parsed_body } + + before do + post "/admin/themes/upload_asset.json", params: { file: upload } + expect(response.status).to eq(201) + end + + it "reuses the original upload" do + expect(response.status).to eq(201) + expect(response_json["upload_id"]).to eq(uploaded_file.id) + end end end + + shared_examples "theme asset upload not allowed" do + it "prevents theme asset upload with a 404 response" do + expect do + post "/admin/themes/upload_asset.json", params: { file: upload } + end.not_to change { Upload.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme asset upload not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme asset upload not allowed" + end end describe '#export' do - it "exports correctly" do - theme = Fabricate(:theme, name: "Awesome Theme") - theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') - theme.set_field(target: :desktop, name: :after_header, value: 'test') - theme.set_field(target: :extra_js, name: "discourse/controller/blah", value: 'console.log("test");') - theme.save! + context "when logged in as an admin" do + before { sign_in(admin) } - get "/admin/customize/themes/#{theme.id}/export" - expect(response.status).to eq(200) + it "exports correctly" do + theme = Fabricate(:theme, name: "Awesome Theme") + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.set_field(target: :desktop, name: :after_header, value: 'test') + theme.set_field(target: :extra_js, name: "discourse/controller/blah", value: 'console.log("test");') + theme.save! - # Save the output in a temp file (automatically cleaned up) - file = Tempfile.new('archive.zip') - file.write(response.body) - file.rewind - uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/zip") + get "/admin/customize/themes/#{theme.id}/export" + expect(response.status).to eq(200) - # Now import it again - expect do - post "/admin/themes/import.json", params: { theme: uploaded_file } - expect(response.status).to eq(201) - end.to change { Theme.count }.by (1) + # Save the output in a temp file (automatically cleaned up) + file = Tempfile.new('archive.zip') + file.write(response.body) + file.rewind + uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/zip") - json = response.parsed_body + # Now import it again + expect do + post "/admin/themes/import.json", params: { theme: uploaded_file } + expect(response.status).to eq(201) + end.to change { Theme.count }.by (1) - expect(json["theme"]["name"]).to eq("Awesome Theme") - expect(json["theme"]["theme_fields"].length).to eq(3) + json = response.parsed_body + + expect(json["theme"]["name"]).to eq("Awesome Theme") + expect(json["theme"]["theme_fields"].length).to eq(3) + end + end + + shared_examples "theme export not allowed" do + it "prevents theme export with a 404 response" do + theme = Fabricate(:theme, name: "Awesome Theme") + + get "/admin/customize/themes/#{theme.id}/export" + + expect(response.status).to eq(404) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme export not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme export not allowed" end end describe '#import' do - let(:theme_json_file) do Rack::Test::UploadedFile.new(file_from_fixtures("sam-s-simple-theme.dcstyle.json", "json"), "application/json") end @@ -115,591 +186,757 @@ RSpec.describe Admin::ThemesController do file_from_fixtures("logo.png") end - context 'when theme allowlist mode is enabled' do - before do - global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header.git" + context "when logged in as an admin" do + before { sign_in(admin) } + + context 'when theme allowlist mode is enabled' do + before do + global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header.git" + end + + it "allows allowlisted imports" do + expect(Theme.allowed_remote_theme_ids.length).to eq(0) + + post "/admin/themes/import.json", params: { + remote: ' https://github.com/discourse/discourse-brand-header.git ' + } + + expect(Theme.allowed_remote_theme_ids.length).to eq(1) + expect(response.status).to eq(201) + end + + it "prevents adding disallowed themes" do + RemoteTheme.stubs(:import_theme) + remote = ' https://bad.com/discourse/discourse-brand-header.git ' + + post "/admin/themes/import.json", params: { remote: remote } + + expect(response.status).to eq(403) + expect(response.parsed_body['errors']).to include(I18n.t("themes.import_error.not_allowed_theme", { repo: remote.strip })) + end + + it "bans json file import" do + post "/admin/themes/import.json", params: { theme: theme_json_file } + expect(response.status).to eq(403) + end end - it "allows allowlisted imports" do - expect(Theme.allowed_remote_theme_ids.length).to eq(0) - + it 'can import a theme from Git' do + RemoteTheme.stubs(:import_theme) post "/admin/themes/import.json", params: { remote: ' https://github.com/discourse/discourse-brand-header.git ' } expect(response.status).to eq(201) - expect(Theme.allowed_remote_theme_ids.length).to eq(1) end - it "prevents adding disallowed themes" do - RemoteTheme.stubs(:import_theme) - remote = ' https://bad.com/discourse/discourse-brand-header.git ' + it 'fails to import with a failing status' do + post "/admin/themes/import.json", params: { + remote: 'non-existant' + } - post "/admin/themes/import.json", params: { remote: remote } - - expect(response.status).to eq(403) - expect(response.parsed_body['errors']).to include(I18n.t("themes.import_error.not_allowed_theme", { repo: remote.strip })) + expect(response.status).to eq(422) end - it "bans json file import" do + it 'can lookup a private key by public key' do + Discourse.redis.setex('ssh_key_abcdef', 1.hour, 'rsa private key') + + ThemeStore::GitImporter.any_instance.stubs(:import!) + RemoteTheme.stubs(:extract_theme_info).returns( + 'name' => 'discourse-brand-header', + 'component' => true + ) + RemoteTheme.any_instance.stubs(:update_from_remote) + + post '/admin/themes/import.json', params: { + remote: ' https://github.com/discourse/discourse-brand-header.git ', + public_key: 'abcdef', + } + + expect(RemoteTheme.last.private_key).to eq('rsa private key') + + expect(response.status).to eq(201) + end + + it 'imports a theme' do post "/admin/themes/import.json", params: { theme: theme_json_file } - expect(response.status).to eq(403) + expect(response.status).to eq(201) + + json = response.parsed_body + + expect(json["theme"]["name"]).to eq("Sam's Simple Theme") + expect(json["theme"]["theme_fields"].length).to eq(2) + expect(json["theme"]["auto_update"]).to eq(false) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end - end - it 'can import a theme from Git' do - RemoteTheme.stubs(:import_theme) - post "/admin/themes/import.json", params: { - remote: ' https://github.com/discourse/discourse-brand-header.git ' - } + it 'can fail if theme is not accessible' do + post "/admin/themes/import.json", params: { + remote: 'git@github.com:discourse/discourse-inexistent-theme.git' + } - expect(response.status).to eq(201) - end + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to contain_exactly(I18n.t("themes.import_error.git")) + end - it 'can lookup a private key by public key' do - Discourse.redis.setex('ssh_key_abcdef', 1.hour, 'rsa private key') + it 'can force install theme' do + post "/admin/themes/import.json", params: { + remote: 'git@github.com:discourse/discourse-inexistent-theme.git', + force: true + } - ThemeStore::GitImporter.any_instance.stubs(:import!) - RemoteTheme.stubs(:extract_theme_info).returns( - 'name' => 'discourse-brand-header', - 'component' => true - ) - RemoteTheme.any_instance.stubs(:update_from_remote) + expect(response.status).to eq(201) + expect(response.parsed_body["theme"]["name"]).to eq("discourse-inexistent-theme") + end - post '/admin/themes/import.json', params: { - remote: ' https://github.com/discourse/discourse-brand-header.git ', - public_key: 'abcdef', - } + it 'fails to import with an error if uploads are not allowed' do + SiteSetting.theme_authorized_extensions = "nothing" - expect(RemoteTheme.last.private_key).to eq('rsa private key') - - expect(response.status).to eq(201) - end - - it 'should not be able to import a theme by moderator' do - sign_in(Fabricate(:moderator)) - - post "/admin/themes/import.json", params: { theme: theme_json_file } - expect(response.status).to eq(404) - end - - it 'imports a theme' do - post "/admin/themes/import.json", params: { theme: theme_json_file } - expect(response.status).to eq(201) - - json = response.parsed_body - - expect(json["theme"]["name"]).to eq("Sam's Simple Theme") - expect(json["theme"]["theme_fields"].length).to eq(2) - expect(json["theme"]["auto_update"]).to eq(false) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) - end - - it 'can fail if theme is not accessible' do - post "/admin/themes/import.json", params: { - remote: 'git@github.com:discourse/discourse-inexistent-theme.git' - } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to contain_exactly(I18n.t("themes.import_error.git")) - end - - it 'can force install theme' do - post "/admin/themes/import.json", params: { - remote: 'git@github.com:discourse/discourse-inexistent-theme.git', - force: true - } - - expect(response.status).to eq(201) - expect(response.parsed_body["theme"]["name"]).to eq("discourse-inexistent-theme") - end - - it 'fails to import with an error if uploads are not allowed' do - SiteSetting.theme_authorized_extensions = "nothing" - - expect do - post "/admin/themes/import.json", params: { theme: theme_archive } - end.to change { Theme.count }.by (0) - - expect(response.status).to eq(422) - end - - it 'imports a theme from an archive' do - _existing_theme = Fabricate(:theme, name: "Header Icons") - - expect do - post "/admin/themes/import.json", params: { theme: theme_archive } - end.to change { Theme.count }.by (1) - expect(response.status).to eq(201) - json = response.parsed_body - - expect(json["theme"]["name"]).to eq("Header Icons") - expect(json["theme"]["theme_fields"].length).to eq(5) - expect(json["theme"]["auto_update"]).to eq(false) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) - end - - it 'updates an existing theme from an archive by name' do - # Old theme CLI method, remove Jan 2020 - _existing_theme = Fabricate(:theme, name: "Header Icons") - - expect do - post "/admin/themes/import.json", params: { bundle: theme_archive } - end.to change { Theme.count }.by (0) - expect(response.status).to eq(201) - json = response.parsed_body - - expect(json["theme"]["name"]).to eq("Header Icons") - expect(json["theme"]["theme_fields"].length).to eq(5) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) - end - - it 'updates an existing theme from an archive by id' do - # Used by theme CLI - _existing_theme = Fabricate(:theme, name: "Header Icons") - other_existing_theme = Fabricate(:theme, name: "Some other name") - - messages = MessageBus.track_publish do expect do - post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: other_existing_theme.id } + post "/admin/themes/import.json", params: { theme: theme_archive } end.to change { Theme.count }.by (0) + + expect(response.status).to eq(422) end - expect(response.status).to eq(201) - json = response.parsed_body - # Ensure only one refresh message is sent. - # More than 1 is wasteful, and can trigger unusual race conditions in the client - # If this test fails, it probably means `theme.save` is being called twice - check any 'autosave' relations - file_change_messages = messages.filter { |m| m[:channel] == "/file-change" } - expect(file_change_messages.count).to eq(1) + it 'imports a theme from an archive' do + _existing_theme = Fabricate(:theme, name: "Header Icons") - expect(json["theme"]["name"]).to eq("Some other name") - expect(json["theme"]["id"]).to eq(other_existing_theme.id) - expect(json["theme"]["theme_fields"].length).to eq(5) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + expect do + post "/admin/themes/import.json", params: { theme: theme_archive } + end.to change { Theme.count }.by (1) + expect(response.status).to eq(201) + json = response.parsed_body + + expect(json["theme"]["name"]).to eq("Header Icons") + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(json["theme"]["auto_update"]).to eq(false) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + + it 'updates an existing theme from an archive by name' do + # Old theme CLI method, remove Jan 2020 + _existing_theme = Fabricate(:theme, name: "Header Icons") + + expect do + post "/admin/themes/import.json", params: { bundle: theme_archive } + end.to change { Theme.count }.by (0) + expect(response.status).to eq(201) + json = response.parsed_body + + expect(json["theme"]["name"]).to eq("Header Icons") + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + + it 'updates an existing theme from an archive by id' do + # Used by theme CLI + _existing_theme = Fabricate(:theme, name: "Header Icons") + other_existing_theme = Fabricate(:theme, name: "Some other name") + + messages = MessageBus.track_publish do + expect do + post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: other_existing_theme.id } + end.to change { Theme.count }.by (0) + end + expect(response.status).to eq(201) + json = response.parsed_body + + # Ensure only one refresh message is sent. + # More than 1 is wasteful, and can trigger unusual race conditions in the client + # If this test fails, it probably means `theme.save` is being called twice - check any 'autosave' relations + file_change_messages = messages.filter { |m| m[:channel] == "/file-change" } + expect(file_change_messages.count).to eq(1) + + expect(json["theme"]["name"]).to eq("Some other name") + expect(json["theme"]["id"]).to eq(other_existing_theme.id) + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + + it 'creates a new theme when id specified as nil' do + # Used by theme CLI + existing_theme = Fabricate(:theme, name: "Header Icons") + + expect do + post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: nil } + end.to change { Theme.count }.by (1) + expect(response.status).to eq(201) + json = response.parsed_body + + expect(json["theme"]["name"]).to eq("Header Icons") + expect(json["theme"]["id"]).not_to eq(existing_theme.id) + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(json["theme"]["auto_update"]).to eq(false) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end end - it 'creates a new theme when id specified as nil' do - # Used by theme CLI - existing_theme = Fabricate(:theme, name: "Header Icons") + shared_examples "theme import not allowed" do + it "prevents theme import with a 404 response" do + post "/admin/themes/import.json", params: { theme: theme_json_file } - expect do - post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: nil } - end.to change { Theme.count }.by (1) - expect(response.status).to eq(201) - json = response.parsed_body + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end - expect(json["theme"]["name"]).to eq("Header Icons") - expect(json["theme"]["id"]).not_to eq(existing_theme.id) - expect(json["theme"]["theme_fields"].length).to eq(5) - expect(json["theme"]["auto_update"]).to eq(false) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme import not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme import not allowed" end end describe '#index' do - it 'correctly returns themes' do - ColorScheme.destroy_all - Theme.destroy_all + context "when logged in as an admin" do + before { sign_in(admin) } - theme = Fabricate(:theme) - theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') - theme.set_field(target: :desktop, name: :after_header, value: 'test') + it 'correctly returns themes' do + ColorScheme.destroy_all + Theme.destroy_all - theme.remote_theme = RemoteTheme.new( - remote_url: 'awesome.git', - remote_version: '7', - local_version: '8', - remote_updated_at: Time.zone.now - ) + theme = Fabricate(:theme) + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.set_field(target: :desktop, name: :after_header, value: 'test') - theme.save! + theme.remote_theme = RemoteTheme.new( + remote_url: 'awesome.git', + remote_version: '7', + local_version: '8', + remote_updated_at: Time.zone.now + ) - # this will get serialized as well - ColorScheme.create_from_base(name: "test", colors: []) + theme.save! - get "/admin/themes.json" + # this will get serialized as well + ColorScheme.create_from_base(name: "test", colors: []) - expect(response.status).to eq(200) + get "/admin/themes.json" - json = response.parsed_body + expect(response.status).to eq(200) - expect(json["extras"]["color_schemes"].length).to eq(1) - theme_json = json["themes"].find { |t| t["id"] == theme.id } - expect(theme_json["theme_fields"].length).to eq(2) - expect(theme_json["remote_theme"]["remote_version"]).to eq("7") + json = response.parsed_body + + expect(json["extras"]["color_schemes"].length).to eq(1) + theme_json = json["themes"].find { |t| t["id"] == theme.id } + expect(theme_json["theme_fields"].length).to eq(2) + expect(theme_json["remote_theme"]["remote_version"]).to eq("7") + end + end + + shared_examples "themes inaccessible" do + it "denies access with a 404 response" do + get "/admin/themes.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "themes inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "themes inaccessible" end end describe '#create' do - it 'creates a theme' do - post "/admin/themes.json", params: { - theme: { - name: 'my test name', - theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}'] + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'creates a theme' do + post "/admin/themes.json", params: { + theme: { + name: 'my test name', + theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}'] + } } - } - expect(response.status).to eq(201) + expect(response.status).to eq(201) - json = response.parsed_body + json = response.parsed_body - expect(json["theme"]["theme_fields"].length).to eq(1) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + expect(json["theme"]["theme_fields"].length).to eq(1) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + + shared_examples "theme creation not allowed" do + it "prevents creation with a 404 response" do + expect do + post "/admin/themes.json", params: { + theme: { + name: 'my test name', + theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}'] + } + } + end.not_to change { Theme.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme creation not allowed" end end describe '#update' do let!(:theme) { Fabricate(:theme) } - it 'returns the right response when an invalid id is given' do - put "/admin/themes/99999.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(400) - end + it 'returns the right response when an invalid id is given' do + put "/admin/themes/99999.json" - it 'can change default theme' do - SiteSetting.default_theme_id = -1 - - put "/admin/themes/#{theme.id}.json", params: { - id: theme.id, theme: { default: true } - } - - expect(response.status).to eq(200) - expect(SiteSetting.default_theme_id).to eq(theme.id) - end - - it 'can unset default theme' do - SiteSetting.default_theme_id = theme.id - - put "/admin/themes/#{theme.id}.json", params: { - theme: { default: false } - } - - expect(response.status).to eq(200) - expect(SiteSetting.default_theme_id).to eq(-1) - end - - context 'when theme allowlist mode is enabled' do - before do - global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git" + expect(response.status).to eq(400) end - it 'unconditionally bans theme_fields from updating' do + it 'can change default theme' do + SiteSetting.default_theme_id = -1 + + put "/admin/themes/#{theme.id}.json", params: { + id: theme.id, theme: { default: true } + } + + expect(response.status).to eq(200) + expect(SiteSetting.default_theme_id).to eq(theme.id) + end + + it 'can unset default theme' do + SiteSetting.default_theme_id = theme.id + + put "/admin/themes/#{theme.id}.json", params: { + theme: { default: false } + } + + expect(response.status).to eq(200) + expect(SiteSetting.default_theme_id).to eq(-1) + end + + context 'when theme allowlist mode is enabled' do + before do + global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git" + end + + it 'unconditionally bans theme_fields from updating' do + r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") + theme.update!(remote_theme_id: r.id) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + name: 'my test name', + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'scss', target: 'desktop', value: 'body{color: blue;}' }, + ] + } + } + + expect(response.status).to eq(403) + end + end + + it 'updates a theme' do + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.save + + child_theme = Fabricate(:theme, component: true) + + upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(Discourse.system_user.id) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + child_theme_ids: [child_theme.id], + name: 'my test name', + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'scss', target: 'desktop', value: 'body{color: blue;}' }, + { name: 'bob', target: 'common', value: '', type_id: 2, upload_id: upload.id }, + ] + } + } + + expect(response.status).to eq(200) + + json = response.parsed_body + + fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } + + expect(fields[0]["value"]).to eq('') + expect(fields[0]["upload_id"]).to eq(upload.id) + expect(fields[1]["value"]).to eq('body{color: blue;}') + expect(fields.length).to eq(2) + expect(json["theme"]["child_themes"].length).to eq(1) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + + it 'prevents theme update when using ember css selectors' do + child_theme = Fabricate(:theme, component: true) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + child_theme_ids: [child_theme.id], + name: 'my test name', + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'scss', target: 'desktop', value: '.ember-view{color: blue;}' }, + ] + } + } + + expect(response.status).to eq(200) + + json = response.parsed_body + + fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } + expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + child_theme_ids: [child_theme.id], + name: 'my test name', + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'scss', target: 'desktop', value: '#ember392{color: blue;}' }, + ] + } + } + + expect(response.status).to eq(200) + json = response.parsed_body + + fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } + expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) + end + + it 'blocks remote theme fields from being locally edited' do r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") theme.update!(remote_theme_id: r.id) put "/admin/themes/#{theme.id}.json", params: { theme: { - name: 'my test name', theme_fields: [ { name: 'scss', target: 'common', value: '' }, - { name: 'scss', target: 'desktop', value: 'body{color: blue;}' }, + { name: 'header', target: 'common', value: 'filename.jpg', upload_id: 4 } ] } } expect(response.status).to eq(403) end - end - it 'updates a theme' do - theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') - theme.save + it 'allows zip-imported theme fields to be locally edited' do + r = RemoteTheme.create!(remote_url: "") + theme.update!(remote_theme_id: r.id) - child_theme = Fabricate(:theme, component: true) - - upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(Discourse.system_user.id) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - child_theme_ids: [child_theme.id], - name: 'my test name', - theme_fields: [ - { name: 'scss', target: 'common', value: '' }, - { name: 'scss', target: 'desktop', value: 'body{color: blue;}' }, - { name: 'bob', target: 'common', value: '', type_id: 2, upload_id: upload.id }, - ] - } - } - - expect(response.status).to eq(200) - - json = response.parsed_body - - fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } - - expect(fields[0]["value"]).to eq('') - expect(fields[0]["upload_id"]).to eq(upload.id) - expect(fields[1]["value"]).to eq('body{color: blue;}') - expect(fields.length).to eq(2) - expect(json["theme"]["child_themes"].length).to eq(1) - expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) - end - - it 'prevents theme update when using ember css selectors' do - child_theme = Fabricate(:theme, component: true) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - child_theme_ids: [child_theme.id], - name: 'my test name', - theme_fields: [ - { name: 'scss', target: 'common', value: '' }, - { name: 'scss', target: 'desktop', value: '.ember-view{color: blue;}' }, - ] - } - } - - expect(response.status).to eq(200) - - json = response.parsed_body - - fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } - expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - child_theme_ids: [child_theme.id], - name: 'my test name', - theme_fields: [ - { name: 'scss', target: 'common', value: '' }, - { name: 'scss', target: 'desktop', value: '#ember392{color: blue;}' }, - ] - } - } - - expect(response.status).to eq(200) - json = response.parsed_body - - fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } - expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) - end - - it 'blocks remote theme fields from being locally edited' do - r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") - theme.update!(remote_theme_id: r.id) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - theme_fields: [ - { name: 'scss', target: 'common', value: '' }, - { name: 'header', target: 'common', value: 'filename.jpg', upload_id: 4 } - ] - } - } - - expect(response.status).to eq(403) - end - - it 'allows zip-imported theme fields to be locally edited' do - r = RemoteTheme.create!(remote_url: "") - theme.update!(remote_theme_id: r.id) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - theme_fields: [ - { name: 'scss', target: 'common', value: '' }, - { name: 'header', target: 'common', value: 'filename.jpg', upload_id: 4 } - ] - } - } - - expect(response.status).to eq(200) - end - - it 'updates a child theme' do - child_theme = Fabricate(:theme, component: true) - put "/admin/themes/#{child_theme.id}.json", params: { - theme: { - parent_theme_ids: [theme.id], - } - } - expect(child_theme.parent_themes).to eq([theme]) - end - - it 'can update translations' do - theme.set_field(target: :translations, name: :en, value: { en: { somegroup: { somestring: "defaultstring" } } }.deep_stringify_keys.to_yaml) - theme.save! - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - translations: { - "somegroup.somestring" => "overriddenstring" + put "/admin/themes/#{theme.id}.json", params: { + theme: { + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'header', target: 'common', value: 'filename.jpg', upload_id: 4 } + ] } } - } - # Response correct - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["theme"]["translations"][0]["value"]).to eq("overriddenstring") + expect(response.status).to eq(200) + end - # Database correct - theme.reload - expect(theme.theme_translation_overrides.count).to eq(1) - expect(theme.theme_translation_overrides.first.translation_key).to eq("somegroup.somestring") - - # Set back to default - put "/admin/themes/#{theme.id}.json", params: { - theme: { - translations: { - "somegroup.somestring" => "defaultstring" + it 'updates a child theme' do + child_theme = Fabricate(:theme, component: true) + put "/admin/themes/#{child_theme.id}.json", params: { + theme: { + parent_theme_ids: [theme.id], } } - } - # Response correct - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["theme"]["translations"][0]["value"]).to eq("defaultstring") + expect(child_theme.parent_themes).to eq([theme]) + end - # Database correct - theme.reload - expect(theme.theme_translation_overrides.count).to eq(0) - end + it 'can update translations' do + theme.set_field(target: :translations, name: :en, value: { en: { somegroup: { somestring: "defaultstring" } } }.deep_stringify_keys.to_yaml) + theme.save! - it 'checking for updates saves the remote_theme record' do - theme.remote_theme = RemoteTheme.create!(remote_url: "http://discourse.org", remote_version: "a", local_version: "a", commits_behind: 0) - theme.save! - ThemeStore::GitImporter.any_instance.stubs(:import!) - ThemeStore::GitImporter.any_instance.stubs(:commits_since).returns(["b", 1]) - - put "/admin/themes/#{theme.id}.json", params: { - theme: { - remote_check: true + put "/admin/themes/#{theme.id}.json", params: { + theme: { + translations: { + "somegroup.somestring" => "overriddenstring" + } + } } - } - theme.reload - expect(theme.remote_theme.remote_version).to eq("b") - expect(theme.remote_theme.commits_behind).to eq(1) - end - it 'can disable component' do - child = Fabricate(:theme, component: true) + # Response correct + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["theme"]["translations"][0]["value"]).to eq("overriddenstring") - put "/admin/themes/#{child.id}.json", params: { - theme: { - enabled: false + # Database correct + theme.reload + expect(theme.theme_translation_overrides.count).to eq(1) + expect(theme.theme_translation_overrides.first.translation_key).to eq("somegroup.somestring") + + # Set back to default + put "/admin/themes/#{theme.id}.json", params: { + theme: { + translations: { + "somegroup.somestring" => "defaultstring" + } + } } - } - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["theme"]["enabled"]).to eq(false) - expect(UserHistory.where( - context: child.id.to_s, - action: UserHistory.actions[:disable_theme_component] - ).size).to eq(1) - expect(json["theme"]["disabled_by"]["id"]).to eq(admin.id) - end + # Response correct + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["theme"]["translations"][0]["value"]).to eq("defaultstring") - it "enabling/disabling a component creates the correct staff action log" do - child = Fabricate(:theme, component: true) - UserHistory.destroy_all + # Database correct + theme.reload + expect(theme.theme_translation_overrides.count).to eq(0) + end - put "/admin/themes/#{child.id}.json", params: { - theme: { - enabled: false + it 'checking for updates saves the remote_theme record' do + theme.remote_theme = RemoteTheme.create!(remote_url: "http://discourse.org", remote_version: "a", local_version: "a", commits_behind: 0) + theme.save! + ThemeStore::GitImporter.any_instance.stubs(:import!) + ThemeStore::GitImporter.any_instance.stubs(:commits_since).returns(["b", 1]) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + remote_check: true + } } - } - expect(response.status).to eq(200) + theme.reload + expect(theme.remote_theme.remote_version).to eq("b") + expect(theme.remote_theme.commits_behind).to eq(1) + end - expect(UserHistory.where( - context: child.id.to_s, - action: UserHistory.actions[:disable_theme_component] - ).size).to eq(1) - expect(UserHistory.where( - context: child.id.to_s, - action: UserHistory.actions[:enable_theme_component] - ).size).to eq(0) + it 'can disable component' do + child = Fabricate(:theme, component: true) - put "/admin/themes/#{child.id}.json", params: { - theme: { - enabled: true + put "/admin/themes/#{child.id}.json", params: { + theme: { + enabled: false + } } - } - expect(response.status).to eq(200) - json = response.parsed_body + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["theme"]["enabled"]).to eq(false) + expect(UserHistory.where( + context: child.id.to_s, + action: UserHistory.actions[:disable_theme_component] + ).size).to eq(1) + expect(json["theme"]["disabled_by"]["id"]).to eq(admin.id) + end - expect(UserHistory.where( - context: child.id.to_s, - action: UserHistory.actions[:disable_theme_component] - ).size).to eq(1) - expect(UserHistory.where( - context: child.id.to_s, - action: UserHistory.actions[:enable_theme_component] - ).size).to eq(1) + it "enabling/disabling a component creates the correct staff action log" do + child = Fabricate(:theme, component: true) + UserHistory.destroy_all - expect(json["theme"]["disabled_by"]).to eq(nil) - expect(json["theme"]["enabled"]).to eq(true) + put "/admin/themes/#{child.id}.json", params: { + theme: { + enabled: false + } + } + expect(response.status).to eq(200) + + expect(UserHistory.where( + context: child.id.to_s, + action: UserHistory.actions[:disable_theme_component] + ).size).to eq(1) + expect(UserHistory.where( + context: child.id.to_s, + action: UserHistory.actions[:enable_theme_component] + ).size).to eq(0) + + put "/admin/themes/#{child.id}.json", params: { + theme: { + enabled: true + } + } + expect(response.status).to eq(200) + json = response.parsed_body + + expect(UserHistory.where( + context: child.id.to_s, + action: UserHistory.actions[:disable_theme_component] + ).size).to eq(1) + expect(UserHistory.where( + context: child.id.to_s, + action: UserHistory.actions[:enable_theme_component] + ).size).to eq(1) + + expect(json["theme"]["disabled_by"]).to eq(nil) + expect(json["theme"]["enabled"]).to eq(true) + end + + it 'handles import errors on update' do + theme.create_remote_theme!(remote_url: "https://example.com/repository") + theme.save! + + # RemoteTheme is extensively tested, and setting up the test scaffold is a large overhead + # So use a stub here to test the controller + RemoteTheme.any_instance.stubs(:update_from_remote).raises(RemoteTheme::ImportError.new("error message")) + put "/admin/themes/#{theme.id}.json", params: { + theme: { remote_update: true } + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"].first).to eq("error message") + end + + it 'returns the right error message' do + theme.update!(component: true) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { default: true } + } + + expect(response.status).to eq(400) + expect(response.parsed_body["errors"].first).to include(I18n.t("themes.errors.component_no_default")) + end + + it 'prevents converting the default theme to a component' do + SiteSetting.default_theme_id = theme.id + + put "/admin/themes/#{theme.id}.json", params: { + theme: { component: true } + } + + # should this error message be localized? InvalidParameters :component + expect(response.status).to eq(400) + expect(response.parsed_body["errors"].first).to include('component') + end end - it 'handles import errors on update' do - theme.create_remote_theme!(remote_url: "https://example.com/repository") - theme.save! + shared_examples "theme update not allowed" do + it "prevents updates with a 404 response" do + SiteSetting.default_theme_id = -1 - # RemoteTheme is extensively tested, and setting up the test scaffold is a large overhead - # So use a stub here to test the controller - RemoteTheme.any_instance.stubs(:update_from_remote).raises(RemoteTheme::ImportError.new("error message")) - put "/admin/themes/#{theme.id}.json", params: { - theme: { remote_update: true } - } - expect(response.status).to eq(422) - expect(response.parsed_body["errors"].first).to eq("error message") + put "/admin/themes/#{theme.id}.json", params: { + id: theme.id, theme: { default: true } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(SiteSetting.default_theme_id).not_to eq(theme.id) + end end - it 'returns the right error message' do - theme.update!(component: true) + context "when logged in as a moderator" do + before { sign_in(moderator) } - put "/admin/themes/#{theme.id}.json", params: { - theme: { default: true } - } - - expect(response.status).to eq(400) - expect(response.parsed_body["errors"].first).to include(I18n.t("themes.errors.component_no_default")) + include_examples "theme update not allowed" end - it 'prevents converting the default theme to a component' do - SiteSetting.default_theme_id = theme.id + context "when logged in as a non-staff user" do + before { sign_in(user) } - put "/admin/themes/#{theme.id}.json", params: { - theme: { component: true } - } - - # should this error message be localized? InvalidParameters :component - expect(response.status).to eq(400) - expect(response.parsed_body["errors"].first).to include('component') + include_examples "theme update not allowed" end end describe '#destroy' do let!(:theme) { Fabricate(:theme) } - it 'returns the right response when an invalid id is given' do - delete "/admin/themes/9999.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(400) + it 'returns the right response when an invalid id is given' do + delete "/admin/themes/9999.json" + + expect(response.status).to eq(400) + end + + it "deletes the field's javascript cache" do + theme.set_field(target: :common, name: :header, value: '') + theme.save! + + javascript_cache = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: :header).javascript_cache + expect(javascript_cache).to_not eq(nil) + + delete "/admin/themes/#{theme.id}.json" + + expect(response.status).to eq(204) + expect { theme.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { javascript_cache.reload }.to raise_error(ActiveRecord::RecordNotFound) + end end - it "deletes the field's javascript cache" do - theme.set_field(target: :common, name: :header, value: '') - theme.save! + shared_examples "theme deletion not allowed" do + it "prevent deletion with a 404 response" do + delete "/admin/themes/#{theme.id}.json" - javascript_cache = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: :header).javascript_cache - expect(javascript_cache).to_not eq(nil) + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(theme.reload).to be_present + end + end - delete "/admin/themes/#{theme.id}.json" + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(response.status).to eq(204) - expect { theme.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { javascript_cache.reload }.to raise_error(ActiveRecord::RecordNotFound) + include_examples "theme deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme deletion not allowed" end end describe '#preview' do - it "should return the right response when an invalid id is given" do - get "/admin/themes/9999/preview.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(400) + it "should return the right response when an invalid id is given" do + get "/admin/themes/9999/preview.json" + + expect(response.status).to eq(400) + end + end + + shared_examples "theme previews inaccessible" do + it "denies access with a 404 response" do + theme = Fabricate(:theme) + + get "/admin/themes/#{theme.id}/preview.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme previews inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme previews inaccessible" end end @@ -711,30 +948,61 @@ RSpec.describe Admin::ThemesController do theme.save! end - it "should update a theme setting" do - put "/admin/themes/#{theme.id}/setting.json", params: { - name: "bg", - value: "green" - } + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - expect(response.parsed_body["bg"]).to eq("green") + it "should update a theme setting" do + put "/admin/themes/#{theme.id}/setting.json", params: { + name: "bg", + value: "green" + } - theme.reload - expect(theme.cached_settings[:bg]).to eq("green") - user_history = UserHistory.last + expect(response.status).to eq(200) + expect(response.parsed_body["bg"]).to eq("green") - expect(user_history.action).to eq( - UserHistory.actions[:change_theme_setting] - ) + theme.reload + expect(theme.cached_settings[:bg]).to eq("green") + user_history = UserHistory.last + + expect(user_history.action).to eq( + UserHistory.actions[:change_theme_setting] + ) + end + + it "should clear a theme setting" do + put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg" } + theme.reload + + expect(response.status).to eq(200) + expect(theme.cached_settings[:bg]).to eq("") + end end - it "should clear a theme setting" do - put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg" } - theme.reload + shared_examples "theme update not allowed" do + it "prevents updates with a 404 response" do + put "/admin/themes/#{theme.id}/setting.json", params: { + name: "bg", + value: "green" + } - expect(response.status).to eq(200) - expect(theme.cached_settings[:bg]).to eq("") + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + theme.reload + expect(theme.cached_settings[:bg]).to eq("red") + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "theme update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "theme update not allowed" end end end diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb index 8949a46c39..450c689cee 100644 --- a/spec/requests/admin/user_fields_controller_spec.rb +++ b/spec/requests/admin/user_fields_controller_spec.rb @@ -1,18 +1,14 @@ # frozen_string_literal: true RSpec.describe Admin::UserFieldsController do - it "is a subclass of AdminController" do - expect(Admin::UserFieldsController < Admin::AdminController).to eq(true) - end + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - context "when logged in" do - fab!(:admin) { Fabricate(:admin) } + describe '#create' do + context "when logged in as an admin" do + before { sign_in(admin) } - before do - sign_in(admin) - end - - describe '#create' do it "creates a user field" do expect { post "/admin/customize/user_fields.json", params: { @@ -41,8 +37,37 @@ RSpec.describe Admin::UserFieldsController do end end - describe '#index' do - fab!(:user_field) { Fabricate(:user_field) } + shared_examples "user field creation not allowed" do + it "prevents creation with a 404 response" do + expect do + post "/admin/customize/user_fields.json", params: { + user_field: { name: 'hello', description: 'hello desc', field_type: 'text' } + } + end.not_to change { UserField.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user field creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user field creation not allowed" + end + end + + describe '#index' do + fab!(:user_field) { Fabricate(:user_field) } + + context "when logged in as an admin" do + before { sign_in(admin) } it "returns a list of user fields" do get "/admin/customize/user_fields.json" @@ -52,8 +77,34 @@ RSpec.describe Admin::UserFieldsController do end end - describe '#destroy' do - fab!(:user_field) { Fabricate(:user_field) } + shared_examples "user fields inaccessible" do + it "denies access with a 404 response" do + get "/admin/customize/user_fields.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body['user_fields']).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user fields inaccessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user fields inaccessible" + end + end + + describe '#destroy' do + fab!(:user_field) { Fabricate(:user_field) } + + context "when logged in as an admin" do + before { sign_in(admin) } it "deletes the user field" do expect { @@ -63,8 +114,35 @@ RSpec.describe Admin::UserFieldsController do end end - describe '#update' do - fab!(:user_field) { Fabricate(:user_field) } + shared_examples "user field deletion not allowed" do + it "prevents deletion with a 404 response" do + expect do + delete "/admin/customize/user_fields/#{user_field.id}.json" + end.not_to change { UserField.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user field deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user field deletion not allowed" + end + end + + describe '#update' do + fab!(:user_field) { Fabricate(:user_field) } + + context "when logged in as an admin" do + before { sign_in(admin) } it "updates the user field" do put "/admin/customize/user_fields/#{user_field.id}.json", params: { @@ -138,5 +216,34 @@ RSpec.describe Admin::UserFieldsController do }.to change { DirectoryColumn.count }.by(-1) end end + + shared_examples "user field update not allowed" do + it "prevents updates with a 404 response" do + user_field.reload + original_name = user_field.name + + put "/admin/customize/user_fields/#{user_field.id}.json", params: { + user_field: { name: 'fraggle', field_type: 'confirm', description: 'muppet' } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + user_field.reload + expect(user_field.name).to eq(original_name) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user field update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user field update not allowed" + end end end diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index 1fc3bb70b4..7acd62b980 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -5,136 +5,225 @@ require 'rotp' RSpec.describe Admin::UsersController do fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } fab!(:coding_horror) { Fabricate(:coding_horror) } - it 'is a subclass of StaffController' do - expect(Admin::UsersController < Admin::StaffController).to eq(true) - end - - before do - sign_in(admin) - end - describe '#index' do - it 'returns success with JSON' do - get "/admin/users/list.json" - expect(response.status).to eq(200) - expect(response.parsed_body).to be_present - end + context "when logged in as an admin" do + before { sign_in(admin) } - context 'when showing emails' do - it "returns email for all the users" do - get "/admin/users/list.json", params: { show_emails: "true" } + it 'returns success with JSON' do + get "/admin/users/list.json" expect(response.status).to eq(200) - data = response.parsed_body - data.each do |user| - expect(user["email"]).to be_present + expect(response.parsed_body).to be_present + end + + context 'when showing emails' do + it "returns email for all the users" do + get "/admin/users/list.json", params: { show_emails: "true" } + expect(response.status).to eq(200) + data = response.parsed_body + data.each do |user| + expect(user["email"]).to be_present + end + end + + it "logs only 1 entry" do + expect do + get "/admin/users/list.json", params: { show_emails: "true" } + end.to change { UserHistory.where(action: UserHistory.actions[:check_email], acting_user_id: admin.id).count }.by(1) + expect(response.status).to eq(200) + end + + it "can be ordered by emails" do + get "/admin/users/list.json", params: { show_emails: "true", order: "email" } + expect(response.status).to eq(200) end end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it 'returns users' do + get "/admin/users/list.json" - it "logs only 1 entry" do - expect do - get "/admin/users/list.json", params: { show_emails: "true" } - end.to change { UserHistory.where(action: UserHistory.actions[:check_email], acting_user_id: admin.id).count }.by(1) expect(response.status).to eq(200) + expect(response.parsed_body).to be_present end + end - it "can be ordered by emails" do - get "/admin/users/list.json", params: { show_emails: "true", order: "email" } - expect(response.status).to eq(200) + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/users/list.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end end describe '#show' do - context 'with an existing user' do - it 'returns success' do - get "/admin/users/#{user.id}.json" - expect(response.status).to eq(200) + context "when logged in as an admin" do + before { sign_in(admin) } + + context 'with an existing user' do + it 'returns success' do + get "/admin/users/#{user.id}.json" + expect(response.status).to eq(200) + end + + it 'includes associated accounts' do + user.user_associated_accounts.create!(provider_name: 'pluginauth', provider_uid: 'pluginauth_uid') + + get "/admin/users/#{user.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body['external_ids'].size).to eq(1) + expect(response.parsed_body['external_ids']['pluginauth']).to eq('pluginauth_uid') + end end - it 'includes associated accounts' do - user.user_associated_accounts.create!(provider_name: 'pluginauth', provider_uid: 'pluginauth_uid') - - get "/admin/users/#{user.id}.json" - expect(response.status).to eq(200) - expect(response.parsed_body['external_ids'].size).to eq(1) - expect(response.parsed_body['external_ids']['pluginauth']).to eq('pluginauth_uid') + context 'with a non-existing user' do + it 'returns 404 error' do + get "/admin/users/0.json" + expect(response.status).to eq(404) + end end end - context 'with a non-existing user' do - it 'returns 404 error' do - get "/admin/users/0.json" + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it 'returns user' do + get "/admin/users/#{user.id}.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(user.id) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/users/#{user.id}.json" + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end end describe '#approve' do let(:evil_trout) { Fabricate(:evil_trout) } + before do SiteSetting.must_approve_users = true end - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{evil_trout.id}/approve.json" - expect(response.status).to eq(404) - evil_trout.reload - expect(evil_trout.approved).to eq(false) + shared_examples "user approval possible" do + it "creates a reviewable if one does not exist" do + evil_trout.update!(active: true) + expect(ReviewableUser.find_by(target: evil_trout)).to be_blank + + put "/admin/users/#{evil_trout.id}/approve.json" + + expect(response.code).to eq("200") + expect(ReviewableUser.find_by(target: evil_trout)).to be_present + expect(evil_trout.reload).to be_approved + end + + it "calls approve" do + Jobs.run_immediately! + evil_trout.activate + + put "/admin/users/#{evil_trout.id}/approve.json" + + expect(response.status).to eq(200) + evil_trout.reload + expect(evil_trout.approved).to eq(true) + expect(UserHistory.where(action: UserHistory.actions[:approve_user], target_user_id: evil_trout.id).count).to eq(1) + end end - it "will create a reviewable if one does not exist" do - evil_trout.update!(active: true) - expect(ReviewableUser.find_by(target: evil_trout)).to be_blank - put "/admin/users/#{evil_trout.id}/approve.json" - expect(response.code).to eq("200") - expect(ReviewableUser.find_by(target: evil_trout)).to be_present - expect(evil_trout.reload).to be_approved + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "user approval possible" end - it 'calls approve' do - Jobs.run_immediately! - evil_trout.activate - put "/admin/users/#{evil_trout.id}/approve.json" - expect(response.status).to eq(200) - evil_trout.reload - expect(evil_trout.approved).to eq(true) - expect(UserHistory.where(action: UserHistory.actions[:approve_user], target_user_id: evil_trout.id).count).to eq(1) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user approval possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents user approvals with a 404 response" do + put "/admin/users/#{evil_trout.id}/approve.json" + + evil_trout.reload + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(evil_trout.approved).to eq(false) + end end end describe '#approve_bulk' do + let(:evil_trout) { Fabricate(:evil_trout) } + before do SiteSetting.must_approve_users = true end - let(:evil_trout) { Fabricate(:evil_trout) } + shared_examples "bulk user approval possible" do + it "does nothing without users" do + put "/admin/users/approve-bulk.json" + evil_trout.reload + expect(response.status).to eq(200) + expect(evil_trout.approved).to eq(false) + end - it "does nothing without users" do - put "/admin/users/approve-bulk.json" - evil_trout.reload - expect(response.status).to eq(200) - expect(evil_trout.approved).to eq(false) + it "approves the user when permitted" do + Jobs.run_immediately! + evil_trout.activate + put "/admin/users/approve-bulk.json", params: { users: [evil_trout.id] } + expect(response.status).to eq(200) + evil_trout.reload + expect(evil_trout.approved).to eq(true) + end end - it "won't approve the user when not allowed" do - sign_in(user) - put "/admin/users/approve-bulk.json", params: { users: [evil_trout.id] } - expect(response.status).to eq(404) - evil_trout.reload - expect(evil_trout.approved).to eq(false) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "bulk user approval possible" end - it "approves the user when permitted" do - Jobs.run_immediately! - evil_trout.activate - put "/admin/users/approve-bulk.json", params: { users: [evil_trout.id] } - expect(response.status).to eq(200) - evil_trout.reload - expect(evil_trout.approved).to eq(true) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "bulk user approval possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents bulk user approvals with a 404 response" do + put "/admin/users/approve-bulk.json", params: { users: [evil_trout.id] } + + evil_trout.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(evil_trout.approved).to eq(false) + end end end @@ -146,211 +235,263 @@ RSpec.describe Admin::UsersController do post_id: created_post.id } end - it "works properly" do - expect(user).not_to be_suspended + shared_examples "suspension of active user possible" do + it "suspends user" do + expect(user).not_to be_suspended - expect do + expect do + put "/admin/users/#{user.id}/suspend.json", params: { + suspend_until: 5.hours.from_now, + reason: "because I said so" + } + end.not_to change { Jobs::CriticalUserEmail.jobs.size } + + expect(response.status).to eq(200) + + user.reload + expect(user).to be_suspended + expect(user.suspended_at).to be_present + expect(user.suspended_till).to be_present + expect(user.suspend_record).to be_present + + log = UserHistory.where(target_user_id: user.id).order('id desc').first + expect(log.details).to match(/because I said so/) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + shared_examples "suspension of active user possible" + + it "checks if user is suspended" do put "/admin/users/#{user.id}/suspend.json", params: { suspend_until: 5.hours.from_now, reason: "because I said so" } - end.not_to change { Jobs::CriticalUserEmail.jobs.size } - expect(response.status).to eq(200) + put "/admin/users/#{user.id}/suspend.json", params: { + suspend_until: 5.hours.from_now, + reason: "because I said so too" + } - user.reload - expect(user).to be_suspended - expect(user.suspended_at).to be_present - expect(user.suspended_till).to be_present - expect(user.suspend_record).to be_present - - log = UserHistory.where(target_user_id: user.id).order('id desc').first - expect(log.details).to match(/because I said so/) - end - - it "checks if user is suspended" do - put "/admin/users/#{user.id}/suspend.json", params: { - suspend_until: 5.hours.from_now, - reason: "because I said so" - } - - put "/admin/users/#{user.id}/suspend.json", params: { - suspend_until: 5.hours.from_now, - reason: "because I said so too" - } - - expect(response.status).to eq(409) - expect(response.parsed_body["message"]).to eq( - I18n.t( - "user.already_suspended", - staff: admin.username, - time_ago: FreedomPatches::Rails4.time_ago_in_words(user.suspend_record.created_at, true, scope: :'datetime.distance_in_words_verbose') + expect(response.status).to eq(409) + expect(response.parsed_body["message"]).to eq( + I18n.t( + "user.already_suspended", + staff: admin.username, + time_ago: FreedomPatches::Rails4.time_ago_in_words(user.suspend_record.created_at, true, scope: :'datetime.distance_in_words_verbose') + ) ) - ) - end + end - it "requires suspend_until and reason" do - expect(user).not_to be_suspended - put "/admin/users/#{user.id}/suspend.json", params: {} - expect(response.status).to eq(400) - user.reload - expect(user).not_to be_suspended + it "requires suspend_until and reason" do + expect(user).not_to be_suspended + put "/admin/users/#{user.id}/suspend.json", params: {} + expect(response.status).to eq(400) + user.reload + expect(user).not_to be_suspended - expect(user).not_to be_suspended - put "/admin/users/#{user.id}/suspend.json", params: { - suspend_until: 5.hours.from_now - } - expect(response.status).to eq(400) - user.reload - expect(user).not_to be_suspended - end + expect(user).not_to be_suspended + put "/admin/users/#{user.id}/suspend.json", params: { + suspend_until: 5.hours.from_now + } + expect(response.status).to eq(400) + user.reload + expect(user).not_to be_suspended + end - context "with an associated post" do - it "can have an associated post" do - put "/admin/users/#{user.id}/suspend.json", params: suspend_params + context "with an associated post" do + it "can have an associated post" do + put "/admin/users/#{user.id}/suspend.json", params: suspend_params + + expect(response.status).to eq(200) + + log = UserHistory.where(target_user_id: user.id).order('id desc').first + expect(log.post_id).to eq(created_post.id) + end + + it "can delete an associated post" do + put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge(post_action: 'delete') + created_post.reload + expect(created_post.deleted_at).to be_present + expect(response.status).to eq(200) + end + + it "won't delete a category topic" do + c = Fabricate(:category_with_definition) + cat_post = c.topic.posts.first + put( + "/admin/users/#{user.id}/suspend.json", + params: suspend_params.merge( + post_action: 'delete', + post_id: cat_post.id + ) + ) + cat_post.reload + expect(cat_post.deleted_at).to be_blank + expect(response.status).to eq(200) + end + + it "won't delete a category topic by replies" do + c = Fabricate(:category_with_definition) + cat_post = c.topic.posts.first + put( + "/admin/users/#{user.id}/suspend.json", + params: suspend_params.merge( + post_action: 'delete_replies', + post_id: cat_post.id + ) + ) + cat_post.reload + expect(cat_post.deleted_at).to be_blank + expect(response.status).to eq(200) + end + + it "can delete an associated post and its replies" do + reply = PostCreator.create( + Fabricate(:user), + raw: 'this is the reply text', + reply_to_post_number: created_post.post_number, + topic_id: created_post.topic_id + ) + nested_reply = PostCreator.create( + Fabricate(:user), + raw: 'this is the reply text2', + reply_to_post_number: reply.post_number, + topic_id: created_post.topic_id + ) + put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge(post_action: 'delete_replies') + expect(created_post.reload.deleted_at).to be_present + expect(reply.reload.deleted_at).to be_present + expect(nested_reply.reload.deleted_at).to be_present + expect(response.status).to eq(200) + end + + it "can edit an associated post" do + put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge( + post_action: 'edit', + post_edit: 'this is the edited content' + ) + + expect(response.status).to eq(200) + created_post.reload + expect(created_post.deleted_at).to be_blank + expect(created_post.raw).to eq("this is the edited content") + expect(response.status).to eq(200) + end + end + + it "can send a message to the user" do + put "/admin/users/#{user.id}/suspend.json", params: { + suspend_until: 10.days.from_now, + reason: "short reason", + message: "long reason" + } expect(response.status).to eq(200) + expect(Jobs::CriticalUserEmail.jobs.size).to eq(1) + job_args = Jobs::CriticalUserEmail.jobs.first["args"].first + expect(job_args["type"]).to eq("account_suspended") + expect(job_args["user_id"]).to eq(user.id) + log = UserHistory.where(target_user_id: user.id).order('id desc').first - expect(log.post_id).to eq(created_post.id) + expect(log).to be_present + expect(log.details).to match(/short reason/) + expect(log.details).to match(/long reason/) end - it "can delete an associated post" do - put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge(post_action: 'delete') - created_post.reload - expect(created_post.deleted_at).to be_present + it "also prevents use of any api keys" do + api_key = Fabricate(:api_key, user: user) + post "/bookmarks.json", params: { + bookmarkable_id: Fabricate(:post).id, + bookmarkable_type: "Post" + }, headers: { HTTP_API_KEY: api_key.key } expect(response.status).to eq(200) - end - it "won't delete a category topic" do - c = Fabricate(:category_with_definition) - cat_post = c.topic.posts.first - put( - "/admin/users/#{user.id}/suspend.json", - params: suspend_params.merge( - post_action: 'delete', - post_id: cat_post.id - ) - ) - cat_post.reload - expect(cat_post.deleted_at).to be_blank + put "/admin/users/#{user.id}/suspend.json", params: suspend_params expect(response.status).to eq(200) - end - it "won't delete a category topic by replies" do - c = Fabricate(:category_with_definition) - cat_post = c.topic.posts.first - put( - "/admin/users/#{user.id}/suspend.json", - params: suspend_params.merge( - post_action: 'delete_replies', - post_id: cat_post.id - ) - ) - cat_post.reload - expect(cat_post.deleted_at).to be_blank - expect(response.status).to eq(200) - end + user.reload + expect(user).to be_suspended - it "can delete an associated post and its replies" do - reply = PostCreator.create( - Fabricate(:user), - raw: 'this is the reply text', - reply_to_post_number: created_post.post_number, - topic_id: created_post.topic_id - ) - nested_reply = PostCreator.create( - Fabricate(:user), - raw: 'this is the reply text2', - reply_to_post_number: reply.post_number, - topic_id: created_post.topic_id - ) - put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge(post_action: 'delete_replies') - expect(created_post.reload.deleted_at).to be_present - expect(reply.reload.deleted_at).to be_present - expect(nested_reply.reload.deleted_at).to be_present - expect(response.status).to eq(200) - end - - it "can edit an associated post" do - put "/admin/users/#{user.id}/suspend.json", params: suspend_params.merge( - post_action: 'edit', - post_edit: 'this is the edited content' - ) - - expect(response.status).to eq(200) - created_post.reload - expect(created_post.deleted_at).to be_blank - expect(created_post.raw).to eq("this is the edited content") - expect(response.status).to eq(200) + post "/bookmarks.json", params: { + post_id: Fabricate(:post).id + }, headers: { HTTP_API_KEY: api_key.key } + expect(response.status).to eq(403) end end - it "can send a message to the user" do - put "/admin/users/#{user.id}/suspend.json", params: { - suspend_until: 10.days.from_now, - reason: "short reason", - message: "long reason" - } + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(response.status).to eq(200) - - expect(Jobs::CriticalUserEmail.jobs.size).to eq(1) - job_args = Jobs::CriticalUserEmail.jobs.first["args"].first - expect(job_args["type"]).to eq("account_suspended") - expect(job_args["user_id"]).to eq(user.id) - - log = UserHistory.where(target_user_id: user.id).order('id desc').first - expect(log).to be_present - expect(log.details).to match(/short reason/) - expect(log.details).to match(/long reason/) + include_examples "suspension of active user possible" end - it "also prevents use of any api keys" do - api_key = Fabricate(:api_key, user: user) - post "/bookmarks.json", params: { - bookmarkable_id: Fabricate(:post).id, - bookmarkable_type: "Post" - }, headers: { HTTP_API_KEY: api_key.key } - expect(response.status).to eq(200) + context "when logged in as a non-staff user" do + before { sign_in(user) } - put "/admin/users/#{user.id}/suspend.json", params: suspend_params - expect(response.status).to eq(200) + it "prevents user suspensions with a 404 response" do + expect do + put "/admin/users/#{user.id}/suspend.json", params: { + suspend_until: 5.hours.from_now, + reason: "because I said so" + } + end.not_to change { Jobs::CriticalUserEmail.jobs.size } - user.reload - expect(user).to be_suspended + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) - post "/bookmarks.json", params: { - post_id: Fabricate(:post).id - }, headers: { HTTP_API_KEY: api_key.key } - expect(response.status).to eq(403) + user.reload + expect(user).not_to be_suspended + expect(user.suspended_at).to be_nil + expect(user.suspended_till).to be_nil + expect(user.suspend_record).to be_nil + end end end describe '#revoke_admin' do fab!(:another_admin) { Fabricate(:admin) } - it 'raises an error unless the user can revoke access' do - sign_in(user) - put "/admin/users/#{another_admin.id}/revoke_admin.json" - expect(response.status).to eq(404) - another_admin.reload - expect(another_admin.admin).to eq(true) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'updates the admin flag' do + put "/admin/users/#{another_admin.id}/revoke_admin.json" + expect(response.status).to eq(200) + another_admin.reload + expect(another_admin.admin).to eq(false) + + expect(response.parsed_body['can_be_merged']).to eq(true) + expect(response.parsed_body['can_be_deleted']).to eq(true) + expect(response.parsed_body['can_be_anonymized']).to eq(true) + expect(response.parsed_body['can_delete_all_posts']).to eq(true) + end end - it 'updates the admin flag' do - put "/admin/users/#{another_admin.id}/revoke_admin.json" - expect(response.status).to eq(200) - another_admin.reload - expect(another_admin.admin).to eq(false) + shared_examples "admin access revocation not allowed" do + it "prevents revoking admin access with a 404 response" do + put "/admin/users/#{another_admin.id}/revoke_admin.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + another_admin.reload + expect(another_admin.admin).to eq(true) + end end - it 'returns detailed user schema' do - put "/admin/users/#{another_admin.id}/revoke_admin.json" - expect(response.parsed_body['can_be_merged']).to eq(true) - expect(response.parsed_body['can_be_deleted']).to eq(true) - expect(response.parsed_body['can_be_anonymized']).to eq(true) - expect(response.parsed_body['can_delete_all_posts']).to eq(true) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "admin access revocation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "admin access revocation not allowed" end end @@ -361,325 +502,464 @@ RSpec.describe Admin::UsersController do Discourse.redis.flushdb end - it "returns a 404 when the acting user doesn't have permission" do - sign_in(user) - put "/admin/users/#{another_user.id}/grant_admin.json" - expect(response.status).to eq(404) - expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) + context "when logged in as an admin" do + before { sign_in(admin) } + + it "returns a 404 if the username doesn't exist" do + put "/admin/users/123123/grant_admin.json" + expect(response.status).to eq(404) + end + + it 'sends a confirmation email if the acting admin does not have a second factor method enabled' do + expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) + put "/admin/users/#{another_user.id}/grant_admin.json" + expect(response.status).to eq(200) + expect(AdminConfirmation.exists_for?(another_user.id)).to eq(true) + end + + it 'asks the acting admin for second factor if it is enabled' do + Fabricate(:user_second_factor_totp, user: admin) + + put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true + + expect(response.parsed_body["second_factor_challenge_nonce"]).to be_present + expect(another_user.reload.admin).to eq(false) + end + + it 'grants admin if second factor is correct' do + user_second_factor = Fabricate(:user_second_factor_totp, user: admin) + + put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true + nonce = response.parsed_body["second_factor_challenge_nonce"] + expect(nonce).to be_present + expect(another_user.reload.admin).to eq(false) + + post "/session/2fa.json", params: { + nonce: nonce, + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] + } + res = response.parsed_body + expect(response.status).to eq(200) + expect(res["ok"]).to eq(true) + expect(res["callback_method"]).to eq("PUT") + expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") + expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") + expect(another_user.reload.admin).to eq(false) + + put res["callback_path"], params: { + second_factor_nonce: nonce + } + expect(response.status).to eq(200) + expect(another_user.reload.admin).to eq(true) + end + + it 'does not grant admin if second factor auth is not successful' do + user_second_factor = Fabricate(:user_second_factor_totp, user: admin) + + put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true + nonce = response.parsed_body["second_factor_challenge_nonce"] + expect(nonce).to be_present + expect(another_user.reload.admin).to eq(false) + + token = ROTP::TOTP.new(user_second_factor.data).now.to_i + token = (token == 999_999 ? token - 1 : token + 1).to_s + post "/session/2fa.json", params: { + nonce: nonce, + second_factor_token: token, + second_factor_method: UserSecondFactor.methods[:totp] + } + expect(response.status).to eq(400) + expect(another_user.reload.admin).to eq(false) + + put "/admin/users/#{another_user.id}/grant_admin.json", params: { + second_factor_nonce: nonce + } + expect(response.status).to eq(401) + expect(another_user.reload.admin).to eq(false) + end + + it 'does not grant admin if the acting admin loses permission in the middle of the process' do + user_second_factor = Fabricate(:user_second_factor_totp, user: admin) + + put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true + nonce = response.parsed_body["second_factor_challenge_nonce"] + expect(nonce).to be_present + expect(another_user.reload.admin).to eq(false) + + post "/session/2fa.json", params: { + nonce: nonce, + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] + } + res = response.parsed_body + expect(response.status).to eq(200) + expect(res["ok"]).to eq(true) + expect(res["callback_method"]).to eq("PUT") + expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") + expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") + expect(another_user.reload.admin).to eq(false) + + admin.update!(admin: false) + put res["callback_path"], params: { + second_factor_nonce: nonce + } + expect(response.status).to eq(404) + expect(another_user.reload.admin).to eq(false) + end + + it 'does not accept backup codes' do + Fabricate(:user_second_factor_totp, user: admin) + Fabricate(:user_second_factor_backup, user: admin) + + put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true + nonce = response.parsed_body["second_factor_challenge_nonce"] + expect(nonce).to be_present + expect(another_user.reload.admin).to eq(false) + + post "/session/2fa.json", params: { + nonce: nonce, + second_factor_token: "iAmValidBackupCode", + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + expect(response.status).to eq(403) + expect(another_user.reload.admin).to eq(false) + end end - it "returns a 404 when the acting user doesn't have permission even if they have 2FA enabled" do - Fabricate(:user_second_factor_totp, user: user) - sign_in(user) - put "/admin/users/#{another_user.id}/grant_admin.json" - expect(response.status).to eq(404) - expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) + shared_examples "admin grants not allowed" do + context "with 2FA enabled" do + before do + Fabricate(:user_second_factor_totp, user: user) + end + + it "prevents granting admin with a 404 response" do + put "/admin/users/#{another_user.id}/grant_admin.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) + end + end + + context "with 2FA disabled" do + it "prevents granting admin with a 404 response" do + put "/admin/users/#{another_user.id}/grant_admin.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) + end + end end - it "returns a 404 if the username doesn't exist" do - put "/admin/users/123123/grant_admin.json" - expect(response.status).to eq(404) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "admin grants not allowed" end - it 'sends a confirmation email if the acting admin does not have a second factor method enabled' do - expect(AdminConfirmation.exists_for?(another_user.id)).to eq(false) - put "/admin/users/#{another_user.id}/grant_admin.json" - expect(response.status).to eq(200) - expect(AdminConfirmation.exists_for?(another_user.id)).to eq(true) - end + context "when logged in as a non-staff user" do + before { sign_in(user) } - it 'asks the acting admin for second factor if it is enabled' do - Fabricate(:user_second_factor_totp, user: admin) - - put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true - - expect(response.parsed_body["second_factor_challenge_nonce"]).to be_present - expect(another_user.reload.admin).to eq(false) - end - - it 'grants admin if second factor is correct' do - user_second_factor = Fabricate(:user_second_factor_totp, user: admin) - - put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true - nonce = response.parsed_body["second_factor_challenge_nonce"] - expect(nonce).to be_present - expect(another_user.reload.admin).to eq(false) - - post "/session/2fa.json", params: { - nonce: nonce, - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, - second_factor_method: UserSecondFactor.methods[:totp] - } - res = response.parsed_body - expect(response.status).to eq(200) - expect(res["ok"]).to eq(true) - expect(res["callback_method"]).to eq("PUT") - expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") - expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") - expect(another_user.reload.admin).to eq(false) - - put res["callback_path"], params: { - second_factor_nonce: nonce - } - expect(response.status).to eq(200) - expect(another_user.reload.admin).to eq(true) - end - - it 'does not grant admin if second factor auth is not successful' do - user_second_factor = Fabricate(:user_second_factor_totp, user: admin) - - put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true - nonce = response.parsed_body["second_factor_challenge_nonce"] - expect(nonce).to be_present - expect(another_user.reload.admin).to eq(false) - - token = ROTP::TOTP.new(user_second_factor.data).now.to_i - token = (token == 999_999 ? token - 1 : token + 1).to_s - post "/session/2fa.json", params: { - nonce: nonce, - second_factor_token: token, - second_factor_method: UserSecondFactor.methods[:totp] - } - expect(response.status).to eq(400) - expect(another_user.reload.admin).to eq(false) - - put "/admin/users/#{another_user.id}/grant_admin.json", params: { - second_factor_nonce: nonce - } - expect(response.status).to eq(401) - expect(another_user.reload.admin).to eq(false) - end - - it 'does not grant admin if the acting admin loses permission in the middle of the process' do - user_second_factor = Fabricate(:user_second_factor_totp, user: admin) - - put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true - nonce = response.parsed_body["second_factor_challenge_nonce"] - expect(nonce).to be_present - expect(another_user.reload.admin).to eq(false) - - post "/session/2fa.json", params: { - nonce: nonce, - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, - second_factor_method: UserSecondFactor.methods[:totp] - } - res = response.parsed_body - expect(response.status).to eq(200) - expect(res["ok"]).to eq(true) - expect(res["callback_method"]).to eq("PUT") - expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") - expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") - expect(another_user.reload.admin).to eq(false) - - admin.update!(admin: false) - put res["callback_path"], params: { - second_factor_nonce: nonce - } - expect(response.status).to eq(404) - expect(another_user.reload.admin).to eq(false) - end - - it 'does not accept backup codes' do - Fabricate(:user_second_factor_totp, user: admin) - Fabricate(:user_second_factor_backup, user: admin) - - put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true - nonce = response.parsed_body["second_factor_challenge_nonce"] - expect(nonce).to be_present - expect(another_user.reload.admin).to eq(false) - - post "/session/2fa.json", params: { - nonce: nonce, - second_factor_token: "iAmValidBackupCode", - second_factor_method: UserSecondFactor.methods[:backup_codes] - } - expect(response.status).to eq(403) - expect(another_user.reload.admin).to eq(false) + include_examples "admin grants not allowed" end end describe '#add_group' do fab!(:group) { Fabricate(:group) } - it 'adds the user to the group' do - post "/admin/users/#{user.id}/groups.json", params: { - group_id: group.id - } + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - expect(GroupUser.where(user_id: user.id, group_id: group.id).exists?).to eq(true) - - group_history = GroupHistory.last - - expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) - expect(group_history.acting_user).to eq(admin) - expect(group_history.target_user).to eq(user) - - # Doing it again doesn't raise an error - post "/admin/users/#{user.id}/groups.json", params: { - group_id: group.id - } - - expect(response.status).to eq(200) - end - - it 'returns not-found error when there is no group' do - group.destroy! - - put "/admin/users/#{user.id}/groups.json", params: { - group_id: group.id - } - - expect(response.status).to eq(404) - end - - it 'does not allow adding users to an automatic group' do - group.update!(automatic: true) - - expect do + it 'adds the user to the group' do post "/admin/users/#{user.id}/groups.json", params: { group_id: group.id } - end.to_not change { group.users.count } - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + expect(response.status).to eq(200) + expect(GroupUser.where(user_id: user.id, group_id: group.id).exists?).to eq(true) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) + expect(group_history.acting_user).to eq(admin) + expect(group_history.target_user).to eq(user) + + # Doing it again doesn't raise an error + post "/admin/users/#{user.id}/groups.json", params: { + group_id: group.id + } + + expect(response.status).to eq(200) + end + + it 'returns not-found error when there is no group' do + group.destroy! + + put "/admin/users/#{user.id}/groups.json", params: { + group_id: group.id + } + + expect(response.status).to eq(404) + end + + it 'does not allow adding users to an automatic group' do + group.update!(automatic: true) + + expect do + post "/admin/users/#{user.id}/groups.json", params: { + group_id: group.id + } + end.to_not change { group.users.count } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + end + end + + shared_examples "adding users to groups not allowed" do + it "prevents adding user to group with a 404 response" do + post "/admin/users/#{user.id}/groups.json", params: { + group_id: group.id + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(GroupUser.where(user_id: user.id, group_id: group.id).exists?).to eq(false) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "adding users to groups not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "adding users to groups not allowed" end end describe '#remove_group' do - it "also clears the user's primary group" do - group = Fabricate(:group, users: [user]) - user.update!(primary_group_id: group.id) - delete "/admin/users/#{user.id}/groups/#{group.id}.json" + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - expect(user.reload.primary_group).to eq(nil) + it "also clears the user's primary group" do + group = Fabricate(:group, users: [user]) + user.update!(primary_group_id: group.id) + delete "/admin/users/#{user.id}/groups/#{group.id}.json" + + expect(response.status).to eq(200) + expect(user.reload.primary_group).to eq(nil) + end + + it 'returns not-found error when there is no group' do + delete "/admin/users/#{user.id}/groups/9090.json" + + expect(response.status).to eq(404) + end + + it 'does not allow removing owners from an automatic group' do + group = Fabricate(:group, users: [user], automatic: true) + + delete "/admin/users/#{user.id}/groups/#{group.id}.json" + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + end end - it 'returns not-found error when there is no group' do - delete "/admin/users/#{user.id}/groups/9090.json" + shared_examples "removing user from groups not allowed" do + it "prevents removing user from group with a 404 response" do + group = Fabricate(:group, users: [user]) + user.update!(primary_group_id: group.id) - expect(response.status).to eq(404) + delete "/admin/users/#{user.id}/groups/#{group.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(user.reload.primary_group).to eq(group) + end end - it 'does not allow removing owners from an automatic group' do - group = Fabricate(:group, users: [user], automatic: true) + context "when logged in as a moderator" do + before { sign_in(moderator) } - delete "/admin/users/#{user.id}/groups/#{group.id}.json" + include_examples "removing user from groups not allowed" + end - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "removing user from groups not allowed" end end describe '#trust_level' do - fab!(:another_user) { + fab!(:another_user) do coding_horror.update!(created_at: 1.month.ago) coding_horror - } - - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{another_user.id}/trust_level.json" - expect(response.status).to eq(404) end - it "returns a 404 if the username doesn't exist" do - put "/admin/users/123123/trust_level.json" - expect(response.status).to eq(404) + shared_examples "trust level updates possible" do + it "returns a 404 if the username doesn't exist" do + put "/admin/users/123123/trust_level.json" + expect(response.status).to eq(404) + end + + it "upgrades the user's trust level" do + put "/admin/users/#{another_user.id}/trust_level.json", params: { level: 2 } + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.trust_level).to eq(2) + + expect(UserHistory.where( + target_user: another_user, + acting_user: acting_user, + action: UserHistory.actions[:change_trust_level] + ).count).to eq(1) + end + + it "raises no error when demoting a user below their current trust level (locks trust level)" do + stat = another_user.user_stat + stat.topics_entered = SiteSetting.tl1_requires_topics_entered + 1 + stat.posts_read_count = SiteSetting.tl1_requires_read_posts + 1 + stat.time_read = SiteSetting.tl1_requires_time_spent_mins * 60 + stat.save! + another_user.update(trust_level: TrustLevel[1]) + + put "/admin/users/#{another_user.id}/trust_level.json", params: { + level: TrustLevel[0] + } + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.trust_level).to eq(TrustLevel[0]) + expect(another_user.manual_locked_trust_level).to eq(TrustLevel[0]) + end end - it "upgrades the user's trust level" do - put "/admin/users/#{another_user.id}/trust_level.json", params: { level: 2 } + context "when logged in as an admin" do + let(:acting_user) { admin } - expect(response.status).to eq(200) - another_user.reload - expect(another_user.trust_level).to eq(2) + before { sign_in(admin) } - expect(UserHistory.where( - target_user: another_user, - acting_user: admin, - action: UserHistory.actions[:change_trust_level] - ).count).to eq(1) + include_examples "trust level updates possible" end - it "raises no error when demoting a user below their current trust level (locks trust level)" do - stat = another_user.user_stat - stat.topics_entered = SiteSetting.tl1_requires_topics_entered + 1 - stat.posts_read_count = SiteSetting.tl1_requires_read_posts + 1 - stat.time_read = SiteSetting.tl1_requires_time_spent_mins * 60 - stat.save! - another_user.update(trust_level: TrustLevel[1]) + context "when logged in as a moderator" do + let(:acting_user) { moderator } - put "/admin/users/#{another_user.id}/trust_level.json", params: { - level: TrustLevel[0] - } + before { sign_in(moderator) } - expect(response.status).to eq(200) - another_user.reload - expect(another_user.trust_level).to eq(TrustLevel[0]) - expect(another_user.manual_locked_trust_level).to eq(TrustLevel[0]) + include_examples "trust level updates possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents updates trust level with a 404 response" do + put "/admin/users/#{another_user.id}/trust_level.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end describe '#grant_moderation' do fab!(:another_user) { coding_horror } - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{another_user.id}/grant_moderation.json" - expect(response.status).to eq(404) - end + context "when logged in as an admin" do + before { sign_in(admin) } - it "returns a 404 if the username doesn't exist" do - put "/admin/users/123123/grant_moderation.json" - expect(response.status).to eq(404) - end - - it 'updates the moderator flag' do - expect_enqueued_with(job: :send_system_message, args: { - user_id: another_user.id, - message_type: 'welcome_staff', - message_options: { role: :moderator } - }) do - put "/admin/users/#{another_user.id}/grant_moderation.json" + it "returns a 404 if the username doesn't exist" do + put "/admin/users/123123/grant_moderation.json" + expect(response.status).to eq(404) end - expect(response.status).to eq(200) - another_user.reload - expect(another_user.moderator).to eq(true) + it 'updates the moderator flag' do + expect_enqueued_with(job: :send_system_message, args: { + user_id: another_user.id, + message_type: 'welcome_staff', + message_options: { role: :moderator } + }) do + put "/admin/users/#{another_user.id}/grant_moderation.json" + end + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.moderator).to eq(true) + + expect(response.parsed_body['can_be_merged']).to eq(false) + expect(response.parsed_body['can_be_anonymized']).to eq(false) + end end - it 'returns detailed user schema' do - put "/admin/users/#{another_user.id}/grant_moderation.json" - expect(response.parsed_body['can_be_merged']).to eq(false) - expect(response.parsed_body['can_be_anonymized']).to eq(false) + shared_examples "moderator access grant not allowed" do + it "prevents granting moderation rights to user with a 404 response" do + put "/admin/users/#{another_user.id}/grant_moderation.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "moderator access grant not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "moderator access grant not allowed" end end describe '#revoke_moderation' do - fab!(:moderator) { Fabricate(:moderator) } + fab!(:another_moderator) { Fabricate(:moderator) } - it 'raises an error unless the user can revoke access' do - sign_in(user) - put "/admin/users/#{moderator.id}/revoke_moderation.json" - expect(response.status).to eq(404) - moderator.reload - expect(moderator.moderator).to eq(true) + context "when logged in as an admin" do + before { sign_in(admin) } + + it 'updates the moderator flag' do + put "/admin/users/#{another_moderator.id}/revoke_moderation.json" + expect(response.status).to eq(200) + another_moderator.reload + expect(another_moderator.moderator).to eq(false) + + expect(response.parsed_body['can_be_merged']).to eq(true) + expect(response.parsed_body['can_be_anonymized']).to eq(true) + end end - it 'updates the moderator flag' do - put "/admin/users/#{moderator.id}/revoke_moderation.json" - expect(response.status).to eq(200) - moderator.reload - expect(moderator.moderator).to eq(false) + shared_examples "moderator access revocation not allowed" do + it "prevents revocation of moderator access with a 404 response" do + put "/admin/users/#{another_moderator.id}/revoke_moderation.json" + + another_moderator.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(another_moderator.moderator).to eq(true) + end end - it 'returns detailed user schema' do - put "/admin/users/#{moderator.id}/revoke_moderation.json" - expect(response.parsed_body['can_be_merged']).to eq(true) - expect(response.parsed_body['can_be_anonymized']).to eq(true) + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "moderator access revocation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "moderator access revocation not allowed" end end @@ -688,426 +968,679 @@ RSpec.describe Admin::UsersController do fab!(:another_user) { coding_horror } fab!(:another_group) { Fabricate(:group, title: 'New') } - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{another_user.id}/primary_group.json" - expect(response.status).to eq(404) - another_user.reload - expect(another_user.primary_group_id).to eq(nil) + shared_examples "primary group updates possible" do + it "returns a 404 if the user doesn't exist" do + put "/admin/users/123123/primary_group.json" + expect(response.status).to eq(404) + end + + it "changes the user's primary group" do + group.add(another_user) + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: group.id + } + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.primary_group_id).to eq(group.id) + end + + it "doesn't change primary group if they aren't a member of the group" do + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: group.id + } + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.primary_group_id).to eq(nil) + end + + it "remove user's primary group" do + group.add(another_user) + + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: "" + } + + expect(response.status).to eq(200) + another_user.reload + expect(another_user.primary_group_id).to eq(nil) + end + + it "updates user's title when it matches the previous primary group title" do + group.update_columns(primary_group: true, title: 'Previous') + group.add(another_user) + another_group.add(another_user) + + expect(another_user.reload.title).to eq('Previous') + + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: another_group.id + } + + another_user.reload + expect(response.status).to eq(200) + expect(another_user.primary_group_id).to eq(another_group.id) + expect(another_user.title).to eq('New') + end + + it "doesn't update user's title when it does not match the previous primary group title" do + another_user.update_columns(title: 'Different') + group.update_columns(primary_group: true, title: 'Previous') + another_group.add(another_user) + group.add(another_user) + + expect(another_user.reload.title).to eq('Different') + + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: another_group.id + } + + another_user.reload + expect(response.status).to eq(200) + expect(another_user.primary_group_id).to eq(another_group.id) + expect(another_user.title).to eq('Different') + end end - it "returns a 404 if the user doesn't exist" do - put "/admin/users/123123/primary_group.json" - expect(response.status).to eq(404) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "primary group updates possible" end - it "changes the user's primary group" do - group.add(another_user) - put "/admin/users/#{another_user.id}/primary_group.json", params: { - primary_group_id: group.id - } + context "when logged in as a moderator" do + before { sign_in(moderator) } - expect(response.status).to eq(200) - another_user.reload - expect(another_user.primary_group_id).to eq(group.id) + context "when moderators_manage_categories_and_groups site setting is enabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = true + end + + include_examples "primary group updates possible" + end + + context "when moderators_manage_categories_and_groups site setting is disabled" do + before do + SiteSetting.moderators_manage_categories_and_groups = false + end + + it "prevents setting primary group with a 403 response" do + group.add(another_user) + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: group.id + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + another_user.reload + expect(another_user.primary_group_id).to eq(nil) + end + end end - it "doesn't change primary group if they aren't a member of the group" do - put "/admin/users/#{another_user.id}/primary_group.json", params: { - primary_group_id: group.id - } + context "when logged in as a non-staff user" do + before { sign_in(user) } - expect(response.status).to eq(200) - another_user.reload - expect(another_user.primary_group_id).to eq(nil) - end + it "prevents setting primary group with a 404 response" do + group.add(another_user) + put "/admin/users/#{another_user.id}/primary_group.json", params: { + primary_group_id: group.id + } - it "remove user's primary group" do - group.add(another_user) - - put "/admin/users/#{another_user.id}/primary_group.json", params: { - primary_group_id: "" - } - - expect(response.status).to eq(200) - another_user.reload - expect(another_user.primary_group_id).to eq(nil) - end - - it "updates user's title when it matches the previous primary group title" do - group.update_columns(primary_group: true, title: 'Previous') - group.add(another_user) - another_group.add(another_user) - - expect(another_user.reload.title).to eq('Previous') - - put "/admin/users/#{another_user.id}/primary_group.json", params: { - primary_group_id: another_group.id - } - - another_user.reload - expect(response.status).to eq(200) - expect(another_user.primary_group_id).to eq(another_group.id) - expect(another_user.title).to eq('New') - end - - it "doesn't update user's title when it does not match the previous primary group title" do - another_user.update_columns(title: 'Different') - group.update_columns(primary_group: true, title: 'Previous') - another_group.add(another_user) - group.add(another_user) - - expect(another_user.reload.title).to eq('Different') - - put "/admin/users/#{another_user.id}/primary_group.json", params: { - primary_group_id: another_group.id - } - - another_user.reload - expect(response.status).to eq(200) - expect(another_user.primary_group_id).to eq(another_group.id) - expect(another_user.title).to eq('Different') + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + another_user.reload + expect(another_user.primary_group_id).to eq(nil) + end end end describe '#destroy' do fab!(:delete_me) { Fabricate(:user) } - it "returns a 403 if the user doesn't exist" do - delete "/admin/users/123123drink.json" - expect(response.status).to eq(403) - end - - context "when user has post" do - let(:topic) { Fabricate(:topic, user: delete_me) } - let!(:post) { Fabricate(:post, topic: topic, user: delete_me) } - - it "returns an api response that the user can't be deleted because it has posts" do - post_count = delete_me.posts.joins(:topic).count - delete_me_topic = Fabricate(:topic) - Fabricate(:post, topic: delete_me_topic, user: delete_me) - PostDestroyer.new(admin, delete_me_topic.first_post, context: "Deleted by admin").destroy - - delete "/admin/users/#{delete_me.id}.json" + shared_examples "user deletion possible" do + it "returns a 403 if the user doesn't exist" do + delete "/admin/users/123123drink.json" expect(response.status).to eq(403) - json = response.parsed_body - expect(json['deleted']).to eq(false) - expect(json['message']).to eq(I18n.t("user.cannot_delete_has_posts", username: delete_me.username, count: post_count)) end - it "doesn't return an error if delete_posts == true" do - delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true } - expect(response.status).to eq(200) - expect(Post.where(id: post.id).count).to eq(0) - expect(Topic.where(id: topic.id).count).to eq(0) - expect(User.where(id: delete_me.id).count).to eq(0) - end + context "when user has post" do + let(:topic) { Fabricate(:topic, user: delete_me) } + let!(:post) { Fabricate(:post, topic: topic, user: delete_me) } - context "when user has reviewable flagged post which was handled" do - let!(:reviewable) { Fabricate(:reviewable_flagged_post, created_by: admin, target_created_by: delete_me, target: post, topic: topic, status: 4) } + it "returns an api response that the user can't be deleted because it has posts" do + post_count = delete_me.posts.joins(:topic).count + delete_me_topic = Fabricate(:topic) + Fabricate(:post, topic: delete_me_topic, user: delete_me) + PostDestroyer.new(admin, delete_me_topic.first_post, context: "Deleted by admin").destroy - it "deletes the user record" do - delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, delete_as_spammer: true } + delete "/admin/users/#{delete_me.id}.json" + expect(response.status).to eq(403) + json = response.parsed_body + expect(json['deleted']).to eq(false) + expect(json['message']).to eq(I18n.t("user.cannot_delete_has_posts", username: delete_me.username, count: post_count)) + end + + it "doesn't return an error if delete_posts == true" do + delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true } expect(response.status).to eq(200) + expect(Post.where(id: post.id).count).to eq(0) + expect(Topic.where(id: topic.id).count).to eq(0) expect(User.where(id: delete_me.id).count).to eq(0) end - end - end - it "blocks the e-mail if block_email param is is true" do - user_emails = delete_me.user_emails.pluck(:email) + context "when user has reviewable flagged post which was handled" do + let!(:reviewable) { Fabricate(:reviewable_flagged_post, created_by: admin, target_created_by: delete_me, target: post, topic: topic, status: 4) } - delete "/admin/users/#{delete_me.id}.json", params: { block_email: true } - expect(response.status).to eq(200) - expect(ScreenedEmail.exists?(email: user_emails)).to eq(true) - end - - it "does not block the e-mails if block_email param is is false" do - user_emails = delete_me.user_emails.pluck(:email) - - delete "/admin/users/#{delete_me.id}.json", params: { block_email: false } - expect(response.status).to eq(200) - expect(ScreenedEmail.exists?(email: user_emails)).to eq(false) - end - - it "does not block the e-mails by default" do - user_emails = delete_me.user_emails.pluck(:email) - - delete "/admin/users/#{delete_me.id}.json" - expect(response.status).to eq(200) - expect(ScreenedEmail.exists?(email: user_emails)).to eq(false) - end - - it "blocks the ip address if block_ip param is true" do - ip_address = delete_me.ip_address - - delete "/admin/users/#{delete_me.id}.json", params: { block_ip: true } - expect(response.status).to eq(200) - expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(true) - end - - it "does not block the ip address if block_ip param is false" do - ip_address = delete_me.ip_address - - delete "/admin/users/#{delete_me.id}.json", params: { block_ip: false } - expect(response.status).to eq(200) - expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(false) - end - - it "does not block the ip address by default" do - ip_address = delete_me.ip_address - - delete "/admin/users/#{delete_me.id}.json" - expect(response.status).to eq(200) - expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(false) - end - - context "with param block_url" do - before do - @post = Fabricate(:post_with_external_links, user: delete_me) - TopicLink.extract_from(@post) - - @urls = TopicLink.where(user: delete_me, internal: false) - .pluck(:url) - .map { |url| ScreenedUrl.normalize_url(url) } + it "deletes the user record" do + delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, delete_as_spammer: true } + expect(response.status).to eq(200) + expect(User.where(id: delete_me.id).count).to eq(0) + end + end end - it "blocks the urls if block_url param is true" do - delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: true } + it "blocks the e-mail if block_email param is is true" do + user_emails = delete_me.user_emails.pluck(:email) + + delete "/admin/users/#{delete_me.id}.json", params: { block_email: true } expect(response.status).to eq(200) - expect(ScreenedUrl.exists?(url: @urls)).to eq(true) + expect(ScreenedEmail.exists?(email: user_emails)).to eq(true) end - it "does not block the urls if block_url param is false" do - delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: false } + it "does not block the e-mails if block_email param is is false" do + user_emails = delete_me.user_emails.pluck(:email) + + delete "/admin/users/#{delete_me.id}.json", params: { block_email: false } expect(response.status).to eq(200) - expect(ScreenedUrl.exists?(url: @urls)).to eq(false) + expect(ScreenedEmail.exists?(email: user_emails)).to eq(false) end - it "does not block the urls by default" do - delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: false } + it "does not block the e-mails by default" do + user_emails = delete_me.user_emails.pluck(:email) + + delete "/admin/users/#{delete_me.id}.json" expect(response.status).to eq(200) - expect(ScreenedUrl.exists?(url: @urls)).to eq(false) + expect(ScreenedEmail.exists?(email: user_emails)).to eq(false) + end + + it "blocks the ip address if block_ip param is true" do + ip_address = delete_me.ip_address + + delete "/admin/users/#{delete_me.id}.json", params: { block_ip: true } + expect(response.status).to eq(200) + expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(true) + end + + it "does not block the ip address if block_ip param is false" do + ip_address = delete_me.ip_address + + delete "/admin/users/#{delete_me.id}.json", params: { block_ip: false } + expect(response.status).to eq(200) + expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(false) + end + + it "does not block the ip address by default" do + ip_address = delete_me.ip_address + + delete "/admin/users/#{delete_me.id}.json" + expect(response.status).to eq(200) + expect(ScreenedIpAddress.exists?(ip_address: ip_address)).to eq(false) + end + + context "with param block_url" do + before do + @post = Fabricate(:post_with_external_links, user: delete_me) + TopicLink.extract_from(@post) + + @urls = TopicLink.where(user: delete_me, internal: false) + .pluck(:url) + .map { |url| ScreenedUrl.normalize_url(url) } + end + + it "blocks the urls if block_url param is true" do + delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: true } + expect(response.status).to eq(200) + expect(ScreenedUrl.exists?(url: @urls)).to eq(true) + end + + it "does not block the urls if block_url param is false" do + delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: false } + expect(response.status).to eq(200) + expect(ScreenedUrl.exists?(url: @urls)).to eq(false) + end + + it "does not block the urls by default" do + delete "/admin/users/#{delete_me.id}.json", params: { delete_posts: true, block_urls: false } + expect(response.status).to eq(200) + expect(ScreenedUrl.exists?(url: @urls)).to eq(false) + end + end + + it "deletes the user record" do + delete "/admin/users/#{delete_me.id}.json" + expect(response.status).to eq(200) + expect(User.where(id: delete_me.id).count).to eq(0) end end - it "deletes the user record" do - delete "/admin/users/#{delete_me.id}.json" - expect(response.status).to eq(200) - expect(User.where(id: delete_me.id).count).to eq(0) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "user deletion possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user deletion possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents deleting user with a 404 response" do + delete "/admin/users/#{delete_me.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(User.where(id: delete_me.id).count).to eq(1) + end end end describe '#activate' do fab!(:reg_user) { Fabricate(:inactive_user) } - it "returns success" do - put "/admin/users/#{reg_user.id}/activate.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json['success']).to eq("OK") - reg_user.reload - expect(reg_user.active).to eq(true) + shared_examples "user activation possible" do + it "returns success" do + put "/admin/users/#{reg_user.id}/activate.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['success']).to eq("OK") + reg_user.reload + expect(reg_user.active).to eq(true) + end + + it "should confirm email even when the tokens are expired" do + reg_user.email_tokens.update_all(confirmed: false, expired: true) + + reg_user.reload + expect(reg_user.email_confirmed?).to eq(false) + + put "/admin/users/#{reg_user.id}/activate.json" + expect(response.status).to eq(200) + + reg_user.reload + expect(reg_user.email_confirmed?).to eq(true) + end end - it "should confirm email even when the tokens are expired" do - reg_user.email_tokens.update_all(confirmed: false, expired: true) + context "when logged in as an admin" do + before { sign_in(admin) } - reg_user.reload - expect(reg_user.email_confirmed?).to eq(false) + include_examples "user activation possible" + end - put "/admin/users/#{reg_user.id}/activate.json" - expect(response.status).to eq(200) + context "when logged in as a moderator" do + before { sign_in(moderator) } - reg_user.reload - expect(reg_user.email_confirmed?).to eq(true) + include_examples "user activation possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents activation of user with a 404 response" do + put "/admin/users/#{reg_user.id}/activate.json" + + reg_user.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(reg_user.active).to eq(false) + end end end describe '#deactivate' do fab!(:reg_user) { Fabricate(:active_user) } - it "returns success" do - put "/admin/users/#{reg_user.id}/deactivate.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json['success']).to eq("OK") - reg_user.reload - expect(reg_user.active).to eq(false) + shared_examples "user deactivation possible" do + it "returns success" do + put "/admin/users/#{reg_user.id}/deactivate.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['success']).to eq("OK") + reg_user.reload + expect(reg_user.active).to eq(false) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "user deactivation possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user deactivation possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents deactivation of user with a 404 response" do + put "/admin/users/#{reg_user.id}/deactivate.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + reg_user.reload + expect(reg_user.active).to eq(true) + end end end describe '#log_out' do fab!(:reg_user) { Fabricate(:user) } - it "returns success" do - post "/admin/users/#{reg_user.id}/log_out.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json['success']).to eq("OK") + context "when logged in as an admin" do + before { sign_in(admin) } + + it "returns success" do + post "/admin/users/#{reg_user.id}/log_out.json" + expect(response.status).to eq(200) + json = response.parsed_body + expect(json['success']).to eq("OK") + end + + it "returns 404 when user_id does not exist" do + post "/admin/users/123123drink/log_out.json" + expect(response.status).to eq(404) + end end - it "returns 404 when user_id does not exist" do - post "/admin/users/123123drink/log_out.json" - expect(response.status).to eq(404) + shared_examples "user log out not allowed" do + it "prevents loging out of user with a 404 response" do + post "/admin/users/#{reg_user.id}/log_out.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user log out not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "user log out not allowed" end end describe '#silence' do fab!(:reg_user) { Fabricate(:user) } - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{reg_user.id}/silence.json" - expect(response.status).to eq(404) - reg_user.reload - expect(reg_user).not_to be_silenced - end + context "when logged in as an admin" do + before { sign_in(admin) } - it "returns a 404 if the user doesn't exist" do - put "/admin/users/123123/silence.json" - expect(response.status).to eq(404) - end + it "returns a 404 if the user doesn't exist" do + put "/admin/users/123123/silence.json" + expect(response.status).to eq(404) + end - it "punishes the user for spamming" do - put "/admin/users/#{reg_user.id}/silence.json" - expect(response.status).to eq(200) - reg_user.reload - expect(reg_user).to be_silenced - expect(reg_user.silenced_record).to be_present - end + it "punishes the user for spamming" do + put "/admin/users/#{reg_user.id}/silence.json" + expect(response.status).to eq(200) + reg_user.reload + expect(reg_user).to be_silenced + expect(reg_user.silenced_record).to be_present + end - it "can have an associated post" do - silence_post = Fabricate(:post, user: reg_user) + it "can have an associated post" do + silence_post = Fabricate(:post, user: reg_user) - put "/admin/users/#{reg_user.id}/silence.json", params: { - post_id: silence_post.id, - post_action: 'edit', - post_edit: "this is the new contents for the post" - } - expect(response.status).to eq(200) - - silence_post.reload - expect(silence_post.raw).to eq("this is the new contents for the post") - - log = UserHistory.where( - target_user_id: reg_user.id, - action: UserHistory.actions[:silence_user] - ).first - expect(log).to be_present - expect(log.post_id).to eq(silence_post.id) - - reg_user.reload - expect(reg_user).to be_silenced - end - - it "will set a length of time if provided" do - future_date = 1.month.from_now.to_date - put "/admin/users/#{reg_user.id}/silence.json", params: { - silenced_till: future_date - } - - expect(response.status).to eq(200) - reg_user.reload - expect(reg_user).to be_silenced - expect(reg_user.silenced_till).to eq(future_date) - end - - it "will send a message if provided" do - expect do put "/admin/users/#{reg_user.id}/silence.json", params: { - message: "Email this to the user" + post_id: silence_post.id, + post_action: 'edit', + post_edit: "this is the new contents for the post" } - end.to change { Jobs::CriticalUserEmail.jobs.size }.by(1) + expect(response.status).to eq(200) - expect(response.status).to eq(200) - reg_user.reload - expect(reg_user).to be_silenced + silence_post.reload + expect(silence_post.raw).to eq("this is the new contents for the post") + + log = UserHistory.where( + target_user_id: reg_user.id, + action: UserHistory.actions[:silence_user] + ).first + expect(log).to be_present + expect(log.post_id).to eq(silence_post.id) + + reg_user.reload + expect(reg_user).to be_silenced + end + + it "will set a length of time if provided" do + future_date = 1.month.from_now.to_date + put "/admin/users/#{reg_user.id}/silence.json", params: { + silenced_till: future_date + } + + expect(response.status).to eq(200) + reg_user.reload + expect(reg_user).to be_silenced + expect(reg_user.silenced_till).to eq(future_date) + end + + it "will send a message if provided" do + expect do + put "/admin/users/#{reg_user.id}/silence.json", params: { + message: "Email this to the user" + } + end.to change { Jobs::CriticalUserEmail.jobs.size }.by(1) + + expect(response.status).to eq(200) + reg_user.reload + expect(reg_user).to be_silenced + end + + it "checks if user is silenced" do + put "/admin/users/#{user.id}/silence.json", params: { + silenced_till: 5.hours.from_now, + reason: "because I said so" + } + + put "/admin/users/#{user.id}/silence.json", params: { + silenced_till: 5.hours.from_now, + reason: "because I said so too" + } + + expect(response.status).to eq(409) + expect(response.parsed_body["message"]).to eq( + I18n.t( + "user.already_silenced", + staff: admin.username, + time_ago: FreedomPatches::Rails4.time_ago_in_words(user.silenced_record.created_at, true, scope: :'datetime.distance_in_words_verbose') + ) + ) + end end - it "checks if user is silenced" do - put "/admin/users/#{user.id}/silence.json", params: { - silenced_till: 5.hours.from_now, - reason: "because I said so" - } + context "when logged in as a moderator" do + before { sign_in(moderator) } - put "/admin/users/#{user.id}/silence.json", params: { - silenced_till: 5.hours.from_now, - reason: "because I said so too" - } + it "silences user" do + put "/admin/users/#{reg_user.id}/silence.json" - expect(response.status).to eq(409) - expect(response.parsed_body["message"]).to eq( - I18n.t( - "user.already_silenced", - staff: admin.username, - time_ago: FreedomPatches::Rails4.time_ago_in_words(user.silenced_record.created_at, true, scope: :'datetime.distance_in_words_verbose') - ) - ) + expect(response.status).to eq(200) + reg_user.reload + expect(reg_user).to be_silenced + expect(reg_user.silenced_record).to be_present + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents silencing user with a 404 response" do + put "/admin/users/#{reg_user.id}/silence.json" + + reg_user.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(reg_user).not_to be_silenced + end end end describe '#unsilence' do fab!(:reg_user) { Fabricate(:user, silenced_till: 10.years.from_now) } - it "raises an error when the user doesn't have permission" do - sign_in(user) - put "/admin/users/#{reg_user.id}/unsilence.json" - expect(response.status).to eq(404) + shared_examples "unsilencing user possible" do + it "returns a 403 if the user doesn't exist" do + put "/admin/users/123123/unsilence.json" + expect(response.status).to eq(404) + end + + it "unsilences the user" do + put "/admin/users/#{reg_user.id}/unsilence.json" + expect(response.status).to eq(200) + reg_user.reload + expect(reg_user.silenced?).to eq(false) + log = UserHistory.where( + target_user_id: reg_user.id, + action: UserHistory.actions[:unsilence_user] + ).first + expect(log).to be_present + end end - it "returns a 403 if the user doesn't exist" do - put "/admin/users/123123/unsilence.json" - expect(response.status).to eq(404) + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "unsilencing user possible" end - it "unsilences the user" do - put "/admin/users/#{reg_user.id}/unsilence.json" - expect(response.status).to eq(200) - reg_user.reload - expect(reg_user.silenced?).to eq(false) - log = UserHistory.where( - target_user_id: reg_user.id, - action: UserHistory.actions[:unsilence_user] - ).first - expect(log).to be_present + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "unsilencing user possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents unsilencing user with a 404 response" do + put "/admin/users/#{reg_user.id}/unsilence.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end describe '#ip_info' do - it "retrieves IP info" do - ip = "81.2.69.142" + shared_examples "IP info retrieval possible" do + it "retrieves IP info" do + ip = "81.2.69.142" - DiscourseIpInfo.open_db(File.join(Rails.root, 'spec', 'fixtures', 'mmdb')) - Resolv::DNS.any_instance.stubs(:getname).with(ip).returns("ip-81-2-69-142.example.com") + DiscourseIpInfo.open_db(File.join(Rails.root, 'spec', 'fixtures', 'mmdb')) + Resolv::DNS.any_instance.stubs(:getname).with(ip).returns("ip-81-2-69-142.example.com") - get "/admin/users/ip-info.json", params: { ip: ip } - expect(response.status).to eq(200) - expect(response.parsed_body.symbolize_keys).to eq( - city: "London", - country: "United Kingdom", - country_code: "GB", - geoname_ids: [6255148, 2635167, 2643743, 6269131], - hostname: "ip-81-2-69-142.example.com", - location: "London, England, United Kingdom", - region: "England", - latitude: 51.5142, - longitude: -0.0931, - ) + get "/admin/users/ip-info.json", params: { ip: ip } + expect(response.status).to eq(200) + expect(response.parsed_body.symbolize_keys).to eq( + city: "London", + country: "United Kingdom", + country_code: "GB", + geoname_ids: [6255148, 2635167, 2643743, 6269131], + hostname: "ip-81-2-69-142.example.com", + location: "London, England, United Kingdom", + region: "England", + latitude: 51.5142, + longitude: -0.0931, + ) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "IP info retrieval possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "IP info retrieval possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents retrieval of IP info with a 404 response" do + ip = "81.2.69.142" + + DiscourseIpInfo.open_db(File.join(Rails.root, 'spec', 'fixtures', 'mmdb')) + Resolv::DNS.any_instance.stubs(:getname).with(ip).returns("ip-81-2-69-142.example.com") + + get "/admin/users/ip-info.json", params: { ip: ip } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end end describe '#delete_other_accounts_with_same_ip' do - it "works" do - user_a = Fabricate(:user, ip_address: "42.42.42.42") - user_b = Fabricate(:user, ip_address: "42.42.42.42") + shared_examples "deleting other accounts with same ip possible" do + it "works" do + user_a = Fabricate(:user, ip_address: "42.42.42.42") + user_b = Fabricate(:user, ip_address: "42.42.42.42") - delete "/admin/users/delete-others-with-same-ip.json", params: { - ip: "42.42.42.42", exclude: -1, order: "trust_level DESC" - } - expect(response.status).to eq(200) - expect(User.where(id: user_a.id).count).to eq(0) - expect(User.where(id: user_b.id).count).to eq(0) + delete "/admin/users/delete-others-with-same-ip.json", params: { + ip: "42.42.42.42", exclude: -1, order: "trust_level DESC" + } + expect(response.status).to eq(200) + expect(User.where(id: user_a.id).count).to eq(0) + expect(User.where(id: user_b.id).count).to eq(0) + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "deleting other accounts with same ip possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "deleting other accounts with same ip possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents deletion of other accounts with same ip with a 404 response" do + user_a = Fabricate(:user, ip_address: "42.42.42.42") + user_b = Fabricate(:user, ip_address: "42.42.42.42") + + delete "/admin/users/delete-others-with-same-ip.json", params: { + ip: "42.42.42.42", exclude: -1, order: "trust_level DESC" + } + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(User.where(id: user_a.id).count).to eq(1) + expect(User.where(id: user_b.id).count).to eq(1) + end end end @@ -1126,89 +1659,130 @@ RSpec.describe Admin::UsersController do sso.sso_secret = sso_secret end - it 'can sync up with the sso' do - sso.name = "Bob The Bob" - sso.username = "bob" - sso.email = "bob@bob.com" - sso.external_id = "1" + context "when logged in as an admin" do + before { sign_in(admin) } - user = DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user + it 'can sync up with the sso' do + sso.name = "Bob The Bob" + sso.username = "bob" + sso.email = "bob@bob.com" + sso.external_id = "1" - sso.name = "Bill" - sso.username = "Hokli$$!!" - sso.email = "bob2@bob.com" + user = DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user - post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) - expect(response.status).to eq(200) + sso.name = "Bill" + sso.username = "Hokli$$!!" + sso.email = "bob2@bob.com" - user.reload - expect(user.email).to eq("bob2@bob.com") - expect(user.name).to eq("Bill") - expect(user.username).to eq("Hokli") - end - - it 'should create new users' do - sso.name = "Dr. Claw" - sso.username = "dr_claw" - sso.email = "dr@claw.com" - sso.external_id = "2" - post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) - expect(response.status).to eq(200) - - user = User.find_by_email('dr@claw.com') - expect(user).to be_present - expect(user.ip_address).to be_blank - end - - it "triggers :sync_sso DiscourseEvent" do - sso.name = "Bob The Bob" - sso.username = "bob" - sso.email = "bob@bob.com" - sso.external_id = "1" - - user = DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user - - sso.name = "Bill" - sso.username = "Hokli$$!!" - sso.email = "bob2@bob.com" - - events = DiscourseEvent.track_events do post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + expect(response.status).to eq(200) + + user.reload + expect(user.email).to eq("bob2@bob.com") + expect(user.name).to eq("Bill") + expect(user.username).to eq("Hokli") + end + + it 'should create new users' do + sso.name = "Dr. Claw" + sso.username = "dr_claw" + sso.email = "dr@claw.com" + sso.external_id = "2" + post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + expect(response.status).to eq(200) + + user = User.find_by_email('dr@claw.com') + expect(user).to be_present + expect(user.ip_address).to be_blank + end + + it "triggers :sync_sso DiscourseEvent" do + sso.name = "Bob The Bob" + sso.username = "bob" + sso.email = "bob@bob.com" + sso.external_id = "1" + + user = DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user + + sso.name = "Bill" + sso.username = "Hokli$$!!" + sso.email = "bob2@bob.com" + + events = DiscourseEvent.track_events do + post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + end + expect(events).to include(event_name: :sync_sso, params: [user]) + end + + it 'should return the right message if the record is invalid' do + sso.email = "" + sso.name = "" + sso.external_id = "1" + + post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + expect(response.status).to eq(403) + expect(response.parsed_body["message"]).to include("Primary email can't be blank") + end + + it 'should return the right message if the signature is invalid' do + sso.name = "Dr. Claw" + sso.username = "dr_claw" + sso.email = "dr@claw.com" + sso.external_id = "2" + + correct_payload = Rack::Utils.parse_query(sso.payload) + post "/admin/users/sync_sso.json", params: correct_payload.merge(sig: "someincorrectsignature") + expect(response.status).to eq(422) + expect(response.parsed_body["message"]).to include(I18n.t('discourse_connect.login_error')) + expect(response.parsed_body["message"]).not_to include(correct_payload["sig"]) + end + + it "returns 404 if the external id does not exist" do + sso.name = "Dr. Claw" + sso.username = "dr_claw" + sso.email = "dr@claw.com" + sso.external_id = "" + post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + expect(response.status).to eq(422) + expect(response.parsed_body["message"]).to include(I18n.t('discourse_connect.blank_id_error')) end - expect(events).to include(event_name: :sync_sso, params: [user]) end - it 'should return the right message if the record is invalid' do - sso.email = "" - sso.name = "" - sso.external_id = "1" + shared_examples "sso sync not allowed" do + it "prevents sso sync with a 404 response" do + sso.name = "Bob The Bob" + sso.username = "bob" + sso.email = "bob@bob.com" + sso.external_id = "1" - post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) - expect(response.status).to eq(403) - expect(response.parsed_body["message"]).to include("Primary email can't be blank") + user = DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user + + sso.name = "Bill" + sso.username = "Hokli$$!!" + sso.email = "bob2@bob.com" + + post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + user.reload + expect(user.email).to eq("bob@bob.com") + expect(user.name).to eq("Bob The Bob") + expect(user.username).to eq("bob") + end end - it 'should return the right message if the signature is invalid' do - sso.name = "Dr. Claw" - sso.username = "dr_claw" - sso.email = "dr@claw.com" - sso.external_id = "2" + context "when logged in as a moderator" do + before { sign_in(moderator) } - correct_payload = Rack::Utils.parse_query(sso.payload) - post "/admin/users/sync_sso.json", params: correct_payload.merge(sig: "someincorrectsignature") - expect(response.status).to eq(422) - expect(response.parsed_body["message"]).to include(I18n.t('discourse_connect.login_error')) - expect(response.parsed_body["message"]).not_to include(correct_payload["sig"]) + include_examples "sso sync not allowed" end - it "returns 404 if the external id does not exist" do - sso.name = "Dr. Claw" - sso.username = "dr_claw" - sso.email = "dr@claw.com" - sso.external_id = "" - post "/admin/users/sync_sso.json", params: Rack::Utils.parse_query(sso.payload) - expect(response.status).to eq(422) - expect(response.parsed_body["message"]).to include(I18n.t('discourse_connect.blank_id_error')) + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "sso sync not allowed" end end @@ -1217,12 +1791,15 @@ RSpec.describe Admin::UsersController do let(:second_factor_backup) { user.generate_backup_codes } let(:security_key) { Fabricate(:user_security_key, user: user) } - describe 'as an admin' do + before do + second_factor + second_factor_backup + security_key + end + + context "when logged in as an admin" do before do sign_in(admin) - second_factor - second_factor_backup - security_key expect(user.reload.user_second_factors.totps.first).to eq(second_factor) end @@ -1275,73 +1852,147 @@ RSpec.describe Admin::UsersController do end end end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "prevents disabling the second factor with a 403 response" do + expect do + put "/admin/users/#{user.id}/disable_second_factor.json" + end.not_to change { Jobs::CriticalUserEmail.jobs.length } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + + expect(user.reload.user_second_factors).not_to be_empty + expect(user.reload.security_keys).not_to be_empty + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents disabling the second factor with a 403 response" do + expect do + put "/admin/users/#{user.id}/disable_second_factor.json" + end.not_to change { Jobs::CriticalUserEmail.jobs.length } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + expect(user.reload.user_second_factors).not_to be_empty + expect(user.reload.security_keys).not_to be_empty + end + end end describe "#penalty_history" do - fab!(:moderator) { Fabricate(:moderator) } let(:logger) { StaffActionLogger.new(admin) } - it "doesn't allow moderators to clear a user's history" do - sign_in(moderator) - delete "/admin/users/#{user.id}/penalty_history.json" - expect(response.code).to eq("404") + context "when logged in as an admin" do + before { sign_in(admin) } + + def find_logs(action) + UserHistory.where(target_user_id: user.id, action: UserHistory.actions[action]) + end + + it "allows admins to clear a user's history" do + logger.log_user_suspend(user, "suspend reason") + logger.log_user_unsuspend(user) + logger.log_unsilence_user(user) + logger.log_silence_user(user) + + delete "/admin/users/#{user.id}/penalty_history.json" + expect(response.code).to eq("200") + + expect(find_logs(:suspend_user)).to be_blank + expect(find_logs(:unsuspend_user)).to be_blank + expect(find_logs(:silence_user)).to be_blank + expect(find_logs(:unsilence_user)).to be_blank + + expect(find_logs(:removed_suspend_user)).to be_present + expect(find_logs(:removed_unsuspend_user)).to be_present + expect(find_logs(:removed_silence_user)).to be_present + expect(find_logs(:removed_unsilence_user)).to be_present + end end - def find_logs(action) - UserHistory.where(target_user_id: user.id, action: UserHistory.actions[action]) + shared_examples "penalty history deletion not allowed" do + it "prevents clearing of a user's penalty history with a 404 response" do + delete "/admin/users/#{user.id}/penalty_history.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end end - it "allows admins to clear a user's history" do - logger.log_user_suspend(user, "suspend reason") - logger.log_user_unsuspend(user) - logger.log_unsilence_user(user) - logger.log_silence_user(user) + context "when logged in as a moderator" do + before { sign_in(moderator) } - sign_in(admin) - delete "/admin/users/#{user.id}/penalty_history.json" - expect(response.code).to eq("200") - - expect(find_logs(:suspend_user)).to be_blank - expect(find_logs(:unsuspend_user)).to be_blank - expect(find_logs(:silence_user)).to be_blank - expect(find_logs(:unsilence_user)).to be_blank - - expect(find_logs(:removed_suspend_user)).to be_present - expect(find_logs(:removed_unsuspend_user)).to be_present - expect(find_logs(:removed_silence_user)).to be_present - expect(find_logs(:removed_unsilence_user)).to be_present + include_examples "penalty history deletion not allowed" end + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "penalty history deletion not allowed" + end end describe "#delete_posts_batch" do - describe 'when user is is invalid' do - it 'should return the right response' do - put "/admin/users/nothing/delete_posts_batch.json" + shared_examples "post batch deletion possible" do + context 'when user is is invalid' do + it 'should return the right response' do + put "/admin/users/nothing/delete_posts_batch.json" + + expect(response.status).to eq(404) + end + end + + context "when there are user posts" do + before do + post = Fabricate(:post, user: user) + Fabricate(:post, topic: post.topic, user: user) + Fabricate(:post, user: user) + end + + it 'returns how many posts were deleted' do + put "/admin/users/#{user.id}/delete_posts_batch.json" + expect(response.status).to eq(200) + expect(response.parsed_body["posts_deleted"]).to eq(3) + end + end + + context "when there are no posts left to be deleted" do + it "returns correct json" do + put "/admin/users/#{user.id}/delete_posts_batch.json" + expect(response.status).to eq(200) + expect(response.parsed_body["posts_deleted"]).to eq(0) + end + end + end + + context "when logged in as an admin" do + before { sign_in(admin) } + + include_examples "post batch deletion possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "post batch deletion possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents batch deletion of posts with a 404 response" do + put "/admin/users/#{user.id}/delete_posts_batch.json" expect(response.status).to eq(404) - end - end - - context "when there are user posts" do - before do - post = Fabricate(:post, user: user) - Fabricate(:post, topic: post.topic, user: user) - Fabricate(:post, user: user) - end - - it 'returns how many posts were deleted' do - put "/admin/users/#{user.id}/delete_posts_batch.json" - expect(response.status).to eq(200) - expect(response.parsed_body["posts_deleted"]).to eq(3) - end - end - - context "when there are no posts left to be deleted" do - it "returns correct json" do - put "/admin/users/#{user.id}/delete_posts_batch.json" - expect(response.status).to eq(200) - expect(response.parsed_body["posts_deleted"]).to eq(0) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["posts_deleted"]).to be_nil end end end @@ -1351,45 +2002,147 @@ RSpec.describe Admin::UsersController do fab!(:topic) { Fabricate(:topic, user: user) } fab!(:first_post) { Fabricate(:post, topic: topic, user: user) } - it 'should merge source user to target user' do - Jobs.run_immediately! - post "/admin/users/#{user.id}/merge.json", params: { - target_username: target_user.username - } + context "when logged in as an admin" do + before { sign_in(admin) } - expect(response.status).to eq(200) - expect(topic.reload.user_id).to eq(target_user.id) - expect(first_post.reload.user_id).to eq(target_user.id) + it 'should merge source user to target user' do + Jobs.run_immediately! + post "/admin/users/#{user.id}/merge.json", params: { + target_username: target_user.username + } + + expect(response.status).to eq(200) + expect(topic.reload.user_id).to eq(target_user.id) + expect(first_post.reload.user_id).to eq(target_user.id) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it 'fails to merge source user to target user with 403 response' do + Jobs.run_immediately! + post "/admin/users/#{user.id}/merge.json", params: { + target_username: target_user.username + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + + expect(topic.reload.user_id).to eq(user.id) + expect(first_post.reload.user_id).to eq(user.id) + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it 'prevents merging source user to target user with a 404 response' do + Jobs.run_immediately! + post "/admin/users/#{user.id}/merge.json", params: { + target_username: target_user.username + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + expect(topic.reload.user_id).to eq(user.id) + expect(first_post.reload.user_id).to eq(user.id) + end end end describe '#sso_record' do - fab!(:sso_record) { SingleSignOnRecord.create!(user_id: user.id, external_id: '12345', external_email: user.email, last_payload: '') } + fab!(:sso_record) do + SingleSignOnRecord.create!( + user_id: user.id, + external_id: '12345', + external_email: user.email, + last_payload: '' + ) + end - it "deletes the record" do + before do SiteSetting.discourse_connect_url = "https://www.example.com/sso" SiteSetting.enable_discourse_connect = true + end - delete "/admin/users/#{user.id}/sso_record.json" - expect(response.status).to eq(200) - expect(user.single_sign_on_record).to eq(nil) + context "when logged in as an admin" do + before { sign_in(admin) } + + it "deletes the record" do + delete "/admin/users/#{user.id}/sso_record.json" + + expect(response.status).to eq(200) + expect(user.single_sign_on_record).to eq(nil) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + it "prevents deletion of sso record with a 403 response" do + delete "/admin/users/#{user.id}/sso_record.json" + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(user.single_sign_on_record).to be_present + end + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents deletion of sso record with a 404 response" do + delete "/admin/users/#{user.id}/sso_record.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(user.single_sign_on_record).to be_present + end end end describe "#anonymize" do - it "will make the user anonymous" do - put "/admin/users/#{user.id}/anonymize.json" - expect(response.status).to eq(200) - expect(response.parsed_body['username']).to be_present + shared_examples "user anonymization possible" do + it "will make the user anonymous" do + put "/admin/users/#{user.id}/anonymize.json" + expect(response.status).to eq(200) + expect(response.parsed_body['username']).to be_present + end + + it "supports `anonymize_ip`" do + Jobs.run_immediately! + sl = Fabricate(:search_log, user_id: user.id) + put "/admin/users/#{user.id}/anonymize.json?anonymize_ip=127.0.0.2" + expect(response.status).to eq(200) + expect(response.parsed_body['username']).to be_present + expect(sl.reload.ip_address).to eq('127.0.0.2') + end end - it "supports `anonymize_ip`" do - Jobs.run_immediately! - sl = Fabricate(:search_log, user_id: user.id) - put "/admin/users/#{user.id}/anonymize.json?anonymize_ip=127.0.0.2" - expect(response.status).to eq(200) - expect(response.parsed_body['username']).to be_present - expect(sl.reload.ip_address).to eq('127.0.0.2') + context "when logged in as admin" do + before { sign_in(admin) } + + include_examples "user anonymization possible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "user anonymization possible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "prevents anonymizing user with a 404 response" do + put "/admin/users/#{user.id}/anonymize.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body['username']).to be_nil + end end end end diff --git a/spec/requests/admin/versions_controller_spec.rb b/spec/requests/admin/versions_controller_spec.rb index 60515532aa..1d13864190 100644 --- a/spec/requests/admin/versions_controller_spec.rb +++ b/spec/requests/admin/versions_controller_spec.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true RSpec.describe Admin::VersionsController do + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } before do Jobs::VersionCheck.any_instance.stubs(:execute).returns(true) @@ -9,17 +12,8 @@ RSpec.describe Admin::VersionsController do DiscourseUpdates.stubs(:critical_updates_available?).returns(false) end - it "is a subclass of StaffController" do - expect(Admin::VersionsController < Admin::StaffController).to eq(true) - end - - context 'while logged in as an admin' do - fab!(:admin) { Fabricate(:admin) } - before do - sign_in(admin) - end - - describe 'show' do + describe "#show" do + shared_examples "version info accessible" do it 'should return the currently available version' do get "/admin/version_check.json" expect(response.status).to eq(200) @@ -34,5 +28,30 @@ RSpec.describe Admin::VersionsController do expect(json['installed_version']).to eq(Discourse::VERSION::STRING) end end + + context 'when logged in as admin' do + before { sign_in(admin) } + + include_examples "version info accessible" + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "version info accessible" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + it "denies access with a 404 response" do + get "/admin/version_check.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["latest_version"]).to be_nil + expect(response.parsed_body["installed_version"]).to be_nil + end + end end end diff --git a/spec/requests/admin/watched_words_controller_spec.rb b/spec/requests/admin/watched_words_controller_spec.rb index 24a153e11f..80fbd2e32c 100644 --- a/spec/requests/admin/watched_words_controller_spec.rb +++ b/spec/requests/admin/watched_words_controller_spec.rb @@ -6,10 +6,6 @@ RSpec.describe Admin::WatchedWordsController do fab!(:admin) { Fabricate(:admin) } fab!(:user) { Fabricate(:user) } - it "is a subclass of StaffController" do - expect(Admin::WatchedWordsController < Admin::StaffController).to eq(true) - end - describe '#destroy' do fab!(:watched_word) { Fabricate(:watched_word) } diff --git a/spec/requests/admin/web_hooks_controller_spec.rb b/spec/requests/admin/web_hooks_controller_spec.rb index ec1abb9fcf..66713bf57e 100644 --- a/spec/requests/admin/web_hooks_controller_spec.rb +++ b/spec/requests/admin/web_hooks_controller_spec.rb @@ -1,20 +1,15 @@ # frozen_string_literal: true RSpec.describe Admin::WebHooksController do + fab!(:web_hook) { Fabricate(:web_hook) } + fab!(:admin) { Fabricate(:admin) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } - it 'is a subclass of AdminController' do - expect(Admin::WebHooksController < Admin::AdminController).to eq(true) - end + describe '#create' do + context "when logged in as admin" do + before { sign_in(admin) } - context 'while logged in as an admin' do - fab!(:web_hook) { Fabricate(:web_hook) } - fab!(:admin) { Fabricate(:admin) } - - before do - sign_in(admin) - end - - describe '#create' do it 'creates a webhook' do post "/admin/api/web_hooks.json", params: { web_hook: { @@ -58,7 +53,45 @@ RSpec.describe Admin::WebHooksController do end end - describe '#update' do + shared_examples "webhook creation not allowed" do + it "prevents creation with a 404 response" do + post "/admin/api/web_hooks.json", params: { + web_hook: { + payload_url: 'https://meta.discourse.org/', + content_type: 1, + secret: "a_secret_for_webhooks", + wildcard_web_hook: false, + active: true, + verify_certificate: true, + web_hook_event_type_ids: [1], + group_ids: [], + category_ids: [] + } + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(response.parsed_body["web_hook"]).to be_nil + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "webhook creation not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "webhook creation not allowed" + end + end + + describe '#update' do + context "when logged in as admin" do + before { sign_in(admin) } + it "logs webhook update" do put "/admin/api/web_hooks/#{web_hook.id}.json", params: { web_hook: { active: false, payload_url: "https://test.com" } @@ -71,7 +104,37 @@ RSpec.describe Admin::WebHooksController do end end - describe '#destroy' do + shared_examples "webhook update not allowed" do + it "prevents updates with a 404 response" do + current_payload_url = web_hook.payload_url + put "/admin/api/web_hooks/#{web_hook.id}.json", params: { + web_hook: { active: false, payload_url: "https://test.com" } + } + + web_hook.reload + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(web_hook.payload_url).to eq(current_payload_url) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "webhook update not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "webhook update not allowed" + end + end + + describe '#destroy' do + context "when logged in as admin" do + before { sign_in(admin) } + it "logs webhook destroy" do delete "/admin/api/web_hooks/#{web_hook.id}.json", params: { web_hook: { active: false, payload_url: "https://test.com" } @@ -82,7 +145,33 @@ RSpec.describe Admin::WebHooksController do end end - describe '#ping' do + shared_examples "webhook deletion not allowed" do + it "prevents deletion with a 404 response" do + delete "/admin/api/web_hooks/#{web_hook.id}.json" + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + expect(web_hook.reload).to be_present + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "webhook deletion not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "webhook deletion not allowed" + end + end + + describe '#ping' do + context "when logged in as admin" do + before { sign_in(admin) } + it 'enqueues the ping event' do expect do post "/admin/api/web_hooks/#{web_hook.id}/ping.json" @@ -95,62 +184,87 @@ RSpec.describe Admin::WebHooksController do end end - describe '#redeliver_event' do - let!(:web_hook_event) do - WebHookEvent.create!( - web_hook: web_hook, - payload: "abc", - headers: JSON.dump(aa: "1", bb: "2"), + shared_examples "webhook ping not allowed" do + it "fails to enqueue a ping with 404 response" do + expect do + post "/admin/api/web_hooks/#{web_hook.id}/ping.json" + end.not_to change { Jobs::EmitWebHookEvent.jobs.size } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + include_examples "webhook ping not allowed" + end + + context "when logged in as a non-staff user" do + before { sign_in(user) } + + include_examples "webhook ping not allowed" + end + end + + describe '#redeliver_event' do + let!(:web_hook_event) do + WebHookEvent.create!( + web_hook: web_hook, + payload: "abc", + headers: JSON.dump(aa: "1", bb: "2"), + ) + end + + before { sign_in(admin) } + + it 'emits the web hook and updates the response headers and body' do + stub_request(:post, web_hook.payload_url) + .with(body: "abc", headers: { "aa" => 1, "bb" => 2 }) + .to_return( + status: 402, + body: "efg", + headers: { "Content-Type" => "application/json", "yoo" => "man" } ) - end + post "/admin/api/web_hooks/#{web_hook.id}/events/#{web_hook_event.id}/redeliver.json" + expect(response.status).to eq(200) - it 'emits the web hook and updates the response headers and body' do - stub_request(:post, web_hook.payload_url) - .with(body: "abc", headers: { "aa" => 1, "bb" => 2 }) - .to_return( - status: 402, - body: "efg", - headers: { "Content-Type" => "application/json", "yoo" => "man" } - ) + parsed_event = response.parsed_body["web_hook_event"] + expect(parsed_event["id"]).to eq(web_hook_event.id) + expect(parsed_event["status"]).to eq(402) + + expect(JSON.parse(parsed_event["headers"])).to eq({ "aa" => "1", "bb" => "2" }) + expect(parsed_event["payload"]).to eq("abc") + + expect(JSON.parse(parsed_event["response_headers"])).to eq({ "content-type" => "application/json", "yoo" => "man" }) + expect(parsed_event["response_body"]).to eq("efg") + end + + it "doesn't emit the web hook if the payload URL resolves to an internal IP" do + FinalDestination::TestHelper.stub_to_fail do post "/admin/api/web_hooks/#{web_hook.id}/events/#{web_hook_event.id}/redeliver.json" - expect(response.status).to eq(200) - - parsed_event = response.parsed_body["web_hook_event"] - expect(parsed_event["id"]).to eq(web_hook_event.id) - expect(parsed_event["status"]).to eq(402) - - expect(JSON.parse(parsed_event["headers"])).to eq({ "aa" => "1", "bb" => "2" }) - expect(parsed_event["payload"]).to eq("abc") - - expect(JSON.parse(parsed_event["response_headers"])).to eq({ "content-type" => "application/json", "yoo" => "man" }) - expect(parsed_event["response_body"]).to eq("efg") end + expect(response.status).to eq(200) - it "doesn't emit the web hook if the payload URL resolves to an internal IP" do - FinalDestination::TestHelper.stub_to_fail do - post "/admin/api/web_hooks/#{web_hook.id}/events/#{web_hook_event.id}/redeliver.json" - end - expect(response.status).to eq(200) + parsed_event = response.parsed_body["web_hook_event"] + expect(parsed_event["id"]).to eq(web_hook_event.id) + expect(parsed_event["response_headers"]).to eq({ error: I18n.t("webhooks.payload_url.blocked_or_internal") }.to_json) + expect(parsed_event["status"]).to eq(-1) + expect(parsed_event["response_body"]).to eq(nil) + end - parsed_event = response.parsed_body["web_hook_event"] - expect(parsed_event["id"]).to eq(web_hook_event.id) - expect(parsed_event["response_headers"]).to eq({ error: I18n.t("webhooks.payload_url.blocked_or_internal") }.to_json) - expect(parsed_event["status"]).to eq(-1) - expect(parsed_event["response_body"]).to eq(nil) + it "doesn't emit the web hook if the payload URL resolves to a blocked IP" do + FinalDestination::TestHelper.stub_to_fail do + post "/admin/api/web_hooks/#{web_hook.id}/events/#{web_hook_event.id}/redeliver.json" end + expect(response.status).to eq(200) - it "doesn't emit the web hook if the payload URL resolves to a blocked IP" do - FinalDestination::TestHelper.stub_to_fail do - post "/admin/api/web_hooks/#{web_hook.id}/events/#{web_hook_event.id}/redeliver.json" - end - expect(response.status).to eq(200) - - parsed_event = response.parsed_body["web_hook_event"] - expect(parsed_event["id"]).to eq(web_hook_event.id) - expect(parsed_event["response_headers"]).to eq({ error: I18n.t("webhooks.payload_url.blocked_or_internal") }.to_json) - expect(parsed_event["status"]).to eq(-1) - expect(parsed_event["response_body"]).to eq(nil) - end + parsed_event = response.parsed_body["web_hook_event"] + expect(parsed_event["id"]).to eq(web_hook_event.id) + expect(parsed_event["response_headers"]).to eq({ error: I18n.t("webhooks.payload_url.blocked_or_internal") }.to_json) + expect(parsed_event["status"]).to eq(-1) + expect(parsed_event["response_body"]).to eq(nil) end end end diff --git a/spec/requests/categories_controller_spec.rb b/spec/requests/categories_controller_spec.rb index 5b927b7d99..facf7c0cb8 100644 --- a/spec/requests/categories_controller_spec.rb +++ b/spec/requests/categories_controller_spec.rb @@ -235,6 +235,54 @@ RSpec.describe CategoriesController do end end + describe "welcome topic" do + fab!(:category) { Fabricate(:category) } + fab!(:topic1) { Fabricate(:topic, category: category, created_at: 5.days.ago, updated_at: Time.now, bumped_at: Time.now) } + fab!(:topic2) { Fabricate(:topic, category: category, created_at: 2.days.ago, bumped_at: 2.days.ago) } + fab!(:topic3) { Fabricate(:topic, category: category, created_at: 1.day.ago, bumped_at: 1.day.ago) } + fab!(:welcome_topic) { Fabricate(:topic) } + fab!(:post) { Fabricate(:post, topic: welcome_topic) } + + before do + SiteSetting.desktop_category_page_style = "categories_and_latest_topics" + SiteSetting.welcome_topic_id = welcome_topic.id + SiteSetting.editing_grace_period = 1.minute.to_i + SiteSetting.bootstrap_mode_enabled = true + end + + it "is hidden for non-admins" do + get "/categories_and_latest.json" + expect(response.status).to eq(200) + expect(response.parsed_body['topic_list']['topics'].map { |t| t["id"] }).not_to include(welcome_topic.id) + end + + it "is shown to non-admins when there is an edit" do + post.revise(post.user, { raw: "#{post.raw}2" }, revised_at: post.updated_at + 2.minutes) + post.reload + expect(post.version).to eq(2) + + get "/categories_and_latest.json" + expect(response.status).to eq(200) + expect(response.parsed_body['topic_list']['topics'].map { |t| t["id"] }).to include(welcome_topic.id) + end + + it "is hidden to admins" do + sign_in(admin) + + get "/categories_and_latest.json" + expect(response.status).to eq(200) + expect(response.parsed_body['topic_list']['topics'].map { |t| t["id"] }).not_to include(welcome_topic.id) + end + + it "is shown to users when bootstrap mode is disabled" do + SiteSetting.bootstrap_mode_enabled = false + + get "/categories_and_latest.json" + expect(response.status).to eq(200) + expect(response.parsed_body['topic_list']['topics'].map { |t| t["id"] }).to include(welcome_topic.id) + end + end + it 'includes subcategories and topics by default when view is subcategories_with_featured_topics' do SiteSetting.max_category_nesting = 3 subcategory = Fabricate(:category, user: admin, parent_category: category) diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index e450296d87..8410bd66ef 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -912,76 +912,144 @@ RSpec.describe InvitesController do end context 'when user is already logged in' do - fab!(:invite) { Fabricate(:invite, email: 'test@example.com') } - fab!(:user) { Fabricate(:user, email: 'test@example.com') } - fab!(:group) { Fabricate(:group) } - before { sign_in(user) } - it 'redeems the invitation and creates the invite accepted notification' do - put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) - invite.reload - expect(invite.invited_users.first.user).to eq(user) - expect(invite.redeemed?).to be_truthy - expect( - Notification.exists?( - user: invite.invited_by, notification_type: Notification.types[:invitee_accepted] - ) - ).to eq(true) - end + context "for an email invite" do + fab!(:invite) { Fabricate(:invite, email: 'test@example.com') } + fab!(:user) { Fabricate(:user, email: 'test@example.com') } + fab!(:group) { Fabricate(:group) } - it 'redirects to the first topic the user was invited to and creates the topic notification' do - topic = Fabricate(:topic) - TopicInvite.create!(invite: invite, topic: topic) - put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - expect(response.status).to eq(200) - expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) - expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) - end - - it "adds the user to the groups specified on the invite and allows them to access the secure topic" do - group.add_owner(invite.invited_by) - secured_category = Fabricate(:category) - secured_category.permissions = { group.name => :full } - secured_category.save! - - topic = Fabricate(:topic, category: secured_category) - TopicInvite.create!(invite: invite, topic: topic) - InvitedGroup.create!(invite: invite, group: group) - - put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) - expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) - invite.reload - expect(invite.redeemed?).to be_truthy - expect(user.reload.groups).to include(group) - expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) - end - - it "does not try to log in the user automatically" do - expect do + it 'redeems the invitation and creates the invite accepted notification' do put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - end.not_to change { UserAuthToken.count } - expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + invite.reload + expect(invite.invited_users.first.user).to eq(user) + expect(invite.redeemed?).to be_truthy + expect( + Notification.exists?( + user: invite.invited_by, notification_type: Notification.types[:invitee_accepted] + ) + ).to eq(true) + end + + it 'redirects to the first topic the user was invited to and creates the topic notification' do + topic = Fabricate(:topic) + TopicInvite.create!(invite: invite, topic: topic) + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) + end + + it "adds the user to the private topic" do + topic = Fabricate(:private_message_topic) + TopicInvite.create!(invite: invite, topic: topic) + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + expect(TopicAllowedUser.exists?(user: user, topic: topic)).to eq(true) + end + + it "adds the user to the groups specified on the invite and allows them to access the secure topic" do + group.add_owner(invite.invited_by) + secured_category = Fabricate(:category) + secured_category.permissions = { group.name => :full } + secured_category.save! + + topic = Fabricate(:topic, category: secured_category) + TopicInvite.create!(invite: invite, topic: topic) + InvitedGroup.create!(invite: invite, group: group) + + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + invite.reload + expect(invite.redeemed?).to be_truthy + expect(user.reload.groups).to include(group) + expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) + end + + it "does not try to log in the user automatically" do + expect do + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + end.not_to change { UserAuthToken.count } + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + end + + it "errors if the user's email doesn't match the invite email" do + user.update!(email: "blah@test.com") + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(412) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.not_matching_email")) + end + + it "errors if the user's email domain doesn't match the invite domain" do + user.update!(email: "blah@test.com") + invite.update!(email: nil, domain: "example.com") + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(412) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.domain_not_allowed")) + end end - it "errors if the user's email doesn't match the invite email" do - user.update!(email: "blah@test.com") - put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - expect(response.status).to eq(412) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.not_matching_email")) - end + context "for an invite link" do + fab!(:invite) { Fabricate(:invite, email: nil) } + fab!(:user) { Fabricate(:user, email: 'test@example.com') } + fab!(:group) { Fabricate(:group) } - it "errors if the user's email domain doesn't match the invite domain" do - user.update!(email: "blah@test.com") - invite.update!(email: nil, domain: "example.com") - put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } - expect(response.status).to eq(412) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.domain_not_allowed")) + it 'redeems the invitation and creates the invite accepted notification' do + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + invite.reload + expect(invite.invited_users.first.user).to eq(user) + expect(invite.redeemed?).to be_truthy + expect( + Notification.exists?( + user: invite.invited_by, notification_type: Notification.types[:invitee_accepted] + ) + ).to eq(true) + end + + it 'redirects to the first topic the user was invited to and creates the topic notification' do + topic = Fabricate(:topic) + TopicInvite.create!(invite: invite, topic: topic) + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) + end + + it "adds the user to the groups specified on the invite and allows them to access the secure topic" do + group.add_owner(invite.invited_by) + secured_category = Fabricate(:category) + secured_category.permissions = { group.name => :full } + secured_category.save! + + topic = Fabricate(:topic, category: secured_category) + TopicInvite.create!(invite: invite, topic: topic) + InvitedGroup.create!(invite: invite, group: group) + + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + invite.reload + expect(invite.redeemed?).to be_truthy + expect(user.reload.groups).to include(group) + expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1) + end + + it "does not try to log in the user automatically" do + expect do + put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } + end.not_to change { UserAuthToken.count } + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success")) + end end end diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 7b08772566..60ea55c524 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -1003,14 +1003,14 @@ RSpec.describe ListController do expect(parsed["topic_list"]["topics"].first["id"]).to eq(welcome_topic.id) end - it "is shown to admins" do + it "is hidden to admins" do sign_in(admin) get "/latest.json" expect(response.status).to eq(200) parsed = response.parsed_body - expect(parsed["topic_list"]["topics"].length).to eq(2) - expect(parsed["topic_list"]["topics"].first["id"]).to eq(welcome_topic.id) + expect(parsed["topic_list"]["topics"].length).to eq(1) + expect(parsed["topic_list"]["topics"].first["id"]).not_to eq(welcome_topic.id) end it "is shown to users when bootstrap mode is disabled" do diff --git a/spec/requests/qunit_controller_spec.rb b/spec/requests/qunit_controller_spec.rb index 0060ea9d15..df9b9ec491 100644 --- a/spec/requests/qunit_controller_spec.rb +++ b/spec/requests/qunit_controller_spec.rb @@ -98,6 +98,7 @@ RSpec.describe QunitController do expect(response.body).to include("/assets/locales/en.js") expect(response.body).to include("/test-support") expect(response.body).to include("/test-helpers") + expect(response.body).to include("/test-site-settings") expect(response.body).to include("/assets/markdown-it-bundle.js") expect(response.body).to include("/assets/discourse.js") expect(response.body).to include("/assets/admin.js") diff --git a/spec/requests/reviewables_controller_spec.rb b/spec/requests/reviewables_controller_spec.rb index ae84f5e23f..bc2effa1d7 100644 --- a/spec/requests/reviewables_controller_spec.rb +++ b/spec/requests/reviewables_controller_spec.rb @@ -542,13 +542,16 @@ RSpec.describe ReviewablesController do fab!(:reviewable_phony) { Fabricate(:reviewable, type: "ReviewablePhony") } it "passes the added param into the reviewable class' perform method" do - MessageBus.expects(:publish) - .with("/phony-reviewable-test", { args: { - version: reviewable_phony.version, - "fake_id" => "2" } + MessageBus.expects(:publish).with( + "/phony-reviewable-test", + { + args: { + version: reviewable_phony.version, + "fake_id" => "2", + } }, - { user_ids: [1] }) - .once + user_ids: [1] + ).once put "/review/#{reviewable_phony.id}/perform/approve_phony.json?version=#{reviewable_phony.version}", params: { fake_id: 2 } expect(response.status).to eq(200) diff --git a/spec/serializers/admin_user_list_serializer_spec.rb b/spec/serializers/admin_user_list_serializer_spec.rb index 955e726719..2c1e3ebadb 100644 --- a/spec/serializers/admin_user_list_serializer_spec.rb +++ b/spec/serializers/admin_user_list_serializer_spec.rb @@ -31,6 +31,18 @@ RSpec.describe AdminUserListSerializer do end end + context "when backup codes enabled" do + before do + Fabricate(:user_second_factor_backup, user: user) + end + + it "is true" do + json = serializer.as_json + + expect(json[:second_factor_enabled]).to eq(true) + end + end + describe "emails" do fab!(:admin) { Fabricate(:user, admin: true, email: "admin@email.com") } fab!(:moderator) { Fabricate(:user, moderator: true, email: "moderator@email.com") } diff --git a/spec/serializers/current_user_serializer_spec.rb b/spec/serializers/current_user_serializer_spec.rb index 3b6ce30583..b25f9c94e0 100644 --- a/spec/serializers/current_user_serializer_spec.rb +++ b/spec/serializers/current_user_serializer_spec.rb @@ -102,6 +102,16 @@ RSpec.describe CurrentUserSerializer do expect(json[:second_factor_enabled]).to eq(true) end end + + context "when backup codes enabled" do + before do + User.any_instance.stubs(:backup_codes_enabled?).returns(true) + end + + it "is true" do + expect(json[:second_factor_enabled]).to eq(true) + end + end end describe "#groups" do diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 15bef392a7..0b5e2457fa 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -8,17 +8,17 @@ RSpec.describe SiteSerializer do Site.clear_cache end - describe '#onboarding_popup_types' do - it 'is included if enable_onboarding_popups' do - SiteSetting.enable_onboarding_popups = true + describe '#user_tips' do + it 'is included if enable_user_tips' do + SiteSetting.enable_user_tips = true serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json - expect(serialized[:onboarding_popup_types]).to eq(OnboardingPopup.types) + expect(serialized[:user_tips]).to eq(User.user_tips) end - it 'is not included if enable_onboarding_popups is disabled' do + it 'is not included if enable_user_tips is disabled' do serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json - expect(serialized[:onboarding_popup_types]).to eq(nil) + expect(serialized[:user_tips]).to eq(nil) end end diff --git a/spec/serializers/user_serializer_spec.rb b/spec/serializers/user_serializer_spec.rb index 0576545325..beb8637d24 100644 --- a/spec/serializers/user_serializer_spec.rb +++ b/spec/serializers/user_serializer_spec.rb @@ -250,6 +250,16 @@ RSpec.describe UserSerializer do expect(json[:second_factor_enabled]).to eq(true) end end + + context "when backup codes enabled" do + before do + User.any_instance.stubs(:backup_codes_enabled?).returns(true) + end + + it "is true" do + expect(json[:second_factor_enabled]).to eq(true) + end + end end describe "ignored and muted" do diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index 1a28936564..8b5d1b4519 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -1049,10 +1049,12 @@ RSpec.describe UserMerger do it "updates the username" do Jobs::UpdateUsername.any_instance .expects(:execute) - .with(user_id: source_user.id, - old_username: 'alice1', - new_username: 'alice', - avatar_template: target_user.avatar_template) + .with({ + user_id: source_user.id, + old_username: 'alice1', + new_username: 'alice', + avatar_template: target_user.avatar_template + }) .once merge_users! diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb index abcfc5448f..95fd5f39d0 100644 --- a/spec/services/user_updater_spec.rb +++ b/spec/services/user_updater_spec.rb @@ -530,7 +530,7 @@ RSpec.describe UserUpdater do UserUpdater.new(Discourse.system_user, user).update(skip_new_user_tips: true) expect(user.user_option.skip_new_user_tips).to eq(true) - expect(user.user_option.seen_popups).to eq(OnboardingPopup.types.values) + expect(user.user_option.seen_popups).to eq([-1]) UserUpdater.new(Discourse.system_user, user).update(skip_new_user_tips: false) diff --git a/spec/support/uploads_helpers.rb b/spec/support/uploads_helpers.rb index 5724cc0143..e26b7e6e53 100644 --- a/spec/support/uploads_helpers.rb +++ b/spec/support/uploads_helpers.rb @@ -13,13 +13,6 @@ module UploadsHelpers stub_request(:head, "https://#{SiteSetting.s3_upload_bucket}.s3.#{SiteSetting.s3_region}.amazonaws.com/") end - # TODO (martin) Remove this alias once discourse-chat plugin has been - # updated to use secure_uploads instead. - def enable_secure_media - enable_secure_uploads - DiscourseEvent.trigger(:site_setting_changed, :secure_media, false, true) - end - def enable_secure_uploads setup_s3 SiteSetting.secure_uploads = true diff --git a/spec/system/page_objects/pages/admin_settings.rb b/spec/system/page_objects/pages/admin_settings.rb new file mode 100644 index 0000000000..441be53746 --- /dev/null +++ b/spec/system/page_objects/pages/admin_settings.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class AdminSettings < PageObjects::Pages::Base + def visit_filtered_plugin_setting(filter) + visit("/admin/site_settings/category/plugins?filter=#{filter}") + self + end + + def toggle_setting(setting_name, text = '') + setting = find(".admin-detail .row.setting[data-setting='#{setting_name}']") + setting.find('.setting-value span', text: text).click + setting.find('.setting-controls button.ok').click + end + end + end +end diff --git a/spec/system/page_objects/pages/category.rb b/spec/system/page_objects/pages/category.rb new file mode 100644 index 0000000000..9c78a2e6f7 --- /dev/null +++ b/spec/system/page_objects/pages/category.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class Category < PageObjects::Pages::Base + # keeping the various category related features combined for now + + def visit(category) + page.visit("/c/#{category.id}") + self + end + + def visit_settings(category) + page.visit("/c/#{category.slug}/edit/settings") + self + end + + def back_to_category + find('.edit-category-title-bar span', text: 'Back to category').click + self + end + + def save_settings + find('#save-category').click + self + end + + def toggle_setting(setting, text = '') + find(".edit-category-tab .#{setting} label.checkbox-label", text: text).click + self + end + end + end +end diff --git a/spec/system/page_objects/pages/user.rb b/spec/system/page_objects/pages/user.rb new file mode 100644 index 0000000000..82115d893b --- /dev/null +++ b/spec/system/page_objects/pages/user.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class User < PageObjects::Pages::Base + def find(selector) + page.find(".user-content-wrapper #{selector}") + end + + def active_user_primary_navigation + find(".user-primary-navigation li a.active") + end + + def active_user_secondary_navigation + find(".user-secondary-navigation li a.active") + end + end + end +end diff --git a/spec/tasks/uploads_spec.rb b/spec/tasks/uploads_spec.rb index 0b801c28cd..600e4800f0 100644 --- a/spec/tasks/uploads_spec.rb +++ b/spec/tasks/uploads_spec.rb @@ -216,4 +216,31 @@ RSpec.describe "tasks/uploads" do ) end end + + describe "uploads:downsize" do + def invoke_task + capture_stdout do + Rake::Task["uploads:downsize"].invoke + end + end + + before do + STDIN.stubs(:beep) + end + + fab!(:upload) { Fabricate(:image_upload, width: 200, height: 200) } + + it "corrects upload attributes" do + upload.update!(thumbnail_height: 0) + + expect { invoke_task }.to change { upload.reload.thumbnail_height }.to(200) + end + + it "updates attributes of uploads that are over the size limit" do + upload.update!(thumbnail_height: 0) + SiteSetting.max_image_size_kb = 0.001 # 1 byte + + expect { invoke_task }.to change { upload.reload.thumbnail_height }.to(200) + end + end end diff --git a/translator.yml b/translator.yml index ffb695fc0d..4cd5e470f4 100644 --- a/translator.yml +++ b/translator.yml @@ -18,6 +18,11 @@ files: - source_path: public/503.html destination_path: error_pages/503.html + - source_path: plugins/chat/config/locales/client.en.yml + destination_path: plugins/chat/client.yml + - source_path: plugins/chat/config/locales/server.en.yml + destination_path: plugins/chat/server.yml + - source_path: plugins/discourse-details/config/locales/client.en.yml destination_path: plugins/details/client.yml - source_path: plugins/discourse-details/config/locales/server.en.yml diff --git a/yarn.lock b/yarn.lock index 1668b9340f..e49841de9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,11 +10,6 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@arkweid/lefthook@^0.7.7": - version "0.7.7" - resolved "https://registry.yarnpkg.com/@arkweid/lefthook/-/lefthook-0.7.7.tgz#12951b09b955d8054885ffe929aa07a49f39027c" - integrity sha512-Eq30OXKmjxIAIsTtbX2fcF3SNZIXS8yry1u8yty7PQFYRctx04rVlhOJCEB2UmfTh8T2vrOMC9IHHUvvo5zbaQ== - "@babel/code-frame@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" @@ -616,11 +611,6 @@ bl@^4.0.3, bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" -bootbox@3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/bootbox/-/bootbox-3.2.0.tgz#00bf643fc9edefd9ae1e7c648c6b022db4be0aee" - integrity sha1-AL9kP8nt79muHnxkjGsCLbS+Cu4= - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1655,6 +1645,48 @@ language-tags@^1.0.5: dependencies: language-subtag-registry "~0.3.2" +lefthook-darwin-arm64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.2.0.tgz#148e89174a3cc9795bea22ab472649efae0352b8" + integrity sha512-jOyt6HxCRPr2LYkze0vpazp2rqKHsnDz6OVFu8fwIafP92cf9lhu9aAbCSIsbnT0wrHWEYi+t9a5n8Xm05BlFw== + +lefthook-darwin-x64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-darwin-x64/-/lefthook-darwin-x64-1.2.0.tgz#1cbeee6aabc05055bc0ab452f1c9032a1d2077cf" + integrity sha512-G9bFTPvZBanfXS+MIm0hMwUWdkmvL26SCh7d3P6P5bnXNnp+2hx+V1ulDy4iGb9My5P9ORNF3tLLGq+qvXRp0w== + +lefthook-linux-arm64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-linux-arm64/-/lefthook-linux-arm64-1.2.0.tgz#f36886a04e0074d4a52f93418891b42762628fe7" + integrity sha512-aoBXGJtGkzkHDVIYZrSUbG/8+J8gkrtTt1y6KE5/l1uY/xH9JSh2igttYvWaKd9KNbYUjsrEnff+25uzluwOEA== + +lefthook-linux-x64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-linux-x64/-/lefthook-linux-x64-1.2.0.tgz#4d35f5bcdf0a866680008f307137fdffb75aea25" + integrity sha512-FxdtAIQianqVNSpU0Hqj5Qb3ALKR4YjNqk9IUfGbFFnVnp7qnPgtitrh0WkcFu6T7KoCo6YEu3dZl+8y7JP9dQ== + +lefthook-windows-arm64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-windows-arm64/-/lefthook-windows-arm64-1.2.0.tgz#8f4a213ee71e95f4b617811932d99f1fff5df5d2" + integrity sha512-ZhItQksNHvvzTitlnmWmZhnAKhoXjStSJwdGYIMMG8g7GOFZRIG3CmTrtSoWPncpZX+xRlJHd2KeEFYTcB+uzA== + +lefthook-windows-x64@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook-windows-x64/-/lefthook-windows-x64-1.2.0.tgz#473048cdf42c761b031912196e3b840c3b263d68" + integrity sha512-UZQb/+o2AfcaMPzP8Y8JmWirEC5inMTzZPStAuG8pBe8Lv6IhC7EFdfDSkn2bdBvxWooBEFKf/a5EdlQ/f5/bQ== + +lefthook@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/lefthook/-/lefthook-1.2.0.tgz#c31e30688df10f205b223f6d612ba14ffe118885" + integrity sha512-FLw0YDTDJGleWDLSVVAMJtGIQ0Kh5wX4JJJHTNQtjzT0CZOAXSBjTP87loubg6S2QOuvmspvhSc62NkW6Jn82w== + optionalDependencies: + lefthook-darwin-arm64 "1.2.0" + lefthook-darwin-x64 "1.2.0" + lefthook-linux-arm64 "1.2.0" + lefthook-linux-x64 "1.2.0" + lefthook-windows-arm64 "1.2.0" + lefthook-windows-x64 "1.2.0" + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -2222,7 +2254,7 @@ squoosh@discourse/squoosh#dc9649d: version "2.0.0" resolved "https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d0a4d396d1251c22291b17d99f1716da44" dependencies: - wasm-feature-detect "^1.2.9" + wasm-feature-detect "^1.2.11" stream-shift@^1.0.0: version "1.0.1" @@ -2440,10 +2472,10 @@ v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -wasm-feature-detect@^1.2.9: - version "1.2.11" - resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.2.11.tgz#e21992fd1f1d41a47490e392a5893cb39d81e29e" - integrity sha512-HUqwaodrQGaZgz1lZaNioIkog9tkeEJjrM3eq4aUL04whXOVDRc/o2EGb/8kV0QX411iAYWEqq7fMBmJ6dKS6w== +wasm-feature-detect@^1.2.11: + version "1.3.0" + resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.3.0.tgz#fb3fc5dd4a1ba950a429be843daad67fe048bc42" + integrity sha512-w9datO3OReMouWgKOelvu1CozmLK/VbkXOtlzNTanBJpR0uBHyUwS3EYdXf5vBPoHKYS0lpuYo91rpqMNIZM9g== wcwidth@^1.0.1: version "1.0.1"