diff --git a/.eslintignore b/.eslintignore index 4447ad67265..885e2cf3c7c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,4 @@ node_modules/ spec/ dist/ tmp/ +documentation/ diff --git a/.gitattributes b/.gitattributes index 546b134a0ce..2ba82579ac6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,3 +27,7 @@ *.PDF diff=astextplain *.rtf diff=astextplain *.RTF diff=astextplain + +# Ember App +*.gjs linguist-language=js linguist-detectable +*.gts linguist-language=ts linguist-detectable diff --git a/.github/labeler.yml b/.github/labeler.yml index 90492aebc06..a7d0c1c2661 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,2 +1,2 @@ chat: -- plugins/chat/**/* + - plugins/chat/**/* diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 057208eda32..b308d844199 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,14 +1,17 @@ -name: "Pull Request Labeler" +name: Pull Request Labeler + on: -- pull_request_target + - pull_request_target + +permissions: + contents: read + pull-requests: write jobs: triage: - permissions: - contents: read - pull-requests: write runs-on: ubuntu-latest + steps: - - uses: actions/labeler@v4 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + - uses: actions/labeler@v4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml index de665ecf290..40e53ad0c4a 100644 --- a/.github/workflows/licenses.yml +++ b/.github/workflows/licenses.yml @@ -15,7 +15,7 @@ permissions: jobs: build: - if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" + if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' name: run runs-on: ubuntu-latest container: discourse/discourse_test:slim @@ -36,8 +36,7 @@ jobs: with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-gem- + restore-keys: ${{ runner.os }}-gem- - name: Setup gems run: | @@ -61,8 +60,7 @@ jobs: with: path: ${{ steps.yarn-cache-dir.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + restore-keys: ${{ runner.os }}-yarn- - name: Check RubyGems Licenses if: ${{ !cancelled() }} diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index b2125eea167..0ded454e9e6 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -15,13 +15,16 @@ permissions: jobs: build: - if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" + if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' name: run runs-on: ubuntu-latest container: discourse/discourse_test:slim timeout-minutes: 30 steps: + - name: Set working directory owner + run: chown root:root . + - uses: actions/checkout@v3 with: fetch-depth: 1 @@ -36,8 +39,7 @@ jobs: with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-gem- + restore-keys: ${{ runner.os }}-gem- - name: Setup gems run: | @@ -58,11 +60,10 @@ jobs: with: path: ${{ steps.yarn-cache-dir.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + restore-keys: ${{ runner.os }}-yarn- - name: Yarn install - run: yarn install + run: yarn install --frozen-lockfile - name: Rubocop if: ${{ !cancelled() }} @@ -70,33 +71,27 @@ jobs: - name: syntax_tree if: ${{ !cancelled() }} - run: bundle exec stree check Gemfile $(git ls-files '*.rb') $(git ls-files '*.rake') + run: | + set -E + bundle exec stree check Gemfile $(git ls-files '*.rb') $(git ls-files '*.rake') - name: ESLint (core) if: ${{ !cancelled() }} - run: yarn eslint app/assets/javascripts + run: yarn lint:js - name: ESLint (core plugins) if: ${{ !cancelled() }} - run: yarn eslint plugins + run: yarn lint:js-plugins - name: Prettier if: ${{ !cancelled() }} run: | yarn prettier -v - yarn pprettier --list-different \ - "app/assets/stylesheets/**/*.scss" \ - "app/assets/javascripts/**/*.js" \ - "plugins/**/assets/stylesheets/**/*.scss" \ - "plugins/**/assets/javascripts/**/*.js" + yarn lint:prettier - name: Ember template lint if: ${{ !cancelled() }} - run: | - yarn ember-template-lint \ - --no-error-on-unmatched-pattern \ - "app/assets/javascripts/**/*.hbs" \ - "plugins/**/assets/javascripts/**/*.hbs" + run: yarn lint:hbs - name: English locale lint (core) if: ${{ !cancelled() }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2598bcac746..d8c991f8809 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,20 +17,19 @@ permissions: jobs: build: - if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" - name: ${{ matrix.target }} ${{ matrix.build_type }} + if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' + name: ${{ matrix.target }} ${{ matrix.build_type }} ${{ matrix.ruby }} runs-on: ${{ (matrix.build_type == 'annotations') && 'ubuntu-latest' || 'ubuntu-20.04-8core' }} - container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }} + container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }}${{ (matrix.ruby == '3.1') && '-ruby-3.1.0' || '' }} timeout-minutes: 20 env: DISCOURSE_HOSTNAME: www.example.com - RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072 RAILS_ENV: test PGUSER: discourse PGPASSWORD: discourse - USES_PARALLEL_DATABASES: ${{ matrix.build_type == 'backend' }} - CAPBYARA_DEFAULT_MAX_WAIT_TIME: 4 + USES_PARALLEL_DATABASES: ${{ matrix.build_type == 'backend' || matrix.build_type == 'system' }} + CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10 strategy: fail-fast: false @@ -38,6 +37,7 @@ jobs: matrix: build_type: [backend, frontend, system, annotations] target: [core, plugins] + ruby: ["3.2"] exclude: - build_type: annotations target: plugins @@ -71,9 +71,8 @@ jobs: uses: actions/cache@v3 with: path: vendor/bundle - key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-gem- + key: ${{ runner.os }}-${{ matrix.ruby }}-gem-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: ${{ runner.os }}-${{ matrix.ruby }}-gem- - name: Setup gems run: | @@ -94,11 +93,10 @@ jobs: with: path: ${{ steps.yarn-cache-dir.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + restore-keys: ${{ runner.os }}-yarn- - name: Yarn install - run: yarn install + run: yarn install --frozen-lockfile - name: Checkout official plugins if: matrix.target == 'plugins' @@ -113,10 +111,9 @@ jobs: id: app-cache with: path: tmp/app-cache - key: >- # postgres version, hash of migrations, "parallel?" + key: >- ${{ runner.os }}- ${{ hashFiles('.github/workflows/tests.yml') }}- - ${{ matrix.postgres }}- ${{ hashFiles('db/**/*', 'plugins/**/db/**/*') }}- ${{ env.USES_PARALLEL_DATABASES }} @@ -153,18 +150,50 @@ jobs: - name: Fetch turbo_rspec_runtime.log cache uses: actions/cache@v3 id: test-runtime-cache - if: matrix.build_type == 'backend' && matrix.target == 'core' + if: matrix.build_type == 'backend' || matrix.build_type == 'system' with: path: tmp/turbo_rspec_runtime.log - key: rspec-runtime-backend-core + key: rspec-runtime-${{ matrix.build_type }}-${{ matrix.target }}-${{ github.run_id }} + restore-keys: rspec-runtime-${{ matrix.build_type }}-${{ matrix.target }}- + + - name: Check Zeitwerk eager_load + if: matrix.build_type == 'backend' + env: + LOAD_PLUGINS: ${{ (matrix.target == 'plugins') && '1' || '0' }} + run: | + if ! bin/rails zeitwerk:check --trace; then + echo + echo "---------------------------------------------" + echo + echo "::error::'bin/rails zeitwerk:check' failed - the app will fail to boot with 'eager_load=true' (e.g. in production)." + echo "To reproduce locally, run 'bin/rails zeitwerk:check'." + echo "Alternatively, you can run your local server/tests with the 'DISCOURSE_ZEITWERK_EAGER_LOAD=1' environment variable." + echo + exit 1 + fi + + - name: Check Zeitwerk reloading + if: matrix.build_type == 'backend' + env: + LOAD_PLUGINS: ${{ (matrix.target == 'plugins') && '1' || '0' }} + run: | + if ! bin/rails runner 'Rails.application.reloader.reload!'; then + echo + echo "---------------------------------------------" + echo + echo "::error::Zeitwerk reload failed - the app will not be able to reload properly in development." + echo "To reproduce locally, run \`bin/rails runner 'Rails.application.reloader.reload!'\`." + echo + exit 1 + fi - name: Core RSpec if: matrix.build_type == 'backend' && matrix.target == 'core' - run: bin/turbo_rspec --verbose + run: bin/turbo_rspec --use-runtime-info --verbose --format documentation - name: Plugin RSpec if: matrix.build_type == 'backend' && matrix.target == 'plugins' - run: bin/rake plugin:turbo_spec + run: bin/rake plugin:turbo_spec['*','--verbose --format documentation --use-runtime-info'] - name: Plugin QUnit if: matrix.build_type == 'frontend' && matrix.target == 'plugins' @@ -181,11 +210,12 @@ jobs: - name: Core System Tests if: matrix.build_type == 'system' && matrix.target == 'core' - run: bin/rspec spec/system --format documentation --profile + run: RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation spec/system - name: Plugin System Tests if: matrix.build_type == 'system' && matrix.target == 'plugins' - run: LOAD_PLUGINS=1 bin/rspec plugins/*/spec/system --format documentation --profile + run: LOAD_PLUGINS=1 RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system + timeout-minutes: 30 - name: Upload failed system test screenshots uses: actions/upload-artifact@v3 @@ -212,7 +242,7 @@ jobs: timeout-minutes: 30 core_frontend_tests: - if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" + if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' name: core frontend (${{ matrix.browser }}) runs-on: ubuntu-20.04-8core container: @@ -250,26 +280,25 @@ jobs: with: path: ${{ steps.yarn-cache-dir.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + restore-keys: ${{ runner.os }}-yarn- - name: Yarn install working-directory: ./app/assets/javascripts/discourse - run: yarn install + run: yarn install --frozen-lockfile - name: Ember Build working-directory: ./app/assets/javascripts/discourse run: | mkdir /tmp/emberbuild - yarn ember build --environment=test -o /tmp/emberbuild + yarn ember build --environment=test -o /tmp/emberbuild - name: Core QUnit working-directory: ./app/assets/javascripts/discourse - run: yarn ember exam --path /tmp/emberbuild --load-balance --parallel=5 --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random + run: yarn ember exam --path /tmp/emberbuild --load-balance --parallel=5 --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random timeout-minutes: 15 - uses: actions/upload-artifact@v3 if: ${{ always() }} with: - name: ember-exam-execution-${{matrix.browser}} + name: ember-exam-execution-${{ matrix.browser }} path: ./app/assets/javascripts/discourse/test-execution-*.json diff --git a/.gitignore b/.gitignore index ad4ff4d9294..6b335523351 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,7 @@ !/plugins/discourse-local-dates !/plugins/discourse-narrative-bot !/plugins/discourse-presence -!/plugins/lazy-yt/ +!/plugins/discourse-lazy-videos/ !/plugins/chat/ !/plugins/poll/ !/plugins/styleguide @@ -52,12 +52,20 @@ # We provide a .sample but people can use newer versions if they want to .ruby-version +.ruby-gemset + +# Likewise, there is a .vscode-sample for VSCode config +.vscode # Front-end dist node_modules yarn-error.log +# Linting artifacts +.eslintcache +/lint-progress/ + # Auto-generated plugin JS assets /app/assets/javascripts/plugins/* diff --git a/.jsdoc b/.jsdoc new file mode 100644 index 00000000000..06d1cf60830 --- /dev/null +++ b/.jsdoc @@ -0,0 +1,21 @@ +// jsdoc doesn't accept paths starting with _ (which is the case on github runners) +// so we need to alter the default config +{ + "source": { + "excludePattern": "" + }, + "templates": { + "default": { + "includeDate": false + } + }, + "opts": { + "template": "./node_modules/tidy-jsdoc", + "prism-theme": "prism-custom", + "encoding": "utf8", + "recurse": true + }, + "metadata": { + "title": "Discourse" + } +} diff --git a/.licensed.yml b/.licensed.yml index 044e732fad2..75e581cdb55 100644 --- a/.licensed.yml +++ b/.licensed.yml @@ -51,6 +51,7 @@ reviewed: - net-imap # Ruby (bundled gem) - net-pop # Ruby (bundled gem) - net-smtp # Ruby (bundled gem) + - nio4r # MIT + BSD - omniauth # MIT - pg # Ruby - r2 # Apache-2.0 (Twitter) diff --git a/.licensee.json b/.licensee.json index 6fe0d279c89..93d376127f5 100644 --- a/.licensee.json +++ b/.licensee.json @@ -11,7 +11,9 @@ "packages": { "@fortawesome/fontawesome-free": "*", "ember-template-lint-plugin-discourse": "*", - "squoosh": "2.0.0" + "spawn-command": "0.0.2", + "squoosh": "2.0.0", + "taffydb": "2.6.2" }, "corrections": true -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore index 93b35f929e2..c1caccc0683 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ plugins/**/assets/stylesheets/vendor/ plugins/**/assets/javascripts/vendor/ plugins/**/config/locales/**/*.yml plugins/**/config/*.yml +documentation/ package.json config/locales/**/*.yml !config/locales/**/*.en*.yml diff --git a/.prettierrc b/.prettierrc index 0967ef424bc..8a1423e9391 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1 +1,17 @@ -{} +{ + "plugins": ["prettier-plugin-ember-template-tag"], + "overrides": [ + { + "files": "*.gjs", + "options": { + "parser": "ember-template-tag" + } + }, + { + "files": "*.gts", + "options": { + "parser": "ember-template-tag" + } + } + ] +} diff --git a/.rubocop.yml b/.rubocop.yml index 4d4ad6d8d80..68abba36ad9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,3 +7,7 @@ Discourse/NoAddReferenceOrAliasesActiveRecordMigration: Discourse/NoResetColumnInformationInMigrations: Enabled: true + +Lint/Debugger: + Exclude: + - script/**/* diff --git a/.ruby-version.sample b/.ruby-version.sample index ff365e06b95..e4604e3afd0 100644 --- a/.ruby-version.sample +++ b/.ruby-version.sample @@ -1 +1 @@ -3.1.3 +3.2.1 diff --git a/.template-lintrc.js b/.template-lintrc.js index a5c4998a0ce..2c70a9c5068 100644 --- a/.template-lintrc.js +++ b/.template-lintrc.js @@ -5,6 +5,7 @@ module.exports = { rules: { "no-action-modifiers": true, "no-args-paths": true, + "no-array-prototype-extensions": false, "no-attrs-in-components": true, "no-capital-arguments": false, // TODO: we extensively use `args` argument name "no-curly-component-invocation": { @@ -15,12 +16,15 @@ module.exports = { "directory-item-value", "directory-table-header-title", "loading-spinner", - "mobile-directory-item-label", + "directory-item-label", ], }, "no-implicit-this": { allow: ["loading-spinner"], }, + "no-obscure-array-access": false, + "require-mandatory-role-attributes": false, + "require-media-caption": false, // Begin prettier compatibility "eol-last": false, "self-closing-void-elements": false, diff --git a/.vscode-sample/extensions.json b/.vscode-sample/extensions.json new file mode 100644 index 00000000000..86c6cd8f91c --- /dev/null +++ b/.vscode-sample/extensions.json @@ -0,0 +1,12 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "esbenp.prettier-vscode", + "typed-ember.glint-vscode", + "chiragpat.vscode-glimmer", + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode-sample/settings.json b/.vscode-sample/settings.json new file mode 100644 index 00000000000..5650eb57a33 --- /dev/null +++ b/.vscode-sample/settings.json @@ -0,0 +1,12 @@ +{ + "[gjs]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[gts]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "eslint.validate": [ + "glimmer-js", + "glimmer-ts" + ] +} diff --git a/Gemfile b/Gemfile index f42b8d9cc3d..647aefea8ad 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ else # this allows us to include the bits of rails we use without pieces we do not. # # To issue a rails update bump the version number here - rails_version = "7.0.4.3" + rails_version = "7.0.5.1" gem "actionmailer", rails_version gem "actionpack", rails_version gem "actionview", rails_version @@ -32,8 +32,8 @@ end gem "json" # TODO: At the moment Discourse does not work with Sprockets 4, we would need to correct internals -# This is a desired upgrade we should get to. -gem "sprockets", "3.7.2" +# We intend to drop sprockets rather than upgrade to 4.x +gem "sprockets", git: "https://github.com/rails/sprockets", branch: "3.x" # this will eventually be added to rails, # allows us to precompile all our templates in the unicorn master @@ -41,7 +41,7 @@ gem "actionview_precompiler", require: false gem "discourse-seed-fu" -gem "mail", git: "https://github.com/discourse/mail.git" +gem "mail" gem "mini_mime" gem "mini_suffix" @@ -71,8 +71,6 @@ gem "rails_multisite" gem "fast_xs", platform: :ruby -gem "xorcist" - gem "fastimage" gem "aws-sdk-s3", require: false @@ -98,14 +96,13 @@ gem "omniauth-oauth2", require: false gem "omniauth-google-oauth2" -# pending: https://github.com/ohler55/oj/issues/789 -gem "oj", "3.13.14" +gem "oj" gem "pg" gem "mini_sql" gem "pry-rails", require: false gem "pry-byebug", require: false -gem "r2", require: false +gem "rtlcss", require: false gem "rake" gem "thor", require: false @@ -147,6 +144,7 @@ group :test do gem "selenium-webdriver", require: false gem "test-prof" gem "webdrivers", require: false + gem "rails-dom-testing", require: false end group :test, :development do @@ -160,7 +158,7 @@ group :test, :development do gem "rspec-rails" - gem "shoulda-matchers", require: false + gem "shoulda-matchers", require: false, github: "thoughtbot/shoulda-matchers" gem "rspec-html-matchers" gem "byebug", require: ENV["RM_INFO"].nil?, platform: :mri gem "rubocop-discourse", require: false @@ -180,6 +178,7 @@ group :development do gem "better_errors", platform: :mri, require: !!ENV["BETTER_ERRORS"] gem "binding_of_caller" gem "yaml-lint" + gem "yard" end if ENV["ALLOW_DEV_POPULATE"] == "1" @@ -229,10 +228,9 @@ gem "logstash-event", require: false gem "logstash-logger", require: false gem "logster" -# NOTE: later versions of sassc are causing a segfault, possibly dependent on processer architecture -# and until resolved should be locked at 2.0.1 -gem "sassc", "2.0.1", require: false -gem "sassc-rails" +# These are forks of sassc and sassc-rails with dart-sass support +gem "dartsass-ruby" +gem "dartsass-sprockets" gem "rotp", require: false @@ -274,8 +272,7 @@ gem "faraday-retry" # https://github.com/ruby/net-imap/issues/16#issuecomment-803086765 gem "net-http" -# workaround for prometheus-client -gem "webrick", require: false - # Workaround until Ruby ships with cgi version 0.3.6 or higher. gem "cgi", ">= 0.3.6", require: false + +gem "tzinfo-data" diff --git a/Gemfile.lock b/Gemfile.lock index 1d24db2da52..60f09c27d00 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,32 +1,41 @@ GIT - remote: https://github.com/discourse/mail.git - revision: 5b700fc95ee66378e0cf2559abc73c8bc3062a4b + remote: https://github.com/rails/sprockets + revision: f4d3dae71ef29c44b75a49cfbf8032cce07b423a + branch: 3.x specs: - mail (2.8.0.edge) - mini_mime (>= 0.1.1) + sprockets (3.7.2) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + +GIT + remote: https://github.com/thoughtbot/shoulda-matchers.git + revision: 783a90554053002017510285bc736099b2749c22 + specs: + shoulda-matchers (5.3.0) + activesupport (>= 5.2.0) GEM remote: https://rubygems.org/ specs: - actionmailer (7.0.4.3) - actionpack (= 7.0.4.3) - actionview (= 7.0.4.3) - activejob (= 7.0.4.3) - activesupport (= 7.0.4.3) + actionmailer (7.0.5.1) + actionpack (= 7.0.5.1) + actionview (= 7.0.5.1) + activejob (= 7.0.5.1) + activesupport (= 7.0.5.1) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.4.3) - actionview (= 7.0.4.3) - activesupport (= 7.0.4.3) - rack (~> 2.0, >= 2.2.0) + actionpack (7.0.5.1) + actionview (= 7.0.5.1) + activesupport (= 7.0.5.1) + rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.4.3) - activesupport (= 7.0.4.3) + actionview (7.0.5.1) + activesupport (= 7.0.5.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -35,20 +44,20 @@ GEM actionview (>= 6.0.a) active_model_serializers (0.8.4) activemodel (>= 3.0) - activejob (7.0.4.3) - activesupport (= 7.0.4.3) + activejob (7.0.5.1) + activesupport (= 7.0.5.1) globalid (>= 0.3.6) - activemodel (7.0.4.3) - activesupport (= 7.0.4.3) - activerecord (7.0.4.3) - activemodel (= 7.0.4.3) - activesupport (= 7.0.4.3) - activesupport (7.0.4.3) + activemodel (7.0.5.1) + activesupport (= 7.0.5.1) + activerecord (7.0.5.1) + activemodel (= 7.0.5.1) + activesupport (= 7.0.5.1) + activesupport (7.0.5.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - addressable (2.8.1) + addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) @@ -73,20 +82,20 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.5.0) aws-eventstream (~> 1, >= 1.0.2) - better_errors (2.9.1) - coderay (>= 1.0.0) + better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) + rouge (>= 1.0.0) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) - bootsnap (1.15.0) + bootsnap (1.16.0) msgpack (~> 1.2) builder (3.2.4) bullet (7.0.7) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) - capybara (3.38.0) + capybara (3.39.2) addressable matrix mini_mime (>= 0.1.3) @@ -101,8 +110,8 @@ GEM chunky_png (1.4.0) coderay (1.1.3) colored2 (3.1.2) - concurrent-ruby (1.1.10) - connection_pool (2.3.0) + concurrent-ruby (1.2.2) + connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -110,8 +119,17 @@ GEM crack (0.4.5) rexml crass (1.0.6) - css_parser (1.13.0) + css_parser (1.14.0) addressable + dartsass-ruby (3.0.1) + sass-embedded (~> 1.54) + dartsass-sprockets (3.0.0) + dartsass-ruby (~> 3.0) + railties (>= 4.0.0) + sprockets (> 3.0) + sprockets-rails + tilt + date (3.3.3) debug_inspector (1.1.0) diff-lcs (1.5.0) diffy (3.4.2) @@ -124,31 +142,34 @@ GEM faker (~> 2.16) literate_randomizer docile (1.4.0) - ecma-re-validator (0.4.0) - regexp_parser (~> 2.2) email_reply_trimmer (0.1.13) - erubi (1.11.0) - excon (0.96.0) + erubi (1.12.0) + excon (0.100.0) execjs (2.8.1) - exifr (1.3.10) + exifr (1.4.0) fabrication (2.30.0) faker (2.23.0) i18n (>= 1.8.11, < 2) fakeweb (1.3.0) - faraday (2.7.2) + faraday (2.7.10) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - faraday-retry (2.0.0) + faraday-retry (2.2.0) faraday (~> 2.0) fast_blank (1.0.1) fast_xs (0.8.0) - fastimage (2.2.6) + fastimage (2.2.7) ffi (1.15.5) fspath (3.1.2) gc_tracer (1.5.1) - globalid (1.0.1) + globalid (1.1.0) activesupport (>= 5.0) + google-protobuf (3.23.4) + google-protobuf (3.23.4-aarch64-linux) + google-protobuf (3.23.4-arm64-darwin) + google-protobuf (3.23.4-x86_64-darwin) + google-protobuf (3.23.4-x86_64-linux) guess_html_encoding (0.0.11) hana (1.3.7) hashdiff (1.0.1) @@ -157,38 +178,37 @@ GEM hkdf (1.0.0) htmlentities (4.3.4) http_accept_language (2.1.1) - i18n (1.12.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) - image_optim (0.31.2) + image_optim (0.31.3) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) image_size (>= 1.5, < 4) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - image_size (3.2.0) + image_size (3.3.0) in_threads (1.6.0) jmespath (1.6.2) json (2.6.3) json-schema (3.0.0) addressable (>= 2.8) - json_schemer (0.2.23) - ecma-re-validator (~> 0.3) + json_schemer (1.0.3) hana (~> 1.3) regexp_parser (~> 2.0) - uri_template (~> 0.7) - jwt (2.6.0) + simpleidn (~> 0.2) + jwt (2.7.1) kgio (2.11.4) - libv8-node (16.10.0.0) - libv8-node (16.10.0.0-aarch64-linux) - libv8-node (16.10.0.0-arm64-darwin) - libv8-node (16.10.0.0-x86_64-darwin) - libv8-node (16.10.0.0-x86_64-darwin-19) - libv8-node (16.10.0.0-x86_64-linux) + language_server-protocol (3.17.0.3) + libv8-node (18.16.0.0) + libv8-node (18.16.0.0-aarch64-linux) + libv8-node (18.16.0.0-arm64-darwin) + libv8-node (18.16.0.0-x86_64-darwin) + libv8-node (18.16.0.0-x86_64-linux) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) literate_randomizer (0.4.0) - lograge (0.12.0) + lograge (0.13.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) @@ -196,37 +216,43 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.11.3) - loofah (2.19.1) + logster (2.12.2) + loofah (2.21.3) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) lru_redux (1.1.0) lz4-ruby (0.3.3) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp matrix (0.4.2) maxminddb (0.1.22) memory_profiler (1.0.1) - message_bus (4.3.1) + message_bus (4.3.7) rack (>= 1.1.3) method_source (1.0.0) mini_mime (1.1.2) - mini_portile2 (2.8.1) - mini_racer (0.6.3) - libv8-node (~> 16.10.0.0) - mini_scheduler (0.15.0) + mini_portile2 (2.8.4) + mini_racer (0.8.0) + libv8-node (~> 18.16.0.0) + mini_scheduler (0.16.0) sidekiq (>= 4.2.3, < 7.0) mini_sql (1.4.0) mini_suffix (0.3.3) ffi (~> 1.9) - minitest (5.17.0) - mocha (2.0.2) + minitest (5.19.0) + mocha (2.1.0) ruby2_keywords (>= 0.0.5) - msgpack (1.6.0) + msgpack (1.7.2) multi_json (1.15.0) multi_xml (0.6.0) mustache (1.1.1) net-http (0.3.2) uri - net-imap (0.3.1) + net-imap (0.3.7) + date net-protocol net-pop (0.1.2) net-protocol @@ -234,17 +260,17 @@ GEM timeout net-smtp (0.3.3) net-protocol - nio4r (2.5.8) - nokogiri (1.14.2) - mini_portile2 (~> 2.8.0) + nio4r (2.5.9) + nokogiri (1.15.3) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.14.2-aarch64-linux) + nokogiri (1.15.3-aarch64-linux) racc (~> 1.4) - nokogiri (1.14.2-arm64-darwin) + nokogiri (1.15.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.2-x86_64-darwin) + nokogiri (1.15.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.2-x86_64-linux) + nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -258,7 +284,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 4) - oj (3.13.14) + oj (3.15.1) omniauth (1.9.2) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) @@ -281,17 +307,18 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - openssl (3.0.2) - openssl-signature_algorithm (1.2.1) - openssl (> 2.0, < 3.1) - optimist (3.0.1) - parallel (1.22.1) - parallel_tests (4.0.0) + openssl (3.1.0) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) + optimist (3.1.0) + parallel (1.23.0) + parallel_tests (4.2.1) parallel - parser (3.2.0.0) + parser (3.2.2.3) ast (~> 2.4.1) - pg (1.4.5) - prettier_print (1.2.0) + racc + pg (1.4.6) + prettier_print (1.2.1) progress (3.6.0) pry (0.14.2) coderay (~> 1.1) @@ -301,39 +328,40 @@ GEM pry (>= 0.13, < 0.15) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (5.0.1) - puma (6.0.2) + public_suffix (5.0.3) + puma (6.3.0) nio4r (~> 2.0) - r2 (0.2.7) - racc (1.6.2) - rack (2.2.5) - rack-mini-profiler (3.0.0) + racc (1.7.1) + rack (2.2.8) + rack-mini-profiler (3.1.0) rack (>= 1.2.0) - rack-protection (3.0.5) + rack-protection (3.0.6) rack - rack-test (2.0.2) + rack-test (2.1.0) rack (>= 1.3) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.1.1) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - rails_failover (0.8.1) - activerecord (> 6.0, < 7.1) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails_failover (2.0.1) + activerecord (>= 6.1, <= 7.1) concurrent-ruby - railties (> 6.0, < 7.1) - rails_multisite (4.0.1) - activerecord (> 5.0, < 7.1) - railties (> 5.0, < 7.1) - railties (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) + railties (>= 6.1, <= 7.1) + rails_multisite (5.0.0) + activerecord (>= 6.0) + railties (>= 6.0) + railties (7.0.5.1) + actionpack (= 7.0.5.1) + activesupport (= 7.0.5.1) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) - raindrops (0.20.0) + raindrops (0.20.1) rake (13.0.6) rb-fsevent (0.11.2) rb-inotify (0.10.1) @@ -343,16 +371,17 @@ GEM msgpack (>= 0.4.3) optimist (>= 3.0.0) rchardet (1.8.0) - redis (4.8.0) - redis-namespace (1.9.0) + redis (4.8.1) + redis-namespace (1.11.0) redis (>= 4) - regexp_parser (2.6.1) + regexp_parser (2.8.1) request_store (1.5.1) rack (>= 1.4) - rexml (3.2.5) + rexml (3.2.6) rinku (2.0.6) rotp (6.2.2) - rqrcode (2.1.2) + rouge (4.1.3) + rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) @@ -360,76 +389,85 @@ GEM rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) rspec-mocks (~> 3.12.0) - rspec-core (3.12.0) + rspec-core (3.12.2) rspec-support (~> 3.12.0) - rspec-expectations (3.12.2) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-html-matchers (0.10.0) nokogiri (~> 1) rspec (>= 3.0.0.a) - rspec-mocks (3.12.2) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-rails (6.0.1) + rspec-rails (6.0.3) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.11) - rspec-expectations (~> 3.11) - rspec-mocks (~> 3.11) - rspec-support (~> 3.11) - rspec-support (3.12.0) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-support (3.12.1) rss (0.2.9) rexml - rswag-specs (2.8.0) + rswag-specs (2.10.1) activesupport (>= 3.1, < 7.1) json-schema (>= 2.2, < 4.0) railties (>= 3.1, < 7.1) rspec-core (>= 2.14) - rubocop (1.43.0) + rtlcss (0.2.1) + mini_racer (>= 0.6.3) + rubocop (1.55.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.24.1, < 2.0) + rubocop-ast (>= 1.28.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.24.1) - parser (>= 3.1.1.0) - rubocop-discourse (3.0.3) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-capybara (2.18.0) + rubocop (~> 1.41) + rubocop-discourse (3.3.0) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.16.0) + rubocop-factory_bot (2.23.1) rubocop (~> 1.33) - ruby-prof (1.4.5) - ruby-progressbar (1.11.0) + rubocop-rspec (2.23.0) + rubocop (~> 1.33) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + ruby-prof (1.6.3) + ruby-progressbar (1.13.0) ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) - sanitize (6.0.0) + sanitize (6.0.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - sassc (2.0.1) - ffi (~> 1.9) - rake - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt - selenium-webdriver (4.7.1) + sass-embedded (1.64.1) + google-protobuf (~> 3.23) + rake (>= 13.0.0) + sass-embedded (1.64.1-aarch64-linux-gnu) + google-protobuf (~> 3.23) + sass-embedded (1.64.1-arm64-darwin) + google-protobuf (~> 3.23) + sass-embedded (1.64.1-x86_64-darwin) + google-protobuf (~> 3.23) + sass-embedded (1.64.1-x86_64-linux-gnu) + google-protobuf (~> 3.23) + selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - shoulda-matchers (5.3.0) - activesupport (>= 5.2.0) - sidekiq (6.5.8) + sidekiq (6.5.9) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) @@ -439,27 +477,28 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) + simpleidn (0.2.1) + unf (~> 0.1.4) snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - sprockets (3.7.2) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) sshkey (2.0.0) - stackprof (0.2.23) - syntax_tree (5.2.0) + stackprof (0.2.25) + syntax_tree (6.1.1) prettier_print (>= 1.2.0) syntax_tree-disable_ternary (1.0.0) - test-prof (1.1.0) - thor (1.2.1) - tilt (2.0.11) - timeout (0.3.1) - tzinfo (2.0.5) + test-prof (1.2.2) + thor (1.2.2) + tilt (2.2.0) + timeout (0.4.0) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) + tzinfo-data (1.2023.3) + tzinfo (>= 1.0.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) @@ -471,27 +510,25 @@ GEM raindrops (~> 0.7) uniform_notifier (1.16.0) uri (0.12.2) - uri_template (0.7.0) - version_gem (1.1.1) + version_gem (1.1.3) web-push (3.0.0) hkdf (~> 1.0) jwt (~> 2.0) openssl (~> 3.0) - webdrivers (5.2.0) + webdrivers (5.3.1) nokogiri (~> 1.6) rubyzip (>= 1.3.0) - selenium-webdriver (~> 4.0) + selenium-webdriver (~> 4.0, < 4.11) webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.7.0) websocket (1.2.9) - xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - yaml-lint (0.0.10) - zeitwerk (2.6.6) + yaml-lint (0.1.2) + yard (0.9.34) + zeitwerk (2.6.10) PLATFORMS aarch64-linux @@ -503,14 +540,14 @@ PLATFORMS x86_64-linux DEPENDENCIES - actionmailer (= 7.0.4.3) - actionpack (= 7.0.4.3) - actionview (= 7.0.4.3) + actionmailer (= 7.0.5.1) + actionpack (= 7.0.5.1) + actionview (= 7.0.5.1) actionview_precompiler active_model_serializers (~> 0.8.3) - activemodel (= 7.0.4.3) - activerecord (= 7.0.4.3) - activesupport (= 7.0.4.3) + activemodel (= 7.0.5.1) + activerecord (= 7.0.5.1) + activesupport (= 7.0.5.1) addressable annotate aws-sdk-s3 @@ -528,6 +565,8 @@ DEPENDENCIES cose cppjieba_rb css_parser + dartsass-ruby + dartsass-sprockets diffy digest discourse-fonts @@ -559,7 +598,7 @@ DEPENDENCIES loofah lru_redux lz4-ruby - mail! + mail maxminddb memory_profiler message_bus @@ -577,7 +616,7 @@ DEPENDENCIES net-pop net-smtp nokogiri - oj (= 3.13.14) + oj omniauth omniauth-facebook omniauth-github @@ -589,13 +628,13 @@ DEPENDENCIES pry-byebug pry-rails puma - r2 rack rack-mini-profiler rack-protection + rails-dom-testing rails_failover rails_multisite - railties (= 7.0.4.3) + railties (= 7.0.5.1) rake rb-fsevent rbtrace @@ -610,18 +649,17 @@ DEPENDENCIES rspec-rails rss rswag-specs + rtlcss rubocop-discourse ruby-prof ruby-readability rubyzip sanitize - sassc (= 2.0.1) - sassc-rails selenium-webdriver - shoulda-matchers + shoulda-matchers! sidekiq simplecov - sprockets (= 3.7.2) + sprockets! sprockets-rails sshkey stackprof @@ -629,15 +667,15 @@ DEPENDENCIES syntax_tree-disable_ternary test-prof thor + tzinfo-data uglifier unf unicorn web-push webdrivers webmock - webrick - xorcist yaml-lint + yard BUNDLED WITH - 2.4.1 + 2.4.13 diff --git a/README.md b/README.md index 4673f84cc3f..42452e4f87c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ To get your environment setup, follow the community setup guide for your operati If you're familiar with how Rails works and are comfortable setting up your own environment, you can also try out the [**Discourse Advanced Developer Guide**](docs/DEVELOPER-ADVANCED.md), which is aimed primarily at Ubuntu and macOS environments. -Before you get started, ensure you have the following minimum versions: [Ruby 2.7+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13+](https://www.postgresql.org/download/), [Redis 6.2+](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! +Before you get started, ensure you have the following minimum versions: [Ruby 3.2+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13](https://www.postgresql.org/download/), [Redis 7](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! ## Setting up Discourse @@ -51,7 +51,7 @@ Discourse supports the **latest, stable releases** of all major browsers and pla | Microsoft Edge | | | | Mozilla Firefox | | | -Additionally, we aim to support Safari on iOS 12.5+ until January 2023 (Discourse 3.0). +Additionally, we aim to support Safari on iOS 15.7+. ## Built With diff --git a/app/assets/javascripts/.licensee.json b/app/assets/javascripts/.licensee.json index b3518d7a131..ae2abbd2b64 100644 --- a/app/assets/javascripts/.licensee.json +++ b/app/assets/javascripts/.licensee.json @@ -10,15 +10,16 @@ ] }, "packages": { + "cli-table": "0.3.11", "component-bind": "1.0.0", "component-inherit": "0.0.3", "duplex": "1.0.0", "glob": "3.1.21", "indexof": "0.0.1", "inherits": "1.0.2", - "jsonify": "0.0.0", - "messageformat": "0.1.5", + "jsonify": "0.0.1", "line-stream": "0.0.0", + "messageformat": "0.1.5", "regenerator-transform": "0.10.1", "source-map": "0.1.43", "sourcemap-validator": "1.1.1" diff --git a/app/assets/javascripts/admin/addon/adapters/api-key.js b/app/assets/javascripts/admin/addon/adapters/api-key.js index 860e4c50692..6e1ad0eb327 100644 --- a/app/assets/javascripts/admin/addon/adapters/api-key.js +++ b/app/assets/javascripts/admin/addon/adapters/api-key.js @@ -1,13 +1,13 @@ -import RESTAdapter from "discourse/adapters/rest"; +import RestAdapter from "discourse/adapters/rest"; -export default RESTAdapter.extend({ - jsonMode: true, +export default class ApiKey extends RestAdapter { + jsonMode = true; basePath() { return "/admin/api/"; - }, + } apiNameFor() { return "key"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/build-plugin.js b/app/assets/javascripts/admin/addon/adapters/build-plugin.js index 3a81ab4e573..98b875ba94b 100644 --- a/app/assets/javascripts/admin/addon/adapters/build-plugin.js +++ b/app/assets/javascripts/admin/addon/adapters/build-plugin.js @@ -1,11 +1,11 @@ import RestAdapter from "discourse/adapters/rest"; export default function buildPluginAdapter(pluginName) { - return RestAdapter.extend({ + return class extends RestAdapter { pathFor(store, type, findArgs) { return ( - "/admin/plugins/" + pluginName + this._super(store, type, findArgs) + "/admin/plugins/" + pluginName + super.pathFor(store, type, findArgs) ); - }, - }); + } + }; } diff --git a/app/assets/javascripts/admin/addon/adapters/customization-base.js b/app/assets/javascripts/admin/addon/adapters/customization-base.js index 272103ee8a8..d20f15d1dca 100644 --- a/app/assets/javascripts/admin/addon/adapters/customization-base.js +++ b/app/assets/javascripts/admin/addon/adapters/customization-base.js @@ -1,7 +1,7 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ +export default class CustomizationBase extends RestAdapter { basePath() { return "/admin/customize/"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/email-style.js b/app/assets/javascripts/admin/addon/adapters/email-style.js index 12919f04b0d..e4465ef56eb 100644 --- a/app/assets/javascripts/admin/addon/adapters/email-style.js +++ b/app/assets/javascripts/admin/addon/adapters/email-style.js @@ -1,7 +1,7 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ +export default class EmailStyle extends RestAdapter { pathFor() { return "/admin/customize/email_style"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/embedding.js b/app/assets/javascripts/admin/addon/adapters/embedding.js index ba3fb816b80..966098fd4d1 100644 --- a/app/assets/javascripts/admin/addon/adapters/embedding.js +++ b/app/assets/javascripts/admin/addon/adapters/embedding.js @@ -1,7 +1,7 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ +export default class Embedding extends RestAdapter { pathFor() { return "/admin/customize/embedding"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/staff-action-log.js b/app/assets/javascripts/admin/addon/adapters/staff-action-log.js index d281f9746be..9a9d1d6c0b4 100644 --- a/app/assets/javascripts/admin/addon/adapters/staff-action-log.js +++ b/app/assets/javascripts/admin/addon/adapters/staff-action-log.js @@ -1,7 +1,7 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ +export default class StaffActionLog extends RestAdapter { basePath() { return "/admin/logs/"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/tag-group.js b/app/assets/javascripts/admin/addon/adapters/tag-group.js index 4c12654d967..4caa9172b92 100644 --- a/app/assets/javascripts/admin/addon/adapters/tag-group.js +++ b/app/assets/javascripts/admin/addon/adapters/tag-group.js @@ -1,5 +1,5 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ - jsonMode: true, -}); +export default class TagGroup extends RestAdapter { + jsonMode = true; +} diff --git a/app/assets/javascripts/admin/addon/adapters/theme.js b/app/assets/javascripts/admin/addon/adapters/theme.js index cf34ab42f5f..66f901b2cdb 100644 --- a/app/assets/javascripts/admin/addon/adapters/theme.js +++ b/app/assets/javascripts/admin/addon/adapters/theme.js @@ -1,9 +1,10 @@ import RestAdapter from "discourse/adapters/rest"; -export default RestAdapter.extend({ +export default class Theme extends RestAdapter { + jsonMode = true; basePath() { return "/admin/"; - }, + } afterFindAll(results) { let map = {}; @@ -20,7 +21,5 @@ export default RestAdapter.extend({ theme.set("parentThemes", mappedParents); }); return results; - }, - - jsonMode: true, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/web-hook-event.js b/app/assets/javascripts/admin/addon/adapters/web-hook-event.js index 1acd38386ba..8a6fa2b082d 100644 --- a/app/assets/javascripts/admin/addon/adapters/web-hook-event.js +++ b/app/assets/javascripts/admin/addon/adapters/web-hook-event.js @@ -1,7 +1,7 @@ -import RESTAdapter from "discourse/adapters/rest"; +import RestAdapter from "discourse/adapters/rest"; -export default RESTAdapter.extend({ +export default class WebHookEvent extends RestAdapter { basePath() { return "/admin/api/"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/adapters/web-hook.js b/app/assets/javascripts/admin/addon/adapters/web-hook.js index 1acd38386ba..813f7dbf03b 100644 --- a/app/assets/javascripts/admin/addon/adapters/web-hook.js +++ b/app/assets/javascripts/admin/addon/adapters/web-hook.js @@ -1,7 +1,7 @@ -import RESTAdapter from "discourse/adapters/rest"; +import RestAdapter from "discourse/adapters/rest"; -export default RESTAdapter.extend({ +export default class WebHook extends RestAdapter { basePath() { return "/admin/api/"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/ace-editor.hbs b/app/assets/javascripts/admin/addon/components/ace-editor.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/ace-editor.hbs rename to app/assets/javascripts/admin/addon/components/ace-editor.hbs diff --git a/app/assets/javascripts/admin/addon/components/ace-editor.js b/app/assets/javascripts/admin/addon/components/ace-editor.js index 06ed3763031..84aa4351e15 100644 --- a/app/assets/javascripts/admin/addon/components/ace-editor.js +++ b/app/assets/javascripts/admin/addon/components/ace-editor.js @@ -1,31 +1,33 @@ +import { action } from "@ember/object"; +import { classNames } from "@ember-decorators/component"; +import { observes, on } from "@ember-decorators/object"; import Component from "@ember/component"; import getURL from "discourse-common/lib/get-url"; import loadScript from "discourse/lib/load-script"; import I18n from "I18n"; -import { bind, observes } from "discourse-common/utils/decorators"; -import { on } from "@ember/object/evented"; +import { bind } from "discourse-common/utils/decorators"; const COLOR_VARS_REGEX = /\$(primary|secondary|tertiary|quaternary|header_background|header_primary|highlight|danger|success|love)(\s|;|-(low|medium|high))/g; -export default Component.extend({ - mode: "css", - classNames: ["ace-wrapper"], - _editor: null, - _skipContentChangeEvent: null, - disabled: false, - htmlPlaceholder: false, +@classNames("ace-wrapper") +export default class AceEditor extends Component { + mode = "css"; + disabled = false; + htmlPlaceholder = false; + _editor = null; + _skipContentChangeEvent = null; @observes("editorId") editorIdChanged() { if (this.autofocus) { this.send("focus"); } - }, + } didRender() { this._skipContentChangeEvent = false; - }, + } @observes("content") contentChanged() { @@ -33,14 +35,14 @@ export default Component.extend({ if (this._editor && !this._skipContentChangeEvent) { this._editor.getSession().setValue(content); } - }, + } @observes("mode") modeChanged() { if (this._editor && !this._skipContentChangeEvent) { this._editor.getSession().setMode("ace/mode/" + this.mode); } - }, + } @observes("placeholder") placeholderChanged() { @@ -49,12 +51,12 @@ export default Component.extend({ placeholder: this.placeholder, }); } - }, + } @observes("disabled") disabledStateChanged() { this.changeDisabledState(); - }, + } changeDisabledState() { const editor = this._editor; @@ -67,9 +69,10 @@ export default Component.extend({ }); editor.container.parentNode.setAttribute("data-disabled", disabled); } - }, + } - _destroyEditor: on("willDestroyElement", function () { + @on("willDestroyElement") + _destroyEditor() { if (this._editor) { this._editor.destroy(); this._editor = null; @@ -80,16 +83,16 @@ export default Component.extend({ } $(window).off("ace:resize"); - }), + } resize() { if (this._editor) { this._editor.resize(); } - }, + } didInsertElement() { - this._super(...arguments); + super.didInsertElement(...arguments); loadScript("/javascripts/ace/ace.js").then(() => { window.ace.require(["ace/ace"], (loadedAce) => { loadedAce.config.set("loadWorkerFromBlob", false); @@ -153,13 +156,13 @@ export default Component.extend({ this._darkModeListener.addListener(this.setAceTheme); }); }); - }, + } willDestroyElement() { if (this._darkModeListener) { this._darkModeListener.removeListener(this.setAceTheme); } - }, + } @bind setAceTheme() { @@ -170,7 +173,7 @@ export default Component.extend({ this._editor.setTheme( `ace/theme/${schemeType === "dark" ? "chaos" : "chrome"}` ); - }, + } warnSCSSDeprecations() { if ( @@ -197,21 +200,20 @@ export default Component.extend({ this._editor.getSession().setAnnotations(warnings); - this.setWarning( + this.setWarning?.( warnings.length ? I18n.t("admin.customize.theme.scss_color_variables_warning") : false ); - }, + } - actions: { - focus() { - if (this._editor) { - this._editor.focus(); - this._editor.navigateFileEnd(); - } - }, - }, + @action + focus() { + if (this._editor) { + this._editor.focus(); + this._editor.navigateFileEnd(); + } + } _overridePlaceholder(loadedAce) { const originalPlaceholderSetter = @@ -239,5 +241,5 @@ export default Component.extend({ this.$updatePlaceholder(); }; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-backups-logs.hbs b/app/assets/javascripts/admin/addon/components/admin-backups-logs.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-backups-logs.hbs rename to app/assets/javascripts/admin/addon/components/admin-backups-logs.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-backups-logs.js b/app/assets/javascripts/admin/addon/components/admin-backups-logs.js index c707c99b70a..e1c12d04a32 100644 --- a/app/assets/javascripts/admin/addon/components/admin-backups-logs.js +++ b/app/assets/javascripts/admin/addon/components/admin-backups-logs.js @@ -1,28 +1,26 @@ -import { observes, on } from "discourse-common/utils/decorators"; +import { classNames } from "@ember-decorators/component"; +import { observes, on } from "@ember-decorators/object"; import Component from "@ember/component"; import I18n from "I18n"; import discourseDebounce from "discourse-common/lib/debounce"; import { scheduleOnce } from "@ember/runloop"; -export default Component.extend({ - classNames: ["admin-backups-logs"], - showLoadingSpinner: false, - hasFormattedLogs: false, - noLogsMessage: I18n.t("admin.backups.logs.none"), - - init() { - this._super(...arguments); - this._reset(); - }, +@classNames("admin-backups-logs") +export default class AdminBackupsLogs extends Component { + showLoadingSpinner = false; + hasFormattedLogs = false; + noLogsMessage = I18n.t("admin.backups.logs.none"); + formattedLogs = ""; + index = 0; _reset() { this.setProperties({ formattedLogs: "", index: 0 }); - }, + } _scrollDown() { const div = this.element; div.scrollTop = div.scrollHeight; - }, + } @on("init") @observes("logs.[]") @@ -31,7 +29,7 @@ export default Component.extend({ this._reset(); // reset the cached logs whenever the model is reset this.renderLogs(); } - }, + } _updateFormattedLogsFunc() { const logs = this.logs; @@ -55,13 +53,13 @@ export default Component.extend({ this.renderLogs(); scheduleOnce("afterRender", this, this._scrollDown); - }, + } @on("init") @observes("logs.[]") _updateFormattedLogs() { discourseDebounce(this, this._updateFormattedLogsFunc, 150); - }, + } renderLogs() { const formattedLogs = this.formattedLogs; @@ -76,5 +74,5 @@ export default Component.extend({ } else { this.set("showLoadingSpinner", false); } - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-editable-field.hbs b/app/assets/javascripts/admin/addon/components/admin-editable-field.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-editable-field.hbs rename to app/assets/javascripts/admin/addon/components/admin-editable-field.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-editable-field.js b/app/assets/javascripts/admin/addon/components/admin-editable-field.js index 892a3208e86..a99265b20cb 100644 --- a/app/assets/javascripts/admin/addon/components/admin-editable-field.js +++ b/app/assets/javascripts/admin/addon/components/admin-editable-field.js @@ -1,28 +1,22 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; import { action } from "@ember/object"; -export default Component.extend({ - tagName: "", - - buffer: "", - editing: false, - - init() { - this._super(...arguments); - this.set("editing", false); - }, +@tagName("") +export default class AdminEditableField extends Component { + buffer = ""; + editing = false; @action edit(event) { event?.preventDefault(); this.set("buffer", this.value); this.toggleProperty("editing"); - }, + } - actions: { - save() { - // Action has to toggle 'editing' property. - this.action(this.buffer); - }, - }, -}); + @action + save() { + // Action has to toggle 'editing' property. + this.action(this.buffer); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-form-row.hbs b/app/assets/javascripts/admin/addon/components/admin-form-row.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-form-row.hbs rename to app/assets/javascripts/admin/addon/components/admin-form-row.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-form-row.js b/app/assets/javascripts/admin/addon/components/admin-form-row.js index 6217c6b913f..266301263d4 100644 --- a/app/assets/javascripts/admin/addon/components/admin-form-row.js +++ b/app/assets/javascripts/admin/addon/components/admin-form-row.js @@ -1,4 +1,5 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - classNames: ["row"], -}); + +@classNames("row") +export default class AdminFormRow extends Component {} diff --git a/app/assets/javascripts/admin/addon/components/admin-graph.js b/app/assets/javascripts/admin/addon/components/admin-graph.js index 1107abeb2ca..4ef05fca32e 100644 --- a/app/assets/javascripts/admin/addon/components/admin-graph.js +++ b/app/assets/javascripts/admin/addon/components/admin-graph.js @@ -1,9 +1,10 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; import loadScript from "discourse/lib/load-script"; -export default Component.extend({ - tagName: "canvas", - type: "line", +@tagName("canvas") +export default class AdminGraph extends Component { + type = "line"; refreshChart() { const ctx = this.element.getContext("2d"); @@ -49,11 +50,11 @@ export default Component.extend({ }; this._chart = new window.Chart(ctx, config); - }, + } didInsertElement() { loadScript("/javascripts/Chart.min.js").then(() => this.refreshChart.apply(this) ); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/components/admin-nav.gjs b/app/assets/javascripts/admin/addon/components/admin-nav.gjs new file mode 100644 index 00000000000..be3aa02cb4a --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-nav.gjs @@ -0,0 +1,15 @@ +import { tagName } from "@ember-decorators/component"; +import Component from "@ember/component"; + +@tagName("") +export default class AdminNav extends Component { + +} diff --git a/app/assets/javascripts/admin/addon/components/admin-nav.js b/app/assets/javascripts/admin/addon/components/admin-nav.js deleted file mode 100644 index 0e6d50b17d4..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-nav.js +++ /dev/null @@ -1,4 +0,0 @@ -import Component from "@ember/component"; -export default Component.extend({ - tagName: "", -}); diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-penalty-history.hbs b/app/assets/javascripts/admin/addon/components/admin-penalty-history.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-penalty-history.hbs rename to app/assets/javascripts/admin/addon/components/admin-penalty-history.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-history.js b/app/assets/javascripts/admin/addon/components/admin-penalty-history.js index 32c288b248d..0931ac8e4a4 100644 --- a/app/assets/javascripts/admin/addon/components/admin-penalty-history.js +++ b/app/assets/javascripts/admin/addon/components/admin-penalty-history.js @@ -1,16 +1,16 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend({ - classNames: ["penalty-history"], - +@classNames("penalty-history") +export default class AdminPenaltyHistory extends Component { @discourseComputed("user.penalty_counts.suspended") suspendedCountClass(count) { if (count > 0) { return "danger"; } return ""; - }, + } @discourseComputed("user.penalty_counts.silenced") silencedCountClass(count) { @@ -18,5 +18,5 @@ export default Component.extend({ return "danger"; } return ""; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-penalty-post-action.hbs b/app/assets/javascripts/admin/addon/components/admin-penalty-post-action.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-penalty-post-action.hbs rename to app/assets/javascripts/admin/addon/components/admin-penalty-post-action.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-post-action.js b/app/assets/javascripts/admin/addon/components/admin-penalty-post-action.js index eb201ae0899..08314925460 100644 --- a/app/assets/javascripts/admin/addon/components/admin-penalty-post-action.js +++ b/app/assets/javascripts/admin/addon/components/admin-penalty-post-action.js @@ -1,5 +1,6 @@ -import Component from "@ember/component"; +import { action } from "@ember/object"; import { equal } from "@ember/object/computed"; +import Component from "@ember/component"; import discourseComputed, { afterRender, } from "discourse-common/utils/decorators"; @@ -7,30 +8,28 @@ import I18n from "I18n"; const ACTIONS = ["delete", "delete_replies", "edit", "none"]; -export default Component.extend({ - postId: null, - postAction: null, - postEdit: null, +export default class AdminPenaltyPostAction extends Component { + postId = null; + postAction = null; + postEdit = null; + @equal("postAction", "edit") editing; @discourseComputed penaltyActions() { return ACTIONS.map((id) => { return { id, name: I18n.t(`admin.user.penalty_post_${id}`) }; }); - }, + } - editing: equal("postAction", "edit"), + @action + penaltyChanged(postAction) { + this.set("postAction", postAction); - actions: { - penaltyChanged(postAction) { - this.set("postAction", postAction); - - // If we switch to edit mode, jump to the edit textarea - if (postAction === "edit") { - this._focusEditTextarea(); - } - }, - }, + // If we switch to edit mode, jump to the edit textarea + if (postAction === "edit") { + this._focusEditTextarea(); + } + } @afterRender _focusEditTextarea() { @@ -38,5 +37,5 @@ export default Component.extend({ const body = elem.closest(".modal-body"); body.scrollTo(0, body.clientHeight); elem.querySelector(".post-editor").focus(); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-penalty-reason.hbs b/app/assets/javascripts/admin/addon/components/admin-penalty-reason.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-penalty-reason.hbs rename to app/assets/javascripts/admin/addon/components/admin-penalty-reason.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-reason.js b/app/assets/javascripts/admin/addon/components/admin-penalty-reason.js index ca6164e7630..e8d037d249b 100644 --- a/app/assets/javascripts/admin/addon/components/admin-penalty-reason.js +++ b/app/assets/javascripts/admin/addon/components/admin-penalty-reason.js @@ -1,43 +1,46 @@ +import { tagName } from "@ember-decorators/component"; +import { equal } from "@ember/object/computed"; import Component from "@ember/component"; import { action } from "@ember/object"; -import { equal } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import I18n from "I18n"; const CUSTOM_REASON_KEY = "custom"; -export default Component.extend({ - tagName: "", - selectedReason: CUSTOM_REASON_KEY, - customReason: "", - reasonKeys: [ +@tagName("") +export default class AdminPenaltyReason extends Component { + selectedReason = CUSTOM_REASON_KEY; + customReason = ""; + + reasonKeys = [ "not_listening_to_staff", "consuming_staff_time", "combative", "in_wrong_place", "no_constructive_purpose", CUSTOM_REASON_KEY, - ], - isCustomReason: equal("selectedReason", CUSTOM_REASON_KEY), + ]; + + @equal("selectedReason", CUSTOM_REASON_KEY) isCustomReason; @discourseComputed("reasonKeys") reasons(keys) { return keys.map((key) => { return { id: key, name: I18n.t(`admin.user.suspend_reasons.${key}`) }; }); - }, + } @action setSelectedReason(value) { this.set("selectedReason", value); this.setReason(); - }, + } @action setCustomReason(value) { this.set("customReason", value); this.setReason(); - }, + } setReason() { if (this.isCustomReason) { @@ -48,5 +51,5 @@ export default Component.extend({ I18n.t(`admin.user.suspend_reasons.${this.selectedReason}`) ); } - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-penalty-similar-users.hbs b/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-penalty-similar-users.hbs rename to app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js b/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js index 241927a7b7f..52996d458f3 100644 --- a/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js +++ b/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js @@ -1,10 +1,10 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; import { action } from "@ember/object"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend({ - tagName: "", - +@tagName("") +export default class AdminPenaltySimilarUsers extends Component { @discourseComputed("penaltyType") penaltyField(penaltyType) { if (penaltyType === "suspend") { @@ -12,7 +12,7 @@ export default Component.extend({ } else if (penaltyType === "silence") { return "can_be_silenced"; } - }, + } @action selectUserId(userId, event) { @@ -25,5 +25,5 @@ export default Component.extend({ } else { this.selectedUserIds.removeObject(userId); } - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-chart.hbs b/app/assets/javascripts/admin/addon/components/admin-report-chart.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-chart.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-chart.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-chart.js index 1c4ad1caa3a..64f09aa0d7d 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-chart.js @@ -1,3 +1,4 @@ +import { classNames } from "@ember-decorators/component"; import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -7,31 +8,31 @@ import { number } from "discourse/lib/formatter"; import { schedule } from "@ember/runloop"; import { bind } from "discourse-common/utils/decorators"; -export default Component.extend({ - classNames: ["admin-report-chart"], - limit: 8, - total: 0, - options: null, +@classNames("admin-report-chart") +export default class AdminReportChart extends Component { + limit = 8; + total = 0; + options = null; didInsertElement() { - this._super(...arguments); + super.didInsertElement(...arguments); window.addEventListener("resize", this._resizeHandler); - }, + } willDestroyElement() { - this._super(...arguments); + super.willDestroyElement(...arguments); window.removeEventListener("resize", this._resizeHandler); this._resetChart(); - }, + } didReceiveAttrs() { - this._super(...arguments); + super.didReceiveAttrs(...arguments); discourseDebounce(this, this._scheduleChartRendering, 100); - }, + } _scheduleChartRendering() { schedule("afterRender", () => { @@ -40,7 +41,7 @@ export default Component.extend({ this.element && this.element.querySelector(".chart-canvas") ); }); - }, + } _renderChart(model, chartCanvas) { if (!chartCanvas) { @@ -99,7 +100,7 @@ export default Component.extend({ this._buildChartConfig(data, this.options) ); }); - }, + } _buildChartConfig(data, options) { return { @@ -161,21 +162,21 @@ export default Component.extend({ }, }, }; - }, + } _resetChart() { if (this._chart) { this._chart.destroy(); this._chart = null; } - }, + } _applyChartGrouping(model, data, options) { return Report.collapse(model, data, options.chartGrouping); - }, + } @bind _resizeHandler() { discourseDebounce(this, this._scheduleChartRendering, 500); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-counters.hbs b/app/assets/javascripts/admin/addon/components/admin-report-counters.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-counters.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-counters.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-counters.js b/app/assets/javascripts/admin/addon/components/admin-report-counters.js index c956b93e359..6e3dab52366 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-counters.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-counters.js @@ -1,6 +1,6 @@ +import { attributeBindings, classNames } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - classNames: ["admin-report-counters"], - attributeBindings: ["model.description:title"], -}); +@classNames("admin-report-counters") +@attributeBindings("model.description:title") +export default class AdminReportCounters extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-counts.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-counts.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-counts.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-counts.js index 03c690dbbd6..fd1b6cc68d6 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-counts.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-counts.js @@ -1,11 +1,12 @@ -import Component from "@ember/component"; +import { classNameBindings, tagName } from "@ember-decorators/component"; import { match } from "@ember/object/computed"; -export default Component.extend({ - allTime: true, - tagName: "tr", - reverseColors: match( - "report.type", - /^(time_to_first_response|topics_with_no_response)$/ - ), - classNameBindings: ["reverseColors"], -}); +import Component from "@ember/component"; + +@tagName("tr") +@classNameBindings("reverseColors") +export default class AdminReportCounts extends Component { + allTime = true; + + @match("report.type", /^(time_to_first_response|topics_with_no_response)$/) + reverseColors; +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-inline-table.hbs b/app/assets/javascripts/admin/addon/components/admin-report-inline-table.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-inline-table.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-inline-table.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-inline-table.js b/app/assets/javascripts/admin/addon/components/admin-report-inline-table.js index 753320cc31a..3e770da6467 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-inline-table.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-inline-table.js @@ -1,4 +1,5 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - classNames: ["admin-report-inline-table"], -}); + +@classNames("admin-report-inline-table") +export default class AdminReportInlineTable extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-per-day-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-per-day-counts.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js index 7f039c061e1..ee48a5138f2 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js @@ -1,4 +1,5 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - tagName: "tr", -}); + +@tagName("tr") +export default class AdminReportPerDayCounts extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-stacked-chart.hbs b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-stacked-chart.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js index 6b773957f66..b69e75141e8 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js @@ -1,3 +1,4 @@ +import { classNames } from "@ember-decorators/component"; import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -7,32 +8,31 @@ import { number } from "discourse/lib/formatter"; import { schedule } from "@ember/runloop"; import { bind } from "discourse-common/utils/decorators"; -export default Component.extend({ - classNames: ["admin-report-chart", "admin-report-stacked-chart"], - +@classNames("admin-report-chart", "admin-report-stacked-chart") +export default class AdminReportStackedChart extends Component { didInsertElement() { - this._super(...arguments); + super.didInsertElement(...arguments); window.addEventListener("resize", this._resizeHandler); - }, + } willDestroyElement() { - this._super(...arguments); + super.willDestroyElement(...arguments); window.removeEventListener("resize", this._resizeHandler); this._resetChart(); - }, + } didReceiveAttrs() { - this._super(...arguments); + super.didReceiveAttrs(...arguments); discourseDebounce(this, this._scheduleChartRendering, 100); - }, + } @bind _resizeHandler() { discourseDebounce(this, this._scheduleChartRendering, 500); - }, + } _scheduleChartRendering() { schedule("afterRender", () => { @@ -45,7 +45,7 @@ export default Component.extend({ this.element.querySelector(".chart-canvas") ); }); - }, + } _renderChart(model, chartCanvas) { if (!chartCanvas) { @@ -79,7 +79,7 @@ export default Component.extend({ this._chart = new window.Chart(context, this._buildChartConfig(data)); }); - }, + } _buildChartConfig(data) { return { @@ -150,10 +150,10 @@ export default Component.extend({ }, }, }; - }, + } _resetChart() { this._chart?.destroy(); this._chart = null; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-storage-stats.hbs b/app/assets/javascripts/admin/addon/components/admin-report-storage-stats.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-storage-stats.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-storage-stats.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-storage-stats.js b/app/assets/javascripts/admin/addon/components/admin-report-storage-stats.js index e12bc6432f7..ee0d7d24764 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-storage-stats.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-storage-stats.js @@ -1,43 +1,45 @@ +import { classNames } from "@ember-decorators/component"; +import { alias } from "@ember/object/computed"; import Component from "@ember/component"; import I18n from "I18n"; -import { alias } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import { setting } from "discourse/lib/computed"; -export default Component.extend({ - classNames: ["admin-report-storage-stats"], +@classNames("admin-report-storage-stats") +export default class AdminReportStorageStats extends Component { + @setting("backup_location") backupLocation; - backupLocation: setting("backup_location"), - backupStats: alias("model.data.backups"), - uploadStats: alias("model.data.uploads"), + @alias("model.data.backups") backupStats; + + @alias("model.data.uploads") uploadStats; @discourseComputed("backupStats") showBackupStats(stats) { return stats && this.currentUser.admin; - }, + } @discourseComputed("backupLocation") backupLocationName(backupLocation) { return I18n.t(`admin.backups.location.${backupLocation}`); - }, + } @discourseComputed("backupStats.used_bytes") usedBackupSpace(bytes) { return I18n.toHumanSize(bytes); - }, + } @discourseComputed("backupStats.free_bytes") freeBackupSpace(bytes) { return I18n.toHumanSize(bytes); - }, + } @discourseComputed("uploadStats.used_bytes") usedUploadSpace(bytes) { return I18n.toHumanSize(bytes); - }, + } @discourseComputed("uploadStats.free_bytes") freeUploadSpace(bytes) { return I18n.toHumanSize(bytes); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-table-cell.hbs b/app/assets/javascripts/admin/addon/components/admin-report-table-cell.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-table-cell.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-table-cell.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js b/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js index 838d8dbe1d4..89997c9afab 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js @@ -1,21 +1,27 @@ -import Component from "@ember/component"; +import { + attributeBindings, + classNameBindings, + classNames, + tagName, +} from "@ember-decorators/component"; import { alias } from "@ember/object/computed"; +import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend({ - tagName: "td", - classNames: ["admin-report-table-cell"], - classNameBindings: ["type", "property"], - attributeBindings: ["value:title"], - options: null, +@tagName("td") +@classNames("admin-report-table-cell") +@classNameBindings("type", "property") +@attributeBindings("value:title") +export default class AdminReportTableCell extends Component { + options = null; + + @alias("label.type") type; + @alias("label.mainProperty") property; + @alias("computedLabel.formattedValue") formattedValue; + @alias("computedLabel.value") value; @discourseComputed("label", "data", "options") computedLabel(label, data, options) { return label.compute(data, options || {}); - }, - - type: alias("label.type"), - property: alias("label.mainProperty"), - formattedValue: alias("computedLabel.formattedValue"), - value: alias("computedLabel.value"), -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-table-header.hbs b/app/assets/javascripts/admin/addon/components/admin-report-table-header.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-table-header.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-table-header.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-table-header.js b/app/assets/javascripts/admin/addon/components/admin-report-table-header.js index 5c7cdf1e4ca..7de3ac97088 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-table-header.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-table-header.js @@ -1,19 +1,24 @@ +import { + attributeBindings, + classNameBindings, + classNames, + tagName, +} from "@ember-decorators/component"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend({ - tagName: "th", - classNames: ["admin-report-table-header"], - classNameBindings: ["label.mainProperty", "label.type", "isCurrentSort"], - attributeBindings: ["label.title:title"], - +@tagName("th") +@classNames("admin-report-table-header") +@classNameBindings("label.mainProperty", "label.type", "isCurrentSort") +@attributeBindings("label.title:title") +export default class AdminReportTableHeader extends Component { @discourseComputed("currentSortLabel.sortProperty", "label.sortProperty") isCurrentSort(currentSortField, labelSortField) { return currentSortField === labelSortField; - }, + } @discourseComputed("currentSortDirection") sortIcon(currentSortDirection) { return currentSortDirection === 1 ? "caret-up" : "caret-down"; - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-table-row.hbs b/app/assets/javascripts/admin/addon/components/admin-report-table-row.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-table-row.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-table-row.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-table-row.js b/app/assets/javascripts/admin/addon/components/admin-report-table-row.js index ff34cb94e8b..e5e275aa7ad 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-table-row.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-table-row.js @@ -1,6 +1,8 @@ +import { classNames, tagName } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - tagName: "tr", - classNames: ["admin-report-table-row"], - options: null, -}); + +@tagName("tr") +@classNames("admin-report-table-row") +export default class AdminReportTableRow extends Component { + options = null; +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-table.hbs b/app/assets/javascripts/admin/addon/components/admin-report-table.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-table.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-table.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-table.js b/app/assets/javascripts/admin/addon/components/admin-report-table.js index a237e93ee43..3638625f872 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-table.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-table.js @@ -1,22 +1,26 @@ -import Component from "@ember/component"; +import { action } from "@ember/object"; +import { classNameBindings, classNames } from "@ember-decorators/component"; import { alias } from "@ember/object/computed"; +import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; const PAGES_LIMIT = 8; -export default Component.extend({ - classNameBindings: ["sortable", "twoColumns"], - classNames: ["admin-report-table"], - sortable: false, - sortDirection: 1, - perPage: alias("options.perPage"), - page: 0, +@classNameBindings("sortable", "twoColumns") +@classNames("admin-report-table") +export default class AdminReportTable extends Component { + sortable = false; + sortDirection = 1; + + @alias("options.perPage") perPage; + + page = 0; @discourseComputed("model.computedLabels.length") twoColumns(labelsLength) { return labelsLength === 2; - }, + } @discourseComputed( "totalsForSample", @@ -31,12 +35,12 @@ export default Component.extend({ .reduce((s, v) => s + v, 0); return sum >= 1 && total && datesFiltering; - }, + } @discourseComputed("model.total", "options.total", "twoColumns") showTotal(reportTotal, total, twoColumns) { return reportTotal && total && twoColumns; - }, + } @discourseComputed( "model.{average,data}", @@ -50,17 +54,17 @@ export default Component.extend({ sampleTotalValue && hasTwoColumns ); - }, + } @discourseComputed("totalsForSample.1.value", "model.data.length") averageForSample(totals, count) { return (totals / count).toFixed(0); - }, + } @discourseComputed("model.data.length") showSortingUI(dataLength) { return dataLength >= 5; - }, + } @discourseComputed("totalsForSampleRow", "model.computedLabels") totalsForSample(row, labels) { @@ -70,7 +74,7 @@ export default Component.extend({ computedLabel.property = label.mainProperty; return computedLabel; }); - }, + } @discourseComputed("model.data", "model.computedLabels") totalsForSampleRow(rows, labels) { @@ -98,7 +102,7 @@ export default Component.extend({ }); return totalsRow; - }, + } @discourseComputed("sortLabel", "sortDirection", "model.data.[]") sortedData(sortLabel, sortDirection, data) { @@ -118,7 +122,7 @@ export default Component.extend({ } return data; - }, + } @discourseComputed("sortedData.[]", "perPage", "page") paginatedData(data, perPage, page) { @@ -128,7 +132,7 @@ export default Component.extend({ } return data; - }, + } @discourseComputed("model.data", "perPage", "page") pages(data, perPage, page) { @@ -156,19 +160,19 @@ export default Component.extend({ } return pages; - }, + } - actions: { - changePage(page) { - this.set("page", page); - }, + @action + changePage(page) { + this.set("page", page); + } - sortByLabel(label) { - if (this.sortLabel === label) { - this.set("sortDirection", this.sortDirection === 1 ? -1 : 1); - } else { - this.set("sortLabel", label); - } - }, - }, -}); + @action + sortByLabel(label) { + if (this.sortLabel === label) { + this.set("sortDirection", this.sortDirection === 1 ? -1 : 1); + } else { + this.set("sortLabel", label); + } + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report-trust-level-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report-trust-level-counts.hbs rename to app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js index 7f039c061e1..6da6a748934 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js @@ -1,4 +1,5 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - tagName: "tr", -}); + +@tagName("tr") +export default class AdminReportTrustLevelCounts extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report.hbs b/app/assets/javascripts/admin/addon/components/admin-report.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-report.hbs rename to app/assets/javascripts/admin/addon/components/admin-report.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-report.js b/app/assets/javascripts/admin/addon/components/admin-report.js index 9f60e09a257..fede9cc2d60 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report.js +++ b/app/assets/javascripts/admin/addon/components/admin-report.js @@ -1,6 +1,7 @@ +import { classNameBindings, classNames } from "@ember-decorators/component"; +import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import EmberObject, { action, computed } from "@ember/object"; import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; -import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import Component from "@ember/component"; import I18n from "I18n"; import ReportLoader from "discourse/lib/reports-loader"; @@ -21,51 +22,58 @@ const TABLE_OPTIONS = { const CHART_OPTIONS = {}; -export default Component.extend({ - classNameBindings: [ - "isHidden:hidden", - "isHidden::is-visible", - "isEnabled", - "isLoading", - "dasherizedDataSourceName", - ], - classNames: ["admin-report"], - isEnabled: true, - disabledLabel: I18n.t("admin.dashboard.disabled"), - isLoading: false, - rateLimitationString: null, - dataSourceName: null, - report: null, - model: null, - reportOptions: null, - forcedModes: null, - showAllReportsLink: false, - filters: null, - showTrend: false, - showHeader: true, - showTitle: true, - showFilteringUI: false, - showDatesOptions: alias("model.dates_filtering"), - showRefresh: or("showDatesOptions", "model.available_filters.length"), - shouldDisplayTrend: and("showTrend", "model.prev_period"), - endDate: null, - startDate: null, +@classNameBindings( + "isHidden:hidden", + "isHidden::is-visible", + "isEnabled", + "isLoading", + "dasherizedDataSourceName" +) +@classNames("admin-report") +export default class AdminReport extends Component { + isEnabled = true; + disabledLabel = I18n.t("admin.dashboard.disabled"); + isLoading = false; + rateLimitationString = null; + dataSourceName = null; + report = null; + model = null; + reportOptions = null; + forcedModes = null; + showAllReportsLink = false; + filters = null; + showTrend = false; + showHeader = true; + showTitle = true; + showFilteringUI = false; - init() { - this._super(...arguments); + @alias("model.dates_filtering") showDatesOptions; - this._reports = []; - }, + @or("showDatesOptions", "model.available_filters.length") showRefresh; - isHidden: computed("siteSettings.dashboard_hidden_reports", function () { + @and("showTrend", "model.prev_period") shouldDisplayTrend; + + endDate = null; + startDate = null; + + @or("showTimeoutError", "showExceptionError", "showNotFoundError") showError; + @equal("model.error", "not_found") showNotFoundError; + @equal("model.error", "timeout") showTimeoutError; + @equal("model.error", "exception") showExceptionError; + @notEmpty("model.data") hasData; + + _reports = []; + + @computed("siteSettings.dashboard_hidden_reports") + get isHidden() { return (this.siteSettings.dashboard_hidden_reports || "") .split("|") .filter(Boolean) .includes(this.dataSourceName); - }), + } didReceiveAttrs() { - this._super(...arguments); + super.didReceiveAttrs(...arguments); let startDate = moment(); if (this.filters && isPresent(this.filters.startDate)) { @@ -88,42 +96,35 @@ export default Component.extend({ } else if (this.dataSourceName) { this._fetchReport(); } - }, - - showError: or("showTimeoutError", "showExceptionError", "showNotFoundError"), - showNotFoundError: equal("model.error", "not_found"), - showTimeoutError: equal("model.error", "timeout"), - showExceptionError: equal("model.error", "exception"), - - hasData: notEmpty("model.data"), + } @discourseComputed("dataSourceName", "model.type") dasherizedDataSourceName(dataSourceName, type) { return (dataSourceName || type || "undefined").replace(/_/g, "-"); - }, + } @discourseComputed("dataSourceName", "model.type") dataSource(dataSourceName, type) { dataSourceName = dataSourceName || type; return `/admin/reports/${dataSourceName}`; - }, + } @discourseComputed("displayedModes.length") showModes(displayedModesLength) { return displayedModesLength > 1; - }, + } @discourseComputed("currentMode") isChartMode(currentMode) { return currentMode === "chart"; - }, + } @action changeGrouping(grouping) { this.send("refreshReport", { chartGrouping: grouping, }); - }, + } @discourseComputed("currentMode", "model.modes", "forcedModes") displayedModes(currentMode, reportModes, forcedModes) { @@ -139,12 +140,12 @@ export default Component.extend({ icon: mode === "table" ? "table" : "signal", }; }); - }, + } @discourseComputed("currentMode") modeComponent(currentMode) { return `admin-report-${currentMode.replace(/_/g, "-")}`; - }, + } @discourseComputed( "dataSourceName", @@ -178,7 +179,7 @@ export default Component.extend({ .join(":"); return reportKey; - }, + } @discourseComputed("options.chartGrouping", "model.chartData.length") chartGroupings(grouping, count) { @@ -192,7 +193,7 @@ export default Component.extend({ class: `chart-grouping ${grouping === id ? "active" : "inactive"}`, }; }); - }, + } @action onChangeDateRange(range) { @@ -200,7 +201,7 @@ export default Component.extend({ startDate: range.from, endDate: range.to, }); - }, + } @action applyFilter(id, value) { @@ -215,7 +216,7 @@ export default Component.extend({ this.send("refreshReport", { filters: customFilters, }); - }, + } @action refreshReport(options = {}) { @@ -238,7 +239,7 @@ export default Component.extend({ ? this.get("filters.customFilters") : options.filters, }); - }, + } @action exportCsv() { @@ -254,7 +255,7 @@ export default Component.extend({ } exportEntity("report", args).then(outputExportResult); - }, + } @action onChangeMode(mode) { @@ -263,7 +264,7 @@ export default Component.extend({ this.send("refreshReport", { chartGrouping: null, }); - }, + } _computeReport() { if (!this.element || this.isDestroying || this.isDestroyed) { @@ -306,7 +307,7 @@ export default Component.extend({ } this._renderReport(report, this.forcedModes, this.currentMode); - }, + } _renderReport(report, forcedModes, currentMode) { const modes = forcedModes ? forcedModes.split(",") : report.modes; @@ -317,11 +318,9 @@ export default Component.extend({ currentMode, options: this._buildOptions(currentMode, report), }); - }, + } _fetchReport() { - this._super(...arguments); - this.setProperties({ isLoading: true, rateLimitationString: null }); next(() => { @@ -349,7 +348,7 @@ export default Component.extend({ ReportLoader.enqueue(this.dataSourceName, payload.data, callback); }); - }, + } _buildPayload(facets) { let payload = { data: { facets } }; @@ -375,7 +374,7 @@ export default Component.extend({ } return payload; - }, + } _buildOptions(mode, report) { if (mode === "table") { @@ -393,7 +392,7 @@ export default Component.extend({ }) ); } - }, + } _loadReport(jsonReport) { Report.fillMissingDates(jsonReport, { filledField: "chartData" }); @@ -423,5 +422,5 @@ export default Component.extend({ } return Report.create(jsonReport); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-theme-editor.hbs b/app/assets/javascripts/admin/addon/components/admin-theme-editor.hbs similarity index 97% rename from app/assets/javascripts/admin/addon/templates/components/admin-theme-editor.hbs rename to app/assets/javascripts/admin/addon/components/admin-theme-editor.hbs index b74b2239f84..e6c201cb784 100644 --- a/app/assets/javascripts/admin/addon/templates/components/admin-theme-editor.hbs +++ b/app/assets/javascripts/admin/addon/components/admin-theme-editor.hbs @@ -44,7 +44,7 @@ @checked={{this.onlyOverridden}} {{on "click" - (action "onlyOverriddenChanged" value="target.checked") + (action this.onlyOverriddenChanged value="target.checked") }} /> {{i18n "admin.customize.theme.hide_unused_fields"}} @@ -125,6 +125,6 @@ @autofocus="true" @placeholder={{this.placeholder}} @htmlPlaceholder={{true}} - @save={{action "save"}} + @save={{this.save}} @setWarning={{action "setWarning"}} /> \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-theme-editor.js b/app/assets/javascripts/admin/addon/components/admin-theme-editor.js index de8f0635791..31a0d02ec91 100644 --- a/app/assets/javascripts/admin/addon/components/admin-theme-editor.js +++ b/app/assets/javascripts/admin/addon/components/admin-theme-editor.js @@ -3,11 +3,13 @@ import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; import { isDocumentRTL } from "discourse/lib/text-direction"; -import { action } from "@ember/object"; +import { action, computed } from "@ember/object"; import { next } from "@ember/runloop"; -export default Component.extend({ - warning: null, +export default class AdminThemeEditor extends Component { + warning = null; + + @fmt("fieldName", "currentTargetName", "%@|%@") editorId; @discourseComputed("theme.targets", "onlyOverridden", "showAdvanced") visibleTargets(targets, onlyOverridden, showAdvanced) { @@ -20,7 +22,7 @@ export default Component.extend({ } return target.edited; }); - }, + } @discourseComputed("currentTargetName", "onlyOverridden", "theme.fields") visibleFields(targetName, onlyOverridden, fields) { @@ -29,7 +31,7 @@ export default Component.extend({ fields = fields.filter((field) => field.edited); } return fields; - }, + } @discourseComputed("currentTargetName", "fieldName") activeSectionMode(targetName, fieldName) { @@ -43,7 +45,7 @@ export default Component.extend({ return "scss"; } return fieldName && fieldName.includes("scss") ? "scss" : "html"; - }, + } @discourseComputed("currentTargetName", "fieldName") placeholder(targetName, fieldName) { @@ -58,30 +60,27 @@ export default Component.extend({ }); } return ""; - }, + } - @discourseComputed("fieldName", "currentTargetName", "theme") - activeSection: { - get(fieldName, target, model) { - return model.getField(target, fieldName); - }, - set(value, fieldName, target, model) { - model.setField(target, fieldName, value); - return value; - }, - }, + @computed("fieldName", "currentTargetName", "theme") + get activeSection() { + return this.theme.getField(this.currentTargetName, this.fieldName); + } - editorId: fmt("fieldName", "currentTargetName", "%@|%@"), + set activeSection(value) { + this.theme.setField(this.currentTargetName, this.fieldName, value); + return value; + } @discourseComputed("maximized") maximizeIcon(maximized) { return maximized ? "discourse-compress" : "discourse-expand"; - }, + } @discourseComputed("currentTargetName", "theme.targets") showAddField(currentTargetName, targets) { return targets.find((t) => t.name === currentTargetName).customNames; - }, + } @discourseComputed( "currentTargetName", @@ -90,52 +89,45 @@ export default Component.extend({ ) error(target, fieldName) { return this.theme.getError(target, fieldName); - }, + } @action toggleShowAdvanced(event) { event?.preventDefault(); this.toggleProperty("showAdvanced"); - }, + } @action toggleAddField(event) { event?.preventDefault(); this.toggleProperty("addingField"); - }, + } @action toggleMaximize(event) { event?.preventDefault(); this.toggleProperty("maximized"); next(() => this.appEvents.trigger("ace:resize")); - }, + } - actions: { - cancelAddField() { - this.set("addingField", false); - }, + @action + cancelAddField() { + this.set("addingField", false); + } - addField(name) { - if (!name) { - return; - } - name = name.replace(/[^a-zA-Z0-9-_/]/g, ""); - this.theme.setField(this.currentTargetName, name, ""); - this.setProperties({ newFieldName: "", addingField: false }); - this.fieldAdded(this.currentTargetName, name); - }, + @action + addField(name) { + if (!name) { + return; + } + name = name.replace(/[^a-zA-Z0-9-_/]/g, ""); + this.theme.setField(this.currentTargetName, name, ""); + this.setProperties({ newFieldName: "", addingField: false }); + this.fieldAdded(this.currentTargetName, name); + } - onlyOverriddenChanged(value) { - this.onlyOverriddenChanged(value); - }, - - save() { - this.attrs.save(); - }, - - setWarning(message) { - this.set("warning", message); - }, - }, -}); + @action + setWarning(message) { + this.set("warning", message); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-user-field-item.hbs b/app/assets/javascripts/admin/addon/components/admin-user-field-item.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-user-field-item.hbs rename to app/assets/javascripts/admin/addon/components/admin-user-field-item.hbs diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs b/app/assets/javascripts/admin/addon/components/admin-watched-word.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs rename to app/assets/javascripts/admin/addon/components/admin-watched-word.hbs diff --git a/app/assets/javascripts/admin/addon/components/admin-watched-word.js b/app/assets/javascripts/admin/addon/components/admin-watched-word.js index 7de7e3d8ab5..d6bde8016d2 100644 --- a/app/assets/javascripts/admin/addon/components/admin-watched-word.js +++ b/app/assets/javascripts/admin/addon/components/admin-watched-word.js @@ -1,23 +1,27 @@ -import Component from "@ember/component"; +import { classNames } from "@ember-decorators/component"; +import { inject as service } from "@ember/service"; import { alias, equal } from "@ember/object/computed"; +import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; import { action } from "@ember/object"; import I18n from "I18n"; -import { inject as service } from "@ember/service"; -export default Component.extend({ - classNames: ["watched-word"], - dialog: service(), +@classNames("watched-word") +export default class AdminWatchedWord extends Component { + @service dialog; - isReplace: equal("actionKey", "replace"), - isTag: equal("actionKey", "tag"), - isLink: equal("actionKey", "link"), - isCaseSensitive: alias("word.case_sensitive"), + @equal("actionKey", "replace") isReplace; + + @equal("actionKey", "tag") isTag; + + @equal("actionKey", "link") isLink; + + @alias("word.case_sensitive") isCaseSensitive; @discourseComputed("word.replacement") tags(replacement) { return replacement.split(","); - }, + } @action deleteWord() { @@ -33,5 +37,5 @@ export default Component.extend({ }) ); }); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/components/admin-wrapper.js b/app/assets/javascripts/admin/addon/components/admin-wrapper.js index d6a3564d420..98d18997d4f 100644 --- a/app/assets/javascripts/admin/addon/components/admin-wrapper.js +++ b/app/assets/javascripts/admin/addon/components/admin-wrapper.js @@ -1,14 +1,15 @@ import Component from "@ember/component"; -export default Component.extend({ + +export default class AdminWrapper extends Component { didInsertElement() { - this._super(...arguments); + super.didInsertElement(...arguments); document.querySelector("html").classList.add("admin-area"); document.querySelector("body").classList.add("admin-interface"); - }, + } willDestroyElement() { - this._super(...arguments); + super.willDestroyElement(...arguments); document.querySelector("html").classList.remove("admin-area"); document.querySelector("body").classList.remove("admin-interface"); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/components/cancel-link.js b/app/assets/javascripts/admin/addon/components/cancel-link.js index 0e6d50b17d4..aa5d4dda65c 100644 --- a/app/assets/javascripts/admin/addon/components/cancel-link.js +++ b/app/assets/javascripts/admin/addon/components/cancel-link.js @@ -1,4 +1,5 @@ +import { tagName } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - tagName: "", -}); + +@tagName("") +export default class CancelLink extends Component {} diff --git a/app/assets/javascripts/discourse/app/templates/components/color-input.hbs b/app/assets/javascripts/admin/addon/components/color-input.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/color-input.hbs rename to app/assets/javascripts/admin/addon/components/color-input.hbs diff --git a/app/assets/javascripts/admin/addon/components/color-input.js b/app/assets/javascripts/admin/addon/components/color-input.js index 2974b43871a..9c1c67f0442 100644 --- a/app/assets/javascripts/admin/addon/components/color-input.js +++ b/app/assets/javascripts/admin/addon/components/color-input.js @@ -1,6 +1,7 @@ +import { classNames } from "@ember-decorators/component"; import { action, computed } from "@ember/object"; import Component from "@ember/component"; -import { observes } from "discourse-common/utils/decorators"; +import { observes } from "@ember-decorators/object"; /** An input field for a color. @@ -9,20 +10,20 @@ import { observes } from "discourse-common/utils/decorators"; @param brightnessValue is a number from 0 to 255 representing the brightness of the color. See ColorSchemeColor. @params valid is a boolean indicating if the input field is a valid color. **/ -export default Component.extend({ - classNames: ["color-picker"], +@classNames("color-picker") +export default class ColorInput extends Component { + onlyHex = true; + styleSelection = true; - onlyHex: true, - - styleSelection: true, - - maxlength: computed("onlyHex", function () { + @computed("onlyHex") + get maxlength() { return this.onlyHex ? 6 : null; - }), + } - normalizedHexValue: computed("hexValue", function () { + @computed("hexValue") + get normalizedHexValue() { return this.normalize(this.hexValue); - }), + } normalize(color) { if (this._valid(color)) { @@ -40,19 +41,19 @@ export default Component.extend({ } } return color; - }, + } @action onHexInput(color) { if (this.attrs.onChangeColor) { this.attrs.onChangeColor(this.normalize(color || "")); } - }, + } @action onPickerInput(event) { this.set("hexValue", event.target.value.replace("#", "")); - }, + } @observes("hexValue", "brightnessValue", "valid") hexValueChanged() { @@ -65,9 +66,9 @@ export default Component.extend({ if (this._valid()) { this.element.querySelector(".picker").value = this.normalize(hex); } - }, + } _valid(color = this.hexValue) { return /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs b/app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs rename to app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.hbs diff --git a/app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.js b/app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.js index 87d5ddb040f..80e6cd8a167 100644 --- a/app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.js +++ b/app/assets/javascripts/admin/addon/components/dashboard-new-feature-item.js @@ -1,3 +1,3 @@ import Component from "@ember/component"; -export default Component.extend({}); +export default class DashboardNewFeatureItem extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs b/app/assets/javascripts/admin/addon/components/dashboard-new-features.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs rename to app/assets/javascripts/admin/addon/components/dashboard-new-features.hbs diff --git a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js index 6e8defb93c4..46b7559585b 100644 --- a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js +++ b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js @@ -1,18 +1,19 @@ +import { classNameBindings, classNames } from "@ember-decorators/component"; import Component from "@ember/component"; import { action, computed } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; -export default Component.extend({ - newFeatures: null, - classNames: ["section", "dashboard-new-features"], - classNameBindings: ["hasUnseenFeatures:ordered-first"], - releaseNotesLink: null, +@classNames("section", "dashboard-new-features") +@classNameBindings("hasUnseenFeatures:ordered-first") +export default class DashboardNewFeatures extends Component { + newFeatures = null; + releaseNotesLink = null; - init() { - this._super(...arguments); + constructor() { + super(...arguments); ajax("/admin/dashboard/new-features.json").then((json) => { - if (!this.element || this.isDestroying || this.isDestroyed) { + if (this.isDestroying || this.isDestroyed) { return; } @@ -22,16 +23,17 @@ export default Component.extend({ releaseNotesLink: json.release_notes_link, }); }); - }, + } - columnCountClass: computed("newFeatures", function () { + @computed("newFeatures") + get columnCountClass() { return this.newFeatures.length > 2 ? "three-or-more-items" : ""; - }), + } @action dismissNewFeatures() { ajax("/admin/dashboard/mark-new-features-as-seen.json", { type: "PUT", }).then(() => this.set("hasUnseenFeatures", false)); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-problems.hbs b/app/assets/javascripts/admin/addon/components/dashboard-problems.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/dashboard-problems.hbs rename to app/assets/javascripts/admin/addon/components/dashboard-problems.hbs diff --git a/app/assets/javascripts/admin/addon/components/dashboard-problems.js b/app/assets/javascripts/admin/addon/components/dashboard-problems.js index 87d5ddb040f..c4df8401e0f 100644 --- a/app/assets/javascripts/admin/addon/components/dashboard-problems.js +++ b/app/assets/javascripts/admin/addon/components/dashboard-problems.js @@ -1,3 +1,3 @@ import Component from "@ember/component"; -export default Component.extend({}); +export default class DashboardProblems extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/email-styles-editor.hbs b/app/assets/javascripts/admin/addon/components/email-styles-editor.hbs similarity index 96% rename from app/assets/javascripts/admin/addon/templates/components/email-styles-editor.hbs rename to app/assets/javascripts/admin/addon/components/email-styles-editor.hbs index 1ca8055f6b5..78b63780e11 100644 --- a/app/assets/javascripts/admin/addon/templates/components/email-styles-editor.hbs +++ b/app/assets/javascripts/admin/addon/components/email-styles-editor.hbs @@ -29,7 +29,7 @@ @content={{this.editorContents}} @mode={{this.currentEditorMode}} @editorId={{this.editorId}} - @save={{action "save"}} + @save={{@save}} /> {{this.host.host}} - -
- {{i18n "admin.embedding.class_name"}} -
- {{this.host.class_name}} -
{{i18n "admin.embedding.allowed_paths"}} diff --git a/app/assets/javascripts/admin/addon/components/embeddable-host.js b/app/assets/javascripts/admin/addon/components/embeddable-host.js index 320b173bfb5..2375aa81412 100644 --- a/app/assets/javascripts/admin/addon/components/embeddable-host.js +++ b/app/assets/javascripts/admin/addon/components/embeddable-host.js @@ -1,85 +1,91 @@ +import { action } from "@ember/object"; +import { tagName } from "@ember-decorators/component"; +import { inject as service } from "@ember/service"; +import { or } from "@ember/object/computed"; import Category from "discourse/models/category"; import Component from "@ember/component"; import I18n from "I18n"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import discourseComputed from "discourse-common/utils/decorators"; -import { inject as service } from "@ember/service"; import { isEmpty } from "@ember/utils"; -import { or } from "@ember/object/computed"; import { popupAjaxError } from "discourse/lib/ajax-error"; -export default Component.extend(bufferedProperty("host"), { - editToggled: false, - tagName: "tr", - categoryId: null, - category: null, - dialog: service(), +@tagName("tr") +export default class EmbeddableHost extends Component.extend( + bufferedProperty("host") +) { + @service dialog; + editToggled = false; + categoryId = null; + category = null; - editing: or("host.isNew", "editToggled"), + @or("host.isNew", "editToggled") editing; init() { - this._super(...arguments); + super.init(...arguments); const host = this.host; const categoryId = host.category_id || this.site.uncategorized_category_id; const category = Category.findById(categoryId); host.set("category", category); - }, + } @discourseComputed("buffered.host", "host.isSaving") cantSave(host, isSaving) { return isSaving || isEmpty(host); - }, + } - actions: { - edit() { - this.set("categoryId", this.get("host.category.id")); - this.set("editToggled", true); - }, + @action + edit() { + this.set("categoryId", this.get("host.category.id")); + this.set("editToggled", true); + } - save() { - if (this.cantSave) { - return; - } + @action + save() { + if (this.cantSave) { + return; + } - const props = this.buffered.getProperties( - "host", - "allowed_paths", - "class_name" - ); - props.category_id = this.categoryId; + const props = this.buffered.getProperties( + "host", + "allowed_paths", + "class_name" + ); + props.category_id = this.categoryId; - const host = this.host; + const host = this.host; - host - .save(props) - .then(() => { - host.set("category", Category.findById(this.categoryId)); - this.set("editToggled", false); - }) - .catch(popupAjaxError); - }, - - delete() { - return this.dialog.confirm({ - message: I18n.t("admin.embedding.confirm_delete"), - didConfirm: () => { - return this.host.destroyRecord().then(() => { - this.deleteHost(this.host); - }); - }, - }); - }, - - cancel() { - const host = this.host; - if (host.get("isNew")) { - this.deleteHost(host); - } else { - this.rollbackBuffer(); + host + .save(props) + .then(() => { + host.set("category", Category.findById(this.categoryId)); this.set("editToggled", false); - } - }, - }, -}); + }) + .catch(popupAjaxError); + } + + @action + delete() { + return this.dialog.confirm({ + message: I18n.t("admin.embedding.confirm_delete"), + didConfirm: () => { + return this.host.destroyRecord().then(() => { + this.deleteHost(this.host); + }); + }, + }); + } + + @action + cancel() { + const host = this.host; + if (host.get("isNew")) { + this.deleteHost(host); + } else { + this.rollbackBuffer(); + this.set("editToggled", false); + } + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/embedding-setting.hbs b/app/assets/javascripts/admin/addon/components/embedding-setting.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/embedding-setting.hbs rename to app/assets/javascripts/admin/addon/components/embedding-setting.hbs diff --git a/app/assets/javascripts/admin/addon/components/embedding-setting.js b/app/assets/javascripts/admin/addon/components/embedding-setting.js index f571d47cf61..85c26e7ea6d 100644 --- a/app/assets/javascripts/admin/addon/components/embedding-setting.js +++ b/app/assets/javascripts/admin/addon/components/embedding-setting.js @@ -1,33 +1,33 @@ +import { classNames } from "@ember-decorators/component"; +import { computed } from "@ember/object"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; import { dasherize } from "@ember/string"; -export default Component.extend({ - classNames: ["embed-setting"], - +@classNames("embed-setting") +export default class EmbeddingSetting extends Component { @discourseComputed("field") inputId(field) { return dasherize(field); - }, + } @discourseComputed("field") translationKey(field) { return `admin.embedding.${field}`; - }, + } @discourseComputed("type") isCheckbox(type) { return type === "checkbox"; - }, + } - @discourseComputed("value") - checked: { - get(value) { - return !!value; - }, - set(value) { - this.set("value", value); - return value; - }, - }, -}); + @computed("value") + get checked() { + return !!this.value; + } + + set checked(value) { + this.set("value", value); + return value; + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs b/app/assets/javascripts/admin/addon/components/emoji-value-list.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs rename to app/assets/javascripts/admin/addon/components/emoji-value-list.hbs diff --git a/app/assets/javascripts/admin/addon/components/emoji-value-list.js b/app/assets/javascripts/admin/addon/components/emoji-value-list.js index 3f2810383d0..40363b874d8 100644 --- a/app/assets/javascripts/admin/addon/components/emoji-value-list.js +++ b/app/assets/javascripts/admin/addon/components/emoji-value-list.js @@ -1,3 +1,4 @@ +import { classNameBindings } from "@ember-decorators/component"; import Component from "@ember/component"; import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; @@ -6,12 +7,12 @@ import { action, set, setProperties } from "@ember/object"; import { schedule } from "@ember/runloop"; import discourseLater from "discourse-common/lib/later"; -export default Component.extend({ - classNameBindings: [":value-list", ":emoji-list"], - values: null, - validationMessage: null, - emojiPickerIsActive: false, - isEditorFocused: false, +@classNameBindings(":value-list", ":emoji-list") +export default class EmojiValueList extends Component { + values = null; + validationMessage = null; + emojiPickerIsActive = false; + isEditorFocused = false; @discourseComputed("values") collection(values) { @@ -28,14 +29,14 @@ export default Component.extend({ emojiUrl: emojiUrlFor(value), }; }); - }, + } @action closeEmojiPicker() { this.collection.setEach("isEditing", false); this.set("emojiPickerIsActive", false); this.set("isEditorFocused", false); - }, + } @action emojiSelected(code) { @@ -65,12 +66,12 @@ export default Component.extend({ this.set("emojiPickerIsActive", false); this.set("isEditorFocused", false); - }, + } @discourseComputed("collection") showUpDownButtons(collection) { return collection.length - 1 ? true : false; - }, + } _splitValues(values) { if (values && values.length) { @@ -91,7 +92,7 @@ export default Component.extend({ } else { return []; } - }, + } @action editValue(index) { @@ -111,12 +112,12 @@ export default Component.extend({ } }, 100); }); - }, + } @action removeValue(value) { this._removeValue(value); - }, + } @action shift(operation, index) { @@ -133,7 +134,7 @@ export default Component.extend({ this.collection.insertAt(futureIndex, shiftedEmoji); this._saveValues(); - }, + } _validateInput(input) { this.set("validationMessage", null); @@ -147,12 +148,12 @@ export default Component.extend({ } return true; - }, + } _removeValue(value) { this.collection.removeObject(value); this._saveValues(); - }, + } _replaceValue(index, newValue) { const item = this.collection[index]; @@ -161,9 +162,9 @@ export default Component.extend({ } set(item, "value", newValue); this._saveValues(); - }, + } _saveValues() { this.set("values", this.collection.mapBy("value").join("|")); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/flag-user-lists.hbs b/app/assets/javascripts/admin/addon/components/flag-user-lists.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/flag-user-lists.hbs rename to app/assets/javascripts/admin/addon/components/flag-user-lists.hbs diff --git a/app/assets/javascripts/admin/addon/components/flag-user-lists.js b/app/assets/javascripts/admin/addon/components/flag-user-lists.js index 8b886047689..0096ce0a575 100644 --- a/app/assets/javascripts/admin/addon/components/flag-user-lists.js +++ b/app/assets/javascripts/admin/addon/components/flag-user-lists.js @@ -1,4 +1,5 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - classNames: ["flag-user-lists"], -}); + +@classNames("flag-user-lists") +export default class FlagUserLists extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/flag-user.hbs b/app/assets/javascripts/admin/addon/components/flag-user.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/flag-user.hbs rename to app/assets/javascripts/admin/addon/components/flag-user.hbs diff --git a/app/assets/javascripts/admin/addon/components/flag-user.js b/app/assets/javascripts/admin/addon/components/flag-user.js index 87d5ddb040f..ef9b83f7803 100644 --- a/app/assets/javascripts/admin/addon/components/flag-user.js +++ b/app/assets/javascripts/admin/addon/components/flag-user.js @@ -1,3 +1,3 @@ import Component from "@ember/component"; -export default Component.extend({}); +export default class FlagUser extends Component {} diff --git a/app/assets/javascripts/admin/addon/components/form-template/form.hbs b/app/assets/javascripts/admin/addon/components/form-template/form.hbs new file mode 100644 index 00000000000..01b8caf4d42 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/form.hbs @@ -0,0 +1,78 @@ +
+
+ + +
+
+
+ + {{I18n "admin.form_templates.quick_insert_fields.add_new_field"}} + + {{#each this.quickInsertFields as |field|}} + + {{/each}} + +
+ +
+ +
+ +
+ + +
+ +{{#if this.showFormTemplateFormPreview}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/form.js b/app/assets/javascripts/admin/addon/components/form-template/form.js new file mode 100644 index 00000000000..0666eb26e14 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/form.js @@ -0,0 +1,137 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import I18n from "I18n"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { templateFormFields } from "admin/lib/template-form-fields"; +import FormTemplate from "admin/models/form-template"; +import showModal from "discourse/lib/show-modal"; + +export default class FormTemplateForm extends Component { + @service router; + @service dialog; + @tracked formSubmitted = false; + @tracked templateContent = this.args.model?.template || ""; + @tracked templateName = this.args.model?.name || ""; + @tracked showFormTemplateFormPreview; + isEditing = this.args.model?.id ? true : false; + quickInsertFields = [ + { + type: "checkbox", + icon: "check-square", + }, + { + type: "input", + icon: "grip-lines", + }, + { + type: "textarea", + icon: "align-left", + }, + { + type: "dropdown", + icon: "chevron-circle-down", + }, + { + type: "upload", + icon: "cloud-upload-alt", + }, + { + type: "multiselect", + icon: "bullseye", + }, + ]; + + get disablePreviewButton() { + return Boolean(!this.templateName.length || !this.templateContent.length); + } + + get disableSubmitButton() { + return ( + Boolean(!this.templateName.length || !this.templateContent.length) || + this.formSubmitted + ); + } + + @action + onSubmit() { + if (!this.formSubmitted) { + this.formSubmitted = true; + } + + const postData = { + name: this.templateName, + template: this.templateContent, + }; + + if (this.isEditing) { + postData["id"] = this.args.model.id; + } + + FormTemplate.createOrUpdateTemplate(postData) + .then(() => { + this.formSubmitted = false; + this.router.transitionTo("adminCustomizeFormTemplates.index"); + }) + .catch((e) => { + popupAjaxError(e); + this.formSubmitted = false; + }); + } + + @action + onCancel() { + this.router.transitionTo("adminCustomizeFormTemplates.index"); + } + + @action + onDelete() { + return this.dialog.yesNoConfirm({ + message: I18n.t("admin.form_templates.delete_confirm"), + didConfirm: () => { + FormTemplate.deleteTemplate(this.args.model.id) + .then(() => { + this.router.transitionTo("adminCustomizeFormTemplates.index"); + }) + .catch(popupAjaxError); + }, + }); + } + + @action + onInsertField(type) { + const structure = templateFormFields.findBy("type", type).structure; + + if (this.templateContent.length === 0) { + this.templateContent += structure; + } else { + this.templateContent += `\n${structure}`; + } + } + + @action + showValidationOptionsModal() { + return showModal("admin-form-template-validation-options", { + admin: true, + }); + } + + @action + showPreview() { + const data = { + name: this.templateName, + template: this.templateContent, + }; + + if (this.isEditing) { + data["id"] = this.args.model.id; + } + + FormTemplate.validateTemplate(data) + .then(() => { + this.showFormTemplateFormPreview = true; + }) + .catch(popupAjaxError); + } +} diff --git a/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs b/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs new file mode 100644 index 00000000000..e9821a56017 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs @@ -0,0 +1,4 @@ +
+

{{i18n "admin.form_templates.title"}}

+

{{i18n "admin.form_templates.help"}}

+
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs b/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs new file mode 100644 index 00000000000..c7c1791b260 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs @@ -0,0 +1,36 @@ + + {{@template.name}} + + {{#each this.activeCategories as |category|}} + {{category-link category}} + {{/each}} + + + + + + + + +{{#if this.showViewTemplateModal}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/row-item.js b/app/assets/javascripts/admin/addon/components/form-template/row-item.js new file mode 100644 index 00000000000..f95df2bb5ce --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/row-item.js @@ -0,0 +1,44 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +export default class FormTemplateRowItem extends Component { + @service router; + @service dialog; + @service site; + + get activeCategories() { + return this.site.categories?.filter((c) => + c["form_template_ids"].includes(this.args.template.id) + ); + } + + @action + editTemplate() { + this.router.transitionTo( + "adminCustomizeFormTemplates.edit", + this.args.template + ); + } + + @action + deleteTemplate() { + return this.dialog.yesNoConfirm({ + message: I18n.t("admin.form_templates.delete_confirm", { + template_name: this.args.template.name, + }), + didConfirm: () => { + ajax(`/admin/customize/form-templates/${this.args.template.id}.json`, { + type: "DELETE", + }) + .then(() => { + this.args.refreshModel(); + }) + .catch(popupAjaxError); + }, + }); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/highlighted-code.hbs b/app/assets/javascripts/admin/addon/components/highlighted-code.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/highlighted-code.hbs rename to app/assets/javascripts/admin/addon/components/highlighted-code.hbs diff --git a/app/assets/javascripts/admin/addon/components/highlighted-code.js b/app/assets/javascripts/admin/addon/components/highlighted-code.js index 21cfaf6b154..782754682f3 100644 --- a/app/assets/javascripts/admin/addon/components/highlighted-code.js +++ b/app/assets/javascripts/admin/addon/components/highlighted-code.js @@ -1,11 +1,11 @@ -import { observes, on } from "discourse-common/utils/decorators"; +import { observes, on } from "@ember-decorators/object"; import Component from "@ember/component"; import highlightSyntax from "discourse/lib/highlight-syntax"; -export default Component.extend({ +export default class HighlightedCode extends Component { @on("didInsertElement") @observes("code") _refresh() { highlightSyntax(this.element, this.siteSettings, this.session); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/inline-edit-checkbox.hbs b/app/assets/javascripts/admin/addon/components/inline-edit-checkbox.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/inline-edit-checkbox.hbs rename to app/assets/javascripts/admin/addon/components/inline-edit-checkbox.hbs diff --git a/app/assets/javascripts/admin/addon/components/inline-edit-checkbox.js b/app/assets/javascripts/admin/addon/components/inline-edit-checkbox.js index cfbd2f82f98..8618e4044b4 100644 --- a/app/assets/javascripts/admin/addon/components/inline-edit-checkbox.js +++ b/app/assets/javascripts/admin/addon/components/inline-edit-checkbox.js @@ -1,15 +1,15 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; import { action } from "@ember/object"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend({ - classNames: ["inline-edit"], - - buffer: null, - bufferModelId: null, +@classNames("inline-edit") +export default class InlineEditCheckbox extends Component { + buffer = null; + bufferModelId = null; didReceiveAttrs() { - this._super(...arguments); + super.didReceiveAttrs(...arguments); if (this.modelId !== this.bufferModelId) { // HACK: The condition above ensures this method is called only when its @@ -24,21 +24,21 @@ export default Component.extend({ bufferModelId: this.modelId, }); } - }, + } @discourseComputed("checked", "buffer") changed(checked, buffer) { return !!checked !== !!buffer; - }, + } @action apply() { this.set("checked", this.buffer); this.action(); - }, + } @action cancel() { this.set("buffer", this.checked); - }, -}); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/components/install-theme-item.hbs b/app/assets/javascripts/admin/addon/components/install-theme-item.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/install-theme-item.hbs rename to app/assets/javascripts/admin/addon/components/install-theme-item.hbs diff --git a/app/assets/javascripts/admin/addon/components/install-theme-item.js b/app/assets/javascripts/admin/addon/components/install-theme-item.js index b8d8823109d..459dc5dc673 100644 --- a/app/assets/javascripts/admin/addon/components/install-theme-item.js +++ b/app/assets/javascripts/admin/addon/components/install-theme-item.js @@ -1,4 +1,5 @@ +import { classNames } from "@ember-decorators/component"; import Component from "@ember/component"; -export default Component.extend({ - classNames: ["install-theme-item"], -}); + +@classNames("install-theme-item") +export default class InstallThemeItem extends Component {} diff --git a/app/assets/javascripts/admin/addon/templates/components/ip-lookup.hbs b/app/assets/javascripts/admin/addon/components/ip-lookup.hbs similarity index 100% rename from app/assets/javascripts/admin/addon/templates/components/ip-lookup.hbs rename to app/assets/javascripts/admin/addon/components/ip-lookup.hbs diff --git a/app/assets/javascripts/admin/addon/components/ip-lookup.js b/app/assets/javascripts/admin/addon/components/ip-lookup.js index 02a52405645..42c5f128116 100644 --- a/app/assets/javascripts/admin/addon/components/ip-lookup.js +++ b/app/assets/javascripts/admin/addon/components/ip-lookup.js @@ -1,3 +1,5 @@ +import { classNames } from "@ember-decorators/component"; +import { inject as service } from "@ember/service"; import AdminUser from "admin/models/admin-user"; import Component from "@ember/component"; import EmberObject, { action } from "@ember/object"; @@ -6,12 +8,11 @@ import { ajax } from "discourse/lib/ajax"; import copyText from "discourse/lib/copy-text"; import discourseComputed from "discourse-common/utils/decorators"; import discourseLater from "discourse-common/lib/later"; -import { inject as service } from "@ember/service"; import { popupAjaxError } from "discourse/lib/ajax-error"; -export default Component.extend({ - classNames: ["ip-lookup"], - dialog: service(), +@classNames("ip-lookup") +export default class IpLookup extends Component { + @service dialog; @discourseComputed("other_accounts.length", "totalOthersWithSameIP") otherAccountsToDelete(otherAccountsLength, totalOthersWithSameIP) { @@ -19,101 +20,100 @@ export default Component.extend({ const total = Math.min(50, totalOthersWithSameIP || 0); const visible = Math.min(50, otherAccountsLength || 0); return Math.max(visible, total); - }, + } @action hide(event) { event?.preventDefault(); this.set("show", false); - }, + } - actions: { - lookup() { - this.set("show", true); + @action + lookup() { + this.set("show", true); - if (!this.location) { - ajax("/admin/users/ip-info", { - data: { ip: this.ip }, - }).then((location) => - this.set("location", EmberObject.create(location)) - ); - } + if (!this.location) { + ajax("/admin/users/ip-info", { + data: { ip: this.ip }, + }).then((location) => this.set("location", EmberObject.create(location))); + } - if (!this.other_accounts) { - this.set("otherAccountsLoading", true); + if (!this.other_accounts) { + this.set("otherAccountsLoading", true); - const data = { - ip: this.ip, - exclude: this.userId, - order: "trust_level DESC", - }; + const data = { + ip: this.ip, + exclude: this.userId, + order: "trust_level DESC", + }; - ajax("/admin/users/total-others-with-same-ip", { - data, - }).then((result) => this.set("totalOthersWithSameIP", result.total)); + ajax("/admin/users/total-others-with-same-ip", { + data, + }).then((result) => this.set("totalOthersWithSameIP", result.total)); - AdminUser.findAll("active", data).then((users) => { - this.setProperties({ - other_accounts: users, - otherAccountsLoading: false, - }); + AdminUser.findAll("active", data).then((users) => { + this.setProperties({ + other_accounts: users, + otherAccountsLoading: false, }); - } - }, - - copy() { - let text = `IP: ${this.ip}\n`; - const location = this.location; - if (location) { - if (location.hostname) { - text += `${I18n.t("ip_lookup.hostname")}: ${location.hostname}\n`; - } - - text += I18n.t("ip_lookup.location"); - if (location.location) { - text += `: ${location.location}\n`; - } else { - text += `: ${I18n.t("ip_lookup.location_not_found")}\n`; - } - - if (location.organization) { - text += I18n.t("ip_lookup.organisation"); - text += `: ${location.organization}\n`; - } - } - - const $copyRange = $('

'); - $copyRange.html(text.trim().replace(/\n/g, "
")); - $(document.body).append($copyRange); - if (copyText(text, $copyRange[0])) { - this.set("copied", true); - discourseLater(() => this.set("copied", false), 2000); - } - $copyRange.remove(); - }, - - deleteOtherAccounts() { - this.dialog.yesNoConfirm({ - message: I18n.t("ip_lookup.confirm_delete_other_accounts"), - didConfirm: () => { - this.setProperties({ - other_accounts: null, - otherAccountsLoading: true, - totalOthersWithSameIP: null, - }); - - ajax("/admin/users/delete-others-with-same-ip.json", { - type: "DELETE", - data: { - ip: this.ip, - exclude: this.userId, - order: "trust_level DESC", - }, - }) - .catch(popupAjaxError) - .finally(this.send("lookup")); - }, }); - }, - }, -}); + } + } + + @action + copy() { + let text = `IP: ${this.ip}\n`; + const location = this.location; + if (location) { + if (location.hostname) { + text += `${I18n.t("ip_lookup.hostname")}: ${location.hostname}\n`; + } + + text += I18n.t("ip_lookup.location"); + if (location.location) { + text += `: ${location.location}\n`; + } else { + text += `: ${I18n.t("ip_lookup.location_not_found")}\n`; + } + + if (location.organization) { + text += I18n.t("ip_lookup.organisation"); + text += `: ${location.organization}\n`; + } + } + + const $copyRange = $('

'); + $copyRange.html(text.trim().replace(/\n/g, "
")); + $(document.body).append($copyRange); + if (copyText(text, $copyRange[0])) { + this.set("copied", true); + discourseLater(() => this.set("copied", false), 2000); + } + $copyRange.remove(); + } + + @action + deleteOtherAccounts() { + this.dialog.yesNoConfirm({ + message: I18n.t("ip_lookup.confirm_delete_other_accounts"), + didConfirm: () => { + this.setProperties({ + other_accounts: null, + otherAccountsLoading: true, + totalOthersWithSameIP: null, + }); + + ajax("/admin/users/delete-others-with-same-ip.json", { + type: "DELETE", + data: { + ip: this.ip, + exclude: this.userId, + order: "trust_level DESC", + }, + }) + .catch(popupAjaxError) + .finally(this.send("lookup")); + }, + }); + } +} diff --git a/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.hbs b/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.hbs new file mode 100644 index 00000000000..8d8680d9b62 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.hbs @@ -0,0 +1,36 @@ + + <:body> +

{{html-safe + (i18n + "admin.user.delete_posts.confirmation.description" + username=@model.user.username + post_count=@model.user.post_count + text=this.text + ) + }}

+ + + <:footer> + + + +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.js b/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.js new file mode 100644 index 00000000000..62d8dadfe35 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/modal/delete-posts-confirmation.js @@ -0,0 +1,18 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import I18n from "I18n"; + +export default class DeletePostsConfirmation extends Component { + @tracked value; + + get text() { + return I18n.t("admin.user.delete_posts.confirmation.text", { + username: this.args.model.user.username, + post_count: this.args.model.user.post_count, + }); + } + + get deleteDisabled() { + return !this.value || this.text !== this.value; + } +} diff --git a/app/assets/javascripts/admin/addon/components/modal/incoming-email.hbs b/app/assets/javascripts/admin/addon/components/modal/incoming-email.hbs new file mode 100644 index 00000000000..e1beb0f6664 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/modal/incoming-email.hbs @@ -0,0 +1,54 @@ + + <:body> +
+ +
+

{{@model.error}}

+ {{#if @model.error_description}} +

{{@model.error_description}}

+ {{/if}} +
+
+ +
+ +
+ +
+ + + +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-button.hbs b/app/assets/javascripts/discourse/app/components/badge-button.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/badge-button.hbs rename to app/assets/javascripts/discourse/app/components/badge-button.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs b/app/assets/javascripts/discourse/app/components/badge-card.hbs similarity index 67% rename from app/assets/javascripts/discourse/app/templates/components/badge-card.hbs rename to app/assets/javascripts/discourse/app/components/badge-card.hbs index 94d492ccb68..ba023121086 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs +++ b/app/assets/javascripts/discourse/app/components/badge-card.hbs @@ -1,10 +1,3 @@ -{{#if this.displayCount}} - {{this.displayCount}} -{{/if}} {{#if this.badge.has_badge}} {{d-icon "check" @@ -34,6 +27,10 @@ {{/if}}
+

{{this.badge.name}}

{{html-safe this.summary}}
+ + {{#if this.displayCount}} + + + {{html-safe + (i18n + "badges.awarded" + count=this.displayCount + number=(number this.displayCount) + ) + }} + + + {{/if}}
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-selector.hbs b/app/assets/javascripts/discourse/app/components/badge-selector.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/badge-selector.hbs rename to app/assets/javascripts/discourse/app/components/badge-selector.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs b/app/assets/javascripts/discourse/app/components/badge-title.hbs similarity index 94% rename from app/assets/javascripts/discourse/app/templates/components/badge-title.hbs rename to app/assets/javascripts/discourse/app/components/badge-title.hbs index 62e7b1e8863..44238820190 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs +++ b/app/assets/javascripts/discourse/app/components/badge-title.hbs @@ -1,5 +1,5 @@
-
+
diff --git a/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs similarity index 88% rename from app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs rename to app/assets/javascripts/discourse/app/components/basic-topic-list.hbs index 596973ac661..478050fce40 100644 --- a/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs +++ b/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs @@ -10,8 +10,6 @@ @canBulkSelect={{this.canBulkSelect}} @selected={{this.selected}} @tagsForUser={{this.tagsForUser}} - @onScroll={{this.onScroll}} - @scrollOnLoad={{this.scrollOnLoad}} @toggleBulkSelect={{this.toggleBulkSelect}} @updateAutoAddTopicsToBulkSelect={{this.updateAutoAddTopicsToBulkSelect}} /> diff --git a/app/assets/javascripts/discourse/app/templates/components/bookmark-icon.hbs b/app/assets/javascripts/discourse/app/components/bookmark-icon.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/bookmark-icon.hbs rename to app/assets/javascripts/discourse/app/components/bookmark-icon.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs b/app/assets/javascripts/discourse/app/components/bookmark-list.hbs similarity index 98% rename from app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs rename to app/assets/javascripts/discourse/app/components/bookmark-list.hbs index b57c4372a2f..86a0b678c90 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs +++ b/app/assets/javascripts/discourse/app/components/bookmark-list.hbs @@ -89,7 +89,8 @@ }} {{/if}} - {{! template-lint-disable }} + + {{! template-lint-disable no-invalid-interactive }}

= 0) { - schedule("afterRender", () => { - if (this.element && !this.isDestroying && !this.isDestroyed) { - next(() => window.scrollTo(0, scrollTo)); - } - }); - } - }, - - scrolled() { - this._super(...arguments); - this.session.set("bookmarkListScrollPosition", window.scrollY); - }, - @action removeBookmark(bookmark) { return new Promise((resolve, reject) => { @@ -84,17 +57,20 @@ export default Component.extend(Scrolling, { @action editBookmark(bookmark) { - openBookmarkModal(bookmark, { - onAfterSave: (savedData) => { - this.appEvents.trigger( - "bookmarks:changed", - savedData, - bookmark.attachedTo() - ); - this.reload(); - }, - onAfterDelete: () => { - this.reload(); + this.modal.show(BookmarkModal, { + model: { + bookmark: new BookmarkFormData(bookmark), + afterSave: (savedData) => { + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + this.reload(); + }, + afterDelete: () => { + this.reload(); + }, }, }); }, diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js deleted file mode 100644 index 51cce43e7ce..00000000000 --- a/app/assets/javascripts/discourse/app/components/bookmark.js +++ /dev/null @@ -1,412 +0,0 @@ -import { now, parseCustomDatetime, startOfDay } from "discourse/lib/time-utils"; -import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; -import Component from "@ember/component"; -import I18n from "I18n"; -import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; -import ItsATrap from "@discourse/itsatrap"; -import { Promise } from "rsvp"; -import { - TIME_SHORTCUT_TYPES, - defaultTimeShortcuts, -} from "discourse/lib/time-shortcut"; -import { action } from "@ember/object"; -import { ajax } from "discourse/lib/ajax"; -import discourseComputed, { bind } from "discourse-common/utils/decorators"; -import { formattedReminderTime } from "discourse/lib/bookmark"; -import { and, notEmpty } from "@ember/object/computed"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import discourseLater from "discourse-common/lib/later"; - -import { inject as service } from "@ember/service"; - -const BOOKMARK_BINDINGS = { - enter: { handler: "saveAndClose" }, - "d d": { handler: "delete" }, -}; - -export default Component.extend({ - dialog: service(), - tagName: "", - errorMessage: null, - selectedReminderType: null, - _closeWithoutSaving: null, - _savingBookmarkManually: null, - _saving: null, - _deleting: null, - _itsatrap: null, - postDetectedLocalDate: null, - postDetectedLocalTime: null, - postDetectedLocalTimezone: null, - prefilledDatetime: null, - userTimezone: null, - showOptions: null, - model: null, - afterSave: null, - - init() { - this._super(...arguments); - - this.setProperties({ - errorMessage: null, - selectedReminderType: TIME_SHORTCUT_TYPES.NONE, - _closeWithoutSaving: false, - _savingBookmarkManually: false, - _saving: false, - _deleting: false, - postDetectedLocalDate: null, - postDetectedLocalTime: null, - postDetectedLocalTimezone: null, - prefilledDatetime: null, - userTimezone: this.currentUser.user_option.timezone, - showOptions: false, - _itsatrap: new ItsATrap(), - autoDeletePreference: - this.model.autoDeletePreference || - AUTO_DELETE_PREFERENCES.CLEAR_REMINDER, - }); - - this.registerOnCloseHandler(this._onModalClose); - this._bindKeyboardShortcuts(); - - if (this.editingExistingBookmark) { - this._initializeExistingBookmarkData(); - } - - this._loadPostLocalDates(); - }, - - didInsertElement() { - this._super(...arguments); - - discourseLater(() => { - if (this.site.isMobileDevice) { - document.getElementById("bookmark-name").blur(); - } - }); - - // we want to make sure the options panel opens so the user - // knows they have set these options previously. - if (this.model.id) { - this.set("showOptions", true); - } else { - document.getElementById("tap_tile_none").classList.add("active"); - } - }, - - _initializeExistingBookmarkData() { - if (this.existingBookmarkHasReminder) { - this.set("prefilledDatetime", this.model.reminderAt); - - let parsedDatetime = parseCustomDatetime( - this.prefilledDatetime, - null, - this.userTimezone - ); - - this.set("selectedDatetime", parsedDatetime); - } - }, - - _bindKeyboardShortcuts() { - KeyboardShortcuts.pause(); - - Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => { - this._itsatrap.bind(shortcut, () => { - let binding = BOOKMARK_BINDINGS[shortcut]; - this.send(binding.handler); - return false; - }); - }); - }, - - _loadPostLocalDates() { - if (this.model.bookmarkableType !== "Post") { - return; - } - - let postEl = document.querySelector( - `[data-post-id="${this.model.bookmarkableId}"]` - ); - let localDateEl; - if (postEl) { - localDateEl = postEl.querySelector(".discourse-local-date"); - } - - if (localDateEl) { - this.setProperties({ - postDetectedLocalDate: localDateEl.dataset.date, - postDetectedLocalTime: localDateEl.dataset.time, - postDetectedLocalTimezone: localDateEl.dataset.timezone, - }); - } - }, - - _saveBookmark() { - let reminderAt; - if (this.selectedReminderType) { - reminderAt = this.selectedDatetime; - } - - const reminderAtISO = reminderAt ? reminderAt.toISOString() : null; - - if (this.selectedReminderType === TIME_SHORTCUT_TYPES.CUSTOM) { - if (!reminderAt) { - return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime")); - } - } - - const data = { - reminder_at: reminderAtISO, - name: this.model.name, - id: this.model.id, - auto_delete_preference: this.autoDeletePreference, - }; - - data.bookmarkable_id = this.model.bookmarkableId; - data.bookmarkable_type = this.model.bookmarkableType; - - if (this.editingExistingBookmark) { - return ajax(`/bookmarks/${this.model.id}`, { - type: "PUT", - data, - }).then((response) => { - this._executeAfterSave(response, reminderAtISO); - }); - } else { - return ajax("/bookmarks", { type: "POST", data }).then((response) => { - this._executeAfterSave(response, reminderAtISO); - }); - } - }, - - _executeAfterSave(response, reminderAtISO) { - if (!this.afterSave) { - return; - } - - const data = { - reminder_at: reminderAtISO, - auto_delete_preference: this.autoDeletePreference, - id: this.model.id || response.id, - name: this.model.name, - }; - - data.bookmarkable_id = this.model.bookmarkableId; - data.bookmarkable_type = this.model.bookmarkableType; - - this.afterSave(data); - }, - - _deleteBookmark() { - return ajax("/bookmarks/" + this.model.id, { - type: "DELETE", - }).then((response) => { - if (this.afterDelete) { - this.afterDelete(response.topic_bookmarked, this.model.id); - } - }); - }, - - _postLocalDate() { - let parsedPostLocalDate = parseCustomDatetime( - this.postDetectedLocalDate, - this.postDetectedLocalTime, - this.userTimezone, - this.postDetectedLocalTimezone - ); - - if (!this.postDetectedLocalTime) { - return startOfDay(parsedPostLocalDate); - } - - return parsedPostLocalDate; - }, - - _handleSaveError(e) { - this._savingBookmarkManually = false; - if (typeof e === "string") { - this.dialog.alert(e); - } else { - popupAjaxError(e); - } - }, - - @bind - _onModalClose(closeOpts) { - // we want to close without saving if the user already saved - // manually or deleted the bookmark, as well as when the modal - // is just closed with the X button - this._closeWithoutSaving = - this._closeWithoutSaving || - closeOpts.initiatedByCloseButton || - closeOpts.initiatedByESC; - - if (!this._closeWithoutSaving && !this._savingBookmarkManually) { - this._saveBookmark().catch((e) => this._handleSaveError(e)); - } - if (this.onCloseWithoutSaving && this._closeWithoutSaving) { - this.onCloseWithoutSaving(); - } - }, - - willDestroyElement() { - this._super(...arguments); - - this._itsatrap?.destroy(); - this.set("_itsatrap", null); - KeyboardShortcuts.unpause(); - }, - - @discourseComputed("model.reminderAt") - showExistingReminderAt(reminderAt) { - return reminderAt && Date.parse(reminderAt) > new Date().getTime(); - }, - - showDelete: notEmpty("model.id"), - userHasTimezoneSet: notEmpty("userTimezone"), - editingExistingBookmark: and("model", "model.id"), - existingBookmarkHasReminder: and("model", "model.id", "model.reminderAt"), - - @discourseComputed("postDetectedLocalDate", "postDetectedLocalTime") - showPostLocalDate(postDetectedLocalDate, postDetectedLocalTime) { - if (!postDetectedLocalTime || !postDetectedLocalDate) { - return; - } - - let postLocalDateTime = this._postLocalDate(); - if (postLocalDateTime < now(this.userTimezone)) { - return; - } - - return true; - }, - - @discourseComputed() - autoDeletePreferences: () => { - return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => { - return { - id: AUTO_DELETE_PREFERENCES[key], - name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`), - }; - }); - }, - - @discourseComputed("userTimezone") - timeOptions(userTimezone) { - const options = defaultTimeShortcuts(userTimezone); - - if (this.showPostLocalDate) { - options.push({ - icon: "globe-americas", - id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE, - label: "time_shortcut.post_local_date", - time: this._postLocalDate(), - timeFormatKey: "dates.long_no_year", - hidden: false, - }); - } - - return options; - }, - - @discourseComputed("existingBookmarkHasReminder") - customTimeShortcutLabels(existingBookmarkHasReminder) { - const labels = {}; - if (existingBookmarkHasReminder) { - labels[TIME_SHORTCUT_TYPES.NONE] = - "bookmarks.remove_reminder_keep_bookmark"; - } - return labels; - }, - - @discourseComputed("editingExistingBookmark", "existingBookmarkHasReminder") - hiddenTimeShortcutOptions( - editingExistingBookmark, - existingBookmarkHasReminder - ) { - if (editingExistingBookmark && !existingBookmarkHasReminder) { - return [TIME_SHORTCUT_TYPES.NONE]; - } - - return []; - }, - - @discourseComputed("model.reminderAt") - existingReminderAtFormatted(existingReminderAt) { - return formattedReminderTime(existingReminderAt, this.userTimezone); - }, - - @action - saveAndClose() { - if (this._saving || this._deleting) { - return; - } - - this._saving = true; - this._savingBookmarkManually = true; - return this._saveBookmark() - .then(() => this.closeModal()) - .catch((e) => this._handleSaveError(e)) - .finally(() => (this._saving = false)); - }, - - @action - toggleShowOptions() { - this.toggleProperty("showOptions"); - }, - - @action - delete() { - if (!this.model.id) { - return; - } - - this._deleting = true; - let deleteAction = () => { - this._closeWithoutSaving = true; - this._deleteBookmark() - .then(() => { - this._deleting = false; - this.closeModal(); - }) - .catch((e) => this._handleSaveError(e)); - }; - - if (this.existingBookmarkHasReminder) { - this.dialog.deleteConfirm({ - message: I18n.t("bookmarks.confirm_delete"), - didConfirm: () => deleteAction(), - }); - } else { - deleteAction(); - } - }, - - @action - closeWithoutSavingBookmark() { - this._closeWithoutSaving = true; - this.closeModal(); - }, - - @action - onTimeSelected(type, time) { - this.setProperties({ selectedReminderType: type, selectedDatetime: time }); - - // if the type is custom, we need to wait for the user to click save, as - // they could still be adjusting the date and time - if ( - ![TIME_SHORTCUT_TYPES.CUSTOM, TIME_SHORTCUT_TYPES.RELATIVE].includes(type) - ) { - return this.saveAndClose(); - } - }, - - @action - selectPostLocalDate(date) { - this.setProperties({ - selectedReminderType: this.reminderTypes.POST_LOCAL_DATE, - postLocalDate: date, - }); - return this.saveAndClose(); - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs new file mode 100644 index 00000000000..3078dc4c160 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.hbs @@ -0,0 +1,21 @@ + + {{#if this.showUserTip}} + + {{else}} + +

+
+ {{i18n "user_tips.admin_guide.title"}} +
+
+ {{i18n "user_tips.admin_guide.content_no_url"}} +
+
+ + {{/if}} + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js new file mode 100644 index 00000000000..128aff7fab0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bootstrap-mode-notice.js @@ -0,0 +1,37 @@ +import getURL from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import I18n from "I18n"; +import DiscourseURL from "discourse/lib/url"; + +export default class BootstrapModeNotice extends Component { + @service currentUser; + @service siteSettings; + + @tracked showUserTip = false; + + @action + setupUserTip() { + this.showUserTip = this.currentUser?.canSeeUserTip("admin_guide"); + } + + @action + routeToAdminGuide() { + this.showUserTip = false; + DiscourseURL.routeTo( + `/t/-/${this.siteSettings.admin_quick_start_topic_id}` + ); + } + + get adminGuideUrl() { + return getURL(`/t/-/${this.siteSettings.admin_quick_start_topic_id}`); + } + + get userTipContent() { + return I18n.t("user_tips.admin_guide.content", { + admin_guide_url: this.adminGuideUrl, + }); + } +} diff --git a/app/assets/javascripts/discourse/app/components/bootstrap_mode_notice.js b/app/assets/javascripts/discourse/app/components/bootstrap_mode_notice.js deleted file mode 100644 index 69914cfa04a..00000000000 --- a/app/assets/javascripts/discourse/app/components/bootstrap_mode_notice.js +++ /dev/null @@ -1,29 +0,0 @@ -import Component from "@glimmer/component"; -import { htmlSafe } from "@ember/template"; -import I18n from "I18n"; -import { inject as service } from "@ember/service"; -import { action } from "@ember/object"; -import showModal from "discourse/lib/show-modal"; - -export default class BootstrapModeNotice extends Component { - @service siteSettings; - @service site; - - get message() { - let msg = null; - const bootstrapModeMinUsers = this.siteSettings.bootstrap_mode_min_users; - - if (bootstrapModeMinUsers > 0) { - msg = "bootstrap_mode_enabled"; - } else { - msg = "bootstrap_mode_disabled"; - } - - return htmlSafe(I18n.t(msg, { count: bootstrapModeMinUsers })); - } - - @action - inviteUsers() { - showModal("create-invite"); - } -} diff --git a/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs similarity index 77% rename from app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs rename to app/assets/javascripts/discourse/app/components/bread-crumbs.hbs index b9956d52f5d..d288b48d764 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/app/components/bread-crumbs.hbs @@ -1,3 +1,18 @@ + + {{#each this.categoryBreadcrumbs as |breadcrumb|}} {{#if breadcrumb.hasOptions}}
  • @@ -42,7 +57,7 @@ {{i18n "topics.bulk.choose_append_tags"}}

    + +

    + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/append-tags.js b/app/assets/javascripts/discourse/app/components/bulk-actions/append-tags.js new file mode 100644 index 00000000000..6b66d271026 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/append-tags.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; + +export default class AppendTags extends Component { + @tracked tags = []; +} diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.hbs b/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.hbs new file mode 100644 index 00000000000..5c68851acf4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.hbs @@ -0,0 +1,15 @@ +

    {{i18n "topics.bulk.choose_new_category"}}

    + +

    + +

    + + + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.js b/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.js new file mode 100644 index 00000000000..61c10614f4e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/change-category.js @@ -0,0 +1,17 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; + +export default class ChangeCategory extends Component { + categoryId = 0; + + @action + async changeCategory() { + await this.args.forEachPerformed( + { + type: "change_category", + category_id: this.categoryId, + }, + (t) => t.set("category_id", this.categoryId) + ); + } +} diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.hbs b/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.hbs new file mode 100644 index 00000000000..4e46d9ff96a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.hbs @@ -0,0 +1,9 @@ +

    {{i18n "topics.bulk.choose_new_tags"}}

    + +

    + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.js b/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.js new file mode 100644 index 00000000000..eb80adf4565 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/change-tags.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; + +export default class ChangeTags extends Component { + @tracked tags = []; +} diff --git a/app/assets/javascripts/discourse/app/templates/modal/bulk-notification-level.hbs b/app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.hbs similarity index 91% rename from app/assets/javascripts/discourse/app/templates/modal/bulk-notification-level.hbs rename to app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.hbs index 89d8aa146f9..e9ff958df96 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/bulk-notification-level.hbs +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.hbs @@ -16,6 +16,6 @@ \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.js b/app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.js new file mode 100644 index 00000000000..9a8dc0e0868 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bulk-actions/notification-level.js @@ -0,0 +1,28 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { empty } from "@ember/object/computed"; +import { topicLevels } from "discourse/lib/notification-levels"; +import { action } from "@ember/object"; + +// Support for changing the notification level of various topics +export default class NotificationLevel extends Component { + notificationLevelId = null; + + @empty("notificationLevelId") disabled; + + get notificationLevels() { + return topicLevels.map((level) => ({ + id: level.id.toString(), + name: I18n.t(`topic.notifications.${level.key}.title`), + description: I18n.t(`topic.notifications.${level.key}.description`), + })); + } + + @action + changeNotificationLevel() { + this.args.performAndRefresh({ + type: "change_notification_level", + notification_level_id: this.notificationLevelId, + }); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/components/bulk-select-toggle.hbs b/app/assets/javascripts/discourse/app/components/bulk-select-toggle.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/bulk-select-toggle.hbs rename to app/assets/javascripts/discourse/app/components/bulk-select-toggle.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/cancel-link.hbs b/app/assets/javascripts/discourse/app/components/cancel-link.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/cancel-link.hbs rename to app/assets/javascripts/discourse/app/components/cancel-link.hbs diff --git a/app/assets/javascripts/discourse/app/templates/user-card.hbs b/app/assets/javascripts/discourse/app/components/card-container.hbs similarity index 68% rename from app/assets/javascripts/discourse/app/templates/user-card.hbs rename to app/assets/javascripts/discourse/app/components/card-container.hbs index b4ee7536a6a..9bde984d351 100644 --- a/app/assets/javascripts/discourse/app/templates/user-card.hbs +++ b/app/assets/javascripts/discourse/app/components/card-container.hbs @@ -4,16 +4,16 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/card-container.js b/app/assets/javascripts/discourse/app/components/card-container.js new file mode 100644 index 00000000000..b61d8334a43 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/card-container.js @@ -0,0 +1,26 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { inject as controller } from "@ember/controller"; +import { action } from "@ember/object"; +import DiscourseURL, { groupPath, userPath } from "discourse/lib/url"; + +export default class CardWrapper extends Component { + @service site; + @controller topic; + + @action + filterPosts(user) { + const topicController = this.topic; + topicController.send("filterParticipant", user); + } + + @action + showUser(user) { + DiscourseURL.routeTo(userPath(user.username_lower)); + } + + @action + showGroup(group) { + DiscourseURL.routeTo(groupPath(group.name)); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs b/app/assets/javascripts/discourse/app/components/categories-and-latest-topics.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs rename to app/assets/javascripts/discourse/app/components/categories-and-latest-topics.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs b/app/assets/javascripts/discourse/app/components/categories-and-top-topics.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs rename to app/assets/javascripts/discourse/app/components/categories-and-top-topics.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-boxes-topic.hbs b/app/assets/javascripts/discourse/app/components/categories-boxes-topic.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/categories-boxes-topic.hbs rename to app/assets/javascripts/discourse/app/components/categories-boxes-topic.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-boxes-with-topics.hbs b/app/assets/javascripts/discourse/app/components/categories-boxes-with-topics.hbs similarity index 85% rename from app/assets/javascripts/discourse/app/templates/components/categories-boxes-with-topics.hbs rename to app/assets/javascripts/discourse/app/components/categories-boxes-with-topics.hbs index 2e2816c0604..10480daa034 100644 --- a/app/assets/javascripts/discourse/app/templates/components/categories-boxes-with-topics.hbs +++ b/app/assets/javascripts/discourse/app/components/categories-boxes-with-topics.hbs @@ -1,12 +1,7 @@ {{#each this.categories as |c|}}
  • diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-boxes.hbs b/app/assets/javascripts/discourse/app/components/categories-boxes.hbs similarity index 90% rename from app/assets/javascripts/discourse/app/templates/components/categories-boxes.hbs rename to app/assets/javascripts/discourse/app/components/categories-boxes.hbs index 88d3a12dc8e..cddedd2a074 100644 --- a/app/assets/javascripts/discourse/app/templates/components/categories-boxes.hbs +++ b/app/assets/javascripts/discourse/app/components/categories-boxes.hbs @@ -1,16 +1,11 @@ {{#each this.categories as |c|}}
    - + {{/each}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-only.hbs b/app/assets/javascripts/discourse/app/components/categories-only.hbs similarity index 96% rename from app/assets/javascripts/discourse/app/templates/components/categories-only.hbs rename to app/assets/javascripts/discourse/app/components/categories-only.hbs index ec8a6c70265..227e8114f24 100644 --- a/app/assets/javascripts/discourse/app/templates/components/categories-only.hbs +++ b/app/assets/javascripts/discourse/app/components/categories-only.hbs @@ -68,5 +68,5 @@ \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-topic-list.hbs b/app/assets/javascripts/discourse/app/components/categories-topic-list.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/categories-topic-list.hbs rename to app/assets/javascripts/discourse/app/components/categories-topic-list.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-with-featured-topics.hbs b/app/assets/javascripts/discourse/app/components/categories-with-featured-topics.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/categories-with-featured-topics.hbs rename to app/assets/javascripts/discourse/app/components/categories-with-featured-topics.hbs diff --git a/app/assets/javascripts/discourse/app/components/category-list-item.js b/app/assets/javascripts/discourse/app/components/category-list-item.js index 468ce928c75..12e41368404 100644 --- a/app/assets/javascripts/discourse/app/components/category-list-item.js +++ b/app/assets/javascripts/discourse/app/components/category-list-item.js @@ -26,4 +26,14 @@ export default Component.extend({ (!isMutedCategory && listType === LIST_TYPE.MUTED) ); }, + + @discourseComputed("topicTrackingState.messageCount") + unreadTopicsCount() { + return this.category.unreadTopicsCount; + }, + + @discourseComputed("topicTrackingState.messageCount") + newTopicsCount() { + return this.category.newTopicsCount; + }, }); diff --git a/app/assets/javascripts/discourse/app/templates/components/category-logo.hbs b/app/assets/javascripts/discourse/app/components/category-logo.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/category-logo.hbs rename to app/assets/javascripts/discourse/app/components/category-logo.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/category-name-fields.hbs b/app/assets/javascripts/discourse/app/components/category-name-fields.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/category-name-fields.hbs rename to app/assets/javascripts/discourse/app/components/category-name-fields.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/category-permission-row.hbs b/app/assets/javascripts/discourse/app/components/category-permission-row.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/category-permission-row.hbs rename to app/assets/javascripts/discourse/app/components/category-permission-row.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/category-read-only-banner.hbs b/app/assets/javascripts/discourse/app/components/category-read-only-banner.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/category-read-only-banner.hbs rename to app/assets/javascripts/discourse/app/components/category-read-only-banner.hbs diff --git a/app/assets/javascripts/discourse/app/components/category-title-before.hbs b/app/assets/javascripts/discourse/app/components/category-title-before.hbs new file mode 100644 index 00000000000..4be248725d2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/category-title-before.hbs @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/category-title-link.hbs b/app/assets/javascripts/discourse/app/components/category-title-link.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/category-title-link.hbs rename to app/assets/javascripts/discourse/app/components/category-title-link.hbs diff --git a/app/assets/javascripts/discourse/app/components/category-unread.hbs b/app/assets/javascripts/discourse/app/components/category-unread.hbs new file mode 100644 index 00000000000..459ccc57945 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/category-unread.hbs @@ -0,0 +1,17 @@ +{{#if this.unreadTopicsCount}} + {{i18n + "filters.unread.lower_title_with_count" + count=this.unreadTopicsCount + }} +{{/if}} +{{#if this.newTopicsCount}} + {{i18n "filters.new.lower_title_with_count" count=this.newTopicsCount}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/cdn-img.hbs b/app/assets/javascripts/discourse/app/components/cdn-img.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/cdn-img.hbs rename to app/assets/javascripts/discourse/app/components/cdn-img.hbs diff --git a/app/assets/javascripts/discourse/app/components/char-counter.hbs b/app/assets/javascripts/discourse/app/components/char-counter.hbs new file mode 100644 index 00000000000..3ab7e455dd7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/char-counter.hbs @@ -0,0 +1,12 @@ +
    + {{yield}} + + {{@value.length}}/{{@max}} + + + {{if (gt @value.length @max) (i18n "char_counter.exceeded")}} + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/choose-message.hbs b/app/assets/javascripts/discourse/app/components/choose-message.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/choose-message.hbs rename to app/assets/javascripts/discourse/app/components/choose-message.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs b/app/assets/javascripts/discourse/app/components/choose-topic.hbs similarity index 95% rename from app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs rename to app/assets/javascripts/discourse/app/components/choose-topic.hbs index f9bf06d223a..15e42850396 100644 --- a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs +++ b/app/assets/javascripts/discourse/app/components/choose-topic.hbs @@ -30,7 +30,7 @@ /> - {{replace-emoji t.fancy_title}} + {{replace-emoji t.title}} {{bound-category-link diff --git a/app/assets/javascripts/discourse/app/templates/components/color-picker.hbs b/app/assets/javascripts/discourse/app/components/color-picker.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/color-picker.hbs rename to app/assets/javascripts/discourse/app/components/color-picker.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs b/app/assets/javascripts/discourse/app/components/composer-action-title.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs rename to app/assets/javascripts/discourse/app/components/composer-action-title.hbs diff --git a/app/assets/javascripts/discourse/app/components/composer-body.js b/app/assets/javascripts/discourse/app/components/composer-body.js index 0827c1f3ad4..8b6f8f8da1e 100644 --- a/app/assets/javascripts/discourse/app/components/composer-body.js +++ b/app/assets/javascripts/discourse/app/components/composer-body.js @@ -7,7 +7,6 @@ import discourseComputed, { import Component from "@ember/component"; import Composer from "discourse/models/composer"; import KeyEnterEscape from "discourse/mixins/key-enter-escape"; -import afterTransition from "discourse/lib/after-transition"; import discourseDebounce from "discourse-common/lib/debounce"; import { headerOffset } from "discourse/lib/offset-calculator"; import positioningWorkaround from "discourse/lib/safari-hacks"; @@ -118,14 +117,15 @@ export default Component.extend(KeyEnterEscape, { @observes("composeState", "composer.{action,canEditTopicFeaturedLink}") _triggerComposerResized() { schedule("afterRender", () => { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } discourseDebounce(this, this.composerResized, 300); }); }, composerResized() { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + this.appEvents.trigger("composer:resized"); }, @@ -181,8 +181,10 @@ export default Component.extend(KeyEnterEscape, { }; triggerOpen(); - afterTransition($(this.element), () => { - triggerOpen(); + this.element.addEventListener("transitionend", (event) => { + if (event.propertyName === "height") { + triggerOpen(); + } }); positioningWorkaround(this.element); diff --git a/app/assets/javascripts/discourse/app/components/composer-container.hbs b/app/assets/javascripts/discourse/app/components/composer-container.hbs new file mode 100644 index 00000000000..8e19f89aa30 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/composer-container.hbs @@ -0,0 +1,428 @@ + +
    + + {{#if this.composer.visible}} + + + {{#if this.composer.showFullScreenPrompt}} + + {{/if}} + + {{#if this.composer.model.viewOpenOrFullscreen}} +
    + + + + +
    + {{#unless this.composer.model.viewFullscreen}} +
    + + + + + {{#unless this.composer.site.mobileView}} + {{#if this.composer.model.unlistTopic}} + ({{i18n "composer.unlist"}}) + {{/if}} + {{#if this.composer.isWhispering}} + {{#if this.composer.model.noBump}} + {{d-icon "anchor"}} + {{/if}} + {{/if}} + {{/unless}} + + {{#if this.composer.canEdit}} + + + + {{/if}} +
    + {{/unless}} + + + + +
    + + +
    + + {{#unless this.composer.model.viewFullscreen}} + {{#if this.composer.model.canEditTitle}} + {{#if this.composer.model.creatingPrivateMessage}} +
    + + {{#if this.composer.showWarning}} + + {{/if}} +
    + {{/if}} + +
    + + + {{#if this.composer.model.showCategoryChooser}} +
    + + +
    + {{/if}} + + {{#if this.composer.canEditTags}} + + + {{/if}} + + +
    + {{/if}} + + + + + {{/unless}} +
    +
    + + + + + +
    + + + + +
    + + + {{#if this.composer.site.mobileView}} + + {{#if this.composer.canEdit}} + {{d-icon "times"}} + {{else}} + {{d-icon "far-trash-alt"}} + {{/if}} + + {{else}} + {{i18n + "close" + }} + {{/if}} + + {{#if this.composer.site.mobileView}} + {{#if this.composer.whisperOrUnlistTopic}} + + {{d-icon "far-eye-slash"}} + + {{/if}} + + {{#if this.composer.model.noBump}} + {{d-icon "anchor"}} + {{/if}} + {{/if}} + + + + +
    + + {{#if this.composer.site.mobileView}} + + + + + {{#if this.composer.allowUpload}} + + {{d-icon this.composer.uploadIcon}} + + {{/if}} + + + {{d-icon "desktop"}} + + + {{#if this.composer.showPreview}} + + {{/if}} + {{/if}} + + {{#if + (or this.composer.isUploading this.composer.isProcessingUpload) + }} +
    + {{#if this.composer.isProcessingUpload}} + {{loading-spinner size="small"}}{{i18n + "upload_selector.processing" + }} + {{else}} + {{loading-spinner size="small"}}{{i18n + "upload_selector.uploading" + }} + {{this.composer.uploadProgress}}% + {{/if}} + + {{#if this.composer.isCancellable}} + {{d-icon "times"}} + {{/if}} +
    + {{/if}} + +
    + {{#if this.composer.model.draftStatus}} + + {{#if this.composer.model.draftConflictUser}} + {{avatar + this.composer.model.draftConflictUser + imageSize="small" + }} + {{d-icon "user-edit"}} + {{else}} + {{d-icon "exclamation-triangle"}} + {{/if}} + {{#unless this.composer.site.mobileView}} + {{this.composer.model.draftStatus}} + {{/unless}} + + {{/if}} +
    + + {{#unless this.site.mobileView}} + + {{/unless}} +
    +
    + {{else}} +
    + {{#if this.composer.model.createdPost}} + {{i18n "composer.saved"}} + + {{else}} + {{i18n "composer.saving"}} + {{loading-spinner size="small"}} + {{/if}} +
    + +
    + {{#if this.composer.model.topic}} + {{d-icon "share"}} + {{html-safe this.composer.draftTitle}} + {{else}} + {{i18n "composer.saved_draft"}} + {{/if}} +
    + + + {{/if}} + {{/if}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/composer-container.js b/app/assets/javascripts/discourse/app/components/composer-container.js new file mode 100644 index 00000000000..35fa83be7ba --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/composer-container.js @@ -0,0 +1,7 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ComposerContainer extends Component { + @service composer; + @service site; +} diff --git a/app/assets/javascripts/discourse/app/templates/components/composer-editor.hbs b/app/assets/javascripts/discourse/app/components/composer-editor.hbs similarity index 76% rename from app/assets/javascripts/discourse/app/templates/components/composer-editor.hbs rename to app/assets/javascripts/discourse/app/components/composer-editor.hbs index bb3c6513aa7..5b1041b8c86 100644 --- a/app/assets/javascripts/discourse/app/templates/components/composer-editor.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-editor.hbs @@ -16,6 +16,9 @@ @onExpandPopupMenuOptions={{action "onExpandPopupMenuOptions"}} @onPopupMenuAction={{this.onPopupMenuAction}} @popupMenuOptions={{this.popupMenuOptions}} + @formTemplateIds={{this.formTemplateIds}} + @replyingToTopic={{this.composer.replyingToTopic}} + @editingPost={{this.composer.editingPost}} @disabled={{this.disableTextarea}} @outletArgs={{hash composer=this.composer editorType="composer"}} > @@ -23,5 +26,9 @@ {{#if this.allowUpload}} - + {{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index 2c8cd343389..6673c23f375 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -6,8 +6,8 @@ import { caretPosition, formatUsername, inCodeBlock, - tinyAvatar, } from "discourse/lib/utilities"; +import { tinyAvatar } from "discourse-common/lib/avatar-utils"; import discourseComputed, { bind, debounce, @@ -31,6 +31,7 @@ import discourseLater from "discourse-common/lib/later"; import Component from "@ember/component"; import Composer from "discourse/models/composer"; import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy"; +import ComposerVideoThumbnailUppy from "discourse/mixins/composer-video-thumbnail-uppy"; import EmberObject from "@ember/object"; import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; @@ -41,6 +42,11 @@ import { isTesting } from "discourse-common/config/environment"; import { loadOneboxes } from "discourse/lib/load-oneboxes"; import putCursorAtEnd from "discourse/lib/put-cursor-at-end"; import userSearch from "discourse/lib/user-search"; +import { + destroyTippyInstances, + initUserStatusHtml, + renderUserStatusHtml, +} from "discourse/lib/user-status-on-autocomplete"; // original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` // group 1 `image|foo=bar` @@ -98,464 +104,456 @@ export function addComposerUploadMarkdownResolver(resolver) { export function cleanUpComposerUploadMarkdownResolver() { uploadMarkdownResolvers = []; } +export default Component.extend( + ComposerUploadUppy, + ComposerVideoThumbnailUppy, + { + classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"], -export default Component.extend(ComposerUploadUppy, { - classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"], + editorClass: ".d-editor", + fileUploadElementId: "file-uploader", + mobileFileUploaderId: "mobile-file-upload", - editorClass: ".d-editor", - fileUploadElementId: "file-uploader", - mobileFileUploaderId: "mobile-file-upload", + composerEventPrefix: "composer", + uploadType: "composer", + uppyId: "composer-editor-uppy", + composerModel: alias("composer"), + composerModelContentKey: "reply", + editorInputClass: ".d-editor-input", + shouldBuildScrollMap: true, + scrollMap: null, + processPreview: true, - // TODO (martin) Remove this once the chat plugin is using the new composerEventPrefix - eventPrefix: "composer", - composerEventPrefix: "composer", - uploadType: "composer", - uppyId: "composer-editor-uppy", - composerModel: alias("composer"), - composerModelContentKey: "reply", - editorInputClass: ".d-editor-input", - shouldBuildScrollMap: true, - scrollMap: null, - processPreview: true, + uploadMarkdownResolvers, + uploadPreProcessors, + uploadHandlers, - uploadMarkdownResolvers, - uploadPreProcessors, - uploadHandlers, + init() { + this._super(...arguments); + this.warnedCannotSeeMentions = []; + this.warnedGroupMentions = []; + }, - init() { - this._super(...arguments); - this.warnedCannotSeeMentions = []; - this.warnedGroupMentions = []; - }, - - @discourseComputed("composer.requiredCategoryMissing") - replyPlaceholder(requiredCategoryMissing) { - if (requiredCategoryMissing) { - return "composer.reply_placeholder_choose_category"; - } else { - const key = authorizesOneOrMoreImageExtensions( - this.currentUser.staff, - this.siteSettings - ) - ? "reply_placeholder" - : "reply_placeholder_no_images"; - return `composer.${key}`; - } - }, - - @discourseComputed - showLink() { - return this.currentUser && this.currentUser.link_posting_access !== "none"; - }, - - @observes("focusTarget") - setFocus() { - if (this.focusTarget === "editor") { - putCursorAtEnd(this.element.querySelector("textarea")); - } - }, - - @discourseComputed - markdownOptions() { - return { - previewing: true, - - formatUsername, - - lookupAvatarByPostNumber: (postNumber, topicId) => { - const topic = this.topic; - if (!topic) { - return; - } - - const posts = topic.get("postStream.posts"); - if (posts && topicId === topic.get("id")) { - const quotedPost = posts.findBy("post_number", postNumber); - if (quotedPost) { - return tinyAvatar(quotedPost.get("avatar_template")); - } - } - }, - - lookupPrimaryUserGroupByPostNumber: (postNumber, topicId) => { - const topic = this.topic; - if (!topic) { - return; - } - - const posts = topic.get("postStream.posts"); - if (posts && topicId === topic.get("id")) { - const quotedPost = posts.findBy("post_number", postNumber); - if (quotedPost) { - return quotedPost.primary_group_name; - } - } - }, - - hashtagTypesInPriorityOrder: - this.site.hashtag_configurations["topic-composer"], - hashtagIcons: this.site.hashtag_icons, - }; - }, - - @bind - _userSearchTerm(term) { - const topicId = this.get("topic.id"); - // maybe this is a brand new topic, so grab category from composer - const categoryId = - this.get("topic.category_id") || this.get("composer.categoryId"); - - return userSearch({ - term, - topicId, - categoryId, - includeGroups: true, - }); - }, - - @bind - _afterMentionComplete(value) { - this.composer.set("reply", value); - - // ensures textarea scroll position is correct - schedule("afterRender", () => { - const input = this.element.querySelector(".d-editor-input"); - input?.blur(); - input?.focus(); - }); - }, - - @on("didInsertElement") - _composerEditorInit() { - const $input = $(this.element.querySelector(".d-editor-input")); - - if (this.siteSettings.enable_mentions) { - $input.autocomplete({ - template: findRawTemplate("user-selector-autocomplete"), - dataSource: this._userSearchTerm, - key: "@", - transformComplete: (v) => v.username || v.name, - afterComplete: this._afterMentionComplete, - triggerRule: (textarea) => - !inCodeBlock(textarea.value, caretPosition(textarea)), - }); - } - - this.element - .querySelector(".d-editor-input") - ?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll); - - // Focus on the body unless we have a title - if (!this.get("composer.canEditTitle")) { - putCursorAtEnd(this.element.querySelector(".d-editor-input")); - } - - if (this.allowUpload) { - this._bindUploadTarget(); - this._bindMobileUploadButton(); - } - - this.appEvents.trigger("composer:will-open"); - }, - - @discourseComputed( - "composer.reply", - "composer.replyLength", - "composer.missingReplyCharacters", - "composer.minimumPostLength", - "lastValidatedAt" - ) - validation( - reply, - replyLength, - missingReplyCharacters, - minimumPostLength, - lastValidatedAt - ) { - const postType = this.get("composer.post.post_type"); - if (postType === this.site.get("post_types.small_action")) { - return; - } - - let reason; - if (replyLength < 1) { - reason = I18n.t("composer.error.post_missing"); - } else if (missingReplyCharacters > 0) { - reason = I18n.t("composer.error.post_length", { - count: minimumPostLength, - }); - const tl = this.get("currentUser.trust_level"); - if (tl === 0 || tl === 1) { - reason += - "
    " + - I18n.t("composer.error.try_like", { - heart: iconHTML("heart", { - label: I18n.t("likes_lowercase", { count: 1 }), - }), - }); - } - } - - if (reason) { - return EmberObject.create({ - failed: true, - reason, - lastShownAt: lastValidatedAt, - }); - } - }, - - _resetShouldBuildScrollMap() { - this.set("shouldBuildScrollMap", true); - }, - - @bind - _handleInputInteraction(event) { - const preview = this.element.querySelector(".d-editor-preview-wrapper"); - - if (!$(preview).is(":visible")) { - return; - } - - preview.removeEventListener("scroll", this._handleInputOrPreviewScroll); - event.target.addEventListener("scroll", this._handleInputOrPreviewScroll); - }, - - @bind - _handleInputOrPreviewScroll(event) { - this._syncScroll( - this._syncEditorAndPreviewScroll, - $(event.target), - $(this.element.querySelector(".d-editor-preview-wrapper")) - ); - }, - - @bind - _handlePreviewInteraction(event) { - this.element - .querySelector(".d-editor-input") - ?.removeEventListener("scroll", this._handleInputOrPreviewScroll); - - event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll); - }, - - _syncScroll($callback, $input, $preview) { - if (!this.scrollMap || this.shouldBuildScrollMap) { - this.set("scrollMap", this._buildScrollMap($input, $preview)); - this.set("shouldBuildScrollMap", false); - } - - throttle(this, $callback, $input, $preview, this.scrollMap, 20); - }, - - // Adapted from https://github.com/markdown-it/markdown-it.github.io - _buildScrollMap($input, $preview) { - let sourceLikeDiv = $("
    ") - .css({ - position: "absolute", - height: "auto", - visibility: "hidden", - width: $input[0].clientWidth, - "font-size": $input.css("font-size"), - "font-family": $input.css("font-family"), - "line-height": $input.css("line-height"), - "white-space": $input.css("white-space"), - }) - .appendTo("body"); - - const linesMap = []; - let numberOfLines = 0; - - $input - .val() - .split("\n") - .forEach((text) => { - linesMap.push(numberOfLines); - - if (text.length === 0) { - numberOfLines++; - } else { - sourceLikeDiv.text(text); - - let height; - let lineHeight; - height = parseFloat(sourceLikeDiv.css("height")); - lineHeight = parseFloat(sourceLikeDiv.css("line-height")); - numberOfLines += Math.round(height / lineHeight); - } - }); - - linesMap.push(numberOfLines); - sourceLikeDiv.remove(); - - const previewOffsetTop = $preview.offset().top; - const offset = - $preview.scrollTop() - - previewOffsetTop - - ($input.offset().top - previewOffsetTop); - const nonEmptyList = []; - const scrollMap = []; - for (let i = 0; i < numberOfLines; i++) { - scrollMap.push(-1); - } - - nonEmptyList.push(0); - scrollMap[0] = 0; - - $preview.find(".preview-sync-line").each((_, element) => { - let $element = $(element); - let lineNumber = $element.data("line-number"); - let linesToTop = linesMap[lineNumber]; - if (linesToTop !== 0) { - nonEmptyList.push(linesToTop); - } - scrollMap[linesToTop] = Math.round($element.offset().top + offset); - }); - - nonEmptyList.push(numberOfLines); - scrollMap[numberOfLines] = $preview[0].scrollHeight; - - let position = 0; - - for (let i = 1; i < numberOfLines; i++) { - if (scrollMap[i] !== -1) { - position++; - continue; - } - - let top = nonEmptyList[position]; - let bottom = nonEmptyList[position + 1]; - - scrollMap[i] = ( - (scrollMap[bottom] * (i - top) + scrollMap[top] * (bottom - i)) / - (bottom - top) - ).toFixed(2); - } - - return scrollMap; - }, - - @bind - _throttledSyncEditorAndPreviewScroll(event) { - const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); - - throttle( - this, - this._syncEditorAndPreviewScroll, - $(event.target), - $preview, - 20 - ); - }, - - _syncEditorAndPreviewScroll($input, $preview) { - if (!$input) { - return; - } - - if ($input.scrollTop() === 0) { - $preview.scrollTop(0); - return; - } - - const inputHeight = $input[0].scrollHeight; - const previewHeight = $preview[0].scrollHeight; - - if ($input.height() + $input.scrollTop() + 100 > inputHeight) { - // cheat, special case for bottom - $preview.scrollTop(previewHeight); - return; - } - - const scrollPosition = $input.scrollTop(); - const factor = previewHeight / inputHeight; - const desired = scrollPosition * factor; - $preview.scrollTop(desired + 50); - }, - - _renderUnseenMentions(preview, unseen) { - fetchUnseenMentions({ - names: unseen, - topicId: this.get("composer.topic.id"), - allowedNames: this.get("composer.targetRecipients")?.split(","), - }).then((response) => { - linkSeenMentions(preview, this.siteSettings); - this._warnMentionedGroups(preview); - this._warnCannotSeeMention(preview); - this._warnHereMention(response.here_count); - }); - }, - - _renderUnseenHashtags(preview) { - let unseen; - const hashtagContext = this.site.hashtag_configurations["topic-composer"]; - if (this.siteSettings.enable_experimental_hashtag_autocomplete) { - unseen = linkSeenHashtagsInContext(hashtagContext, preview); - } else { - unseen = linkSeenHashtags(preview); - } - - if (unseen.length > 0) { - if (this.siteSettings.enable_experimental_hashtag_autocomplete) { - fetchUnseenHashtagsInContext(hashtagContext, unseen).then(() => { - linkSeenHashtagsInContext(hashtagContext, preview); - }); + @discourseComputed("composer.requiredCategoryMissing") + replyPlaceholder(requiredCategoryMissing) { + if (requiredCategoryMissing) { + return "composer.reply_placeholder_choose_category"; } else { - fetchUnseenHashtags(unseen).then(() => { - linkSeenHashtags(preview); - }); + const key = authorizesOneOrMoreImageExtensions( + this.currentUser.staff, + this.siteSettings + ) + ? "reply_placeholder" + : "reply_placeholder_no_images"; + return `composer.${key}`; } - } - }, + }, - @debounce(2000) - _warnMentionedGroups(preview) { - schedule("afterRender", () => { - preview - .querySelectorAll(".mention-group[data-mentionable-user-count]") - .forEach((mention) => { - const { name } = mention.dataset; - if ( - this.warnedGroupMentions.includes(name) || - this._isInQuote(mention) - ) { + @discourseComputed + showLink() { + return ( + this.currentUser && this.currentUser.link_posting_access !== "none" + ); + }, + + @observes("focusTarget") + setFocus() { + if (this.focusTarget === "editor") { + putCursorAtEnd(this.element.querySelector("textarea")); + } + }, + + @discourseComputed + markdownOptions() { + return { + previewing: true, + + formatUsername, + + lookupAvatarByPostNumber: (postNumber, topicId) => { + const topic = this.topic; + if (!topic) { return; } - this.warnedGroupMentions.push(name); - this.groupsMentioned({ - name, - userCount: mention.dataset.mentionableUserCount, - maxMentions: mention.dataset.maxMentions, - }); + const posts = topic.get("postStream.posts"); + if (posts && topicId === topic.get("id")) { + const quotedPost = posts.findBy("post_number", postNumber); + if (quotedPost) { + return tinyAvatar(quotedPost.get("avatar_template")); + } + } + }, + + lookupPrimaryUserGroupByPostNumber: (postNumber, topicId) => { + const topic = this.topic; + if (!topic) { + return; + } + + const posts = topic.get("postStream.posts"); + if (posts && topicId === topic.get("id")) { + const quotedPost = posts.findBy("post_number", postNumber); + if (quotedPost) { + return quotedPost.primary_group_name; + } + } + }, + + hashtagTypesInPriorityOrder: + this.site.hashtag_configurations["topic-composer"], + hashtagIcons: this.site.hashtag_icons, + }; + }, + + @bind + _afterMentionComplete(value) { + this.composer.set("reply", value); + + // ensures textarea scroll position is correct + schedule("afterRender", () => { + const input = this.element.querySelector(".d-editor-input"); + input?.blur(); + input?.focus(); + }); + }, + + @on("didInsertElement") + _composerEditorInit() { + const $input = $(this.element.querySelector(".d-editor-input")); + + if (this.siteSettings.enable_mentions) { + $input.autocomplete({ + template: findRawTemplate("user-selector-autocomplete"), + dataSource: (term) => { + destroyTippyInstances(); + return userSearch({ + term, + topicId: this.topic?.id, + categoryId: this.topic?.category_id || this.composer?.categoryId, + includeGroups: true, + }).then((result) => { + initUserStatusHtml(result.users); + return result; + }); + }, + onRender: (options) => { + renderUserStatusHtml(options); + }, + key: "@", + transformComplete: (v) => v.username || v.name, + afterComplete: this._afterMentionComplete, + triggerRule: (textarea) => + !inCodeBlock(textarea.value, caretPosition(textarea)), + onClose: destroyTippyInstances, }); - }); - }, + } - // add a delay to allow for typing, so you don't open the warning right away - // previously we would warn after @bob even if you were about to mention @bob2 - @debounce(2000) - _warnCannotSeeMention(preview) { - if (this.composer.draftKey === Composer.NEW_PRIVATE_MESSAGE_KEY) { - return; - } + this.element + .querySelector(".d-editor-input") + ?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll); - preview.querySelectorAll(".mention[data-reason]").forEach((mention) => { - const { name } = mention.dataset; - if (this.warnedCannotSeeMentions.includes(name)) { + // Focus on the body unless we have a title + if (!this.get("composer.canEditTitle")) { + putCursorAtEnd(this.element.querySelector(".d-editor-input")); + } + + if (this.allowUpload) { + this._bindUploadTarget(); + this._bindMobileUploadButton(); + } + + this.appEvents.trigger(`${this.composerEventPrefix}:will-open`); + }, + + @discourseComputed( + "composer.reply", + "composer.replyLength", + "composer.missingReplyCharacters", + "composer.minimumPostLength", + "lastValidatedAt" + ) + validation( + reply, + replyLength, + missingReplyCharacters, + minimumPostLength, + lastValidatedAt + ) { + const postType = this.get("composer.post.post_type"); + if (postType === this.site.get("post_types.small_action")) { return; } - this.warnedCannotSeeMentions.push(name); - this.cannotSeeMention({ - name, - reason: mention.dataset.reason, - }); - }); + let reason; + if (replyLength < 1) { + reason = I18n.t("composer.error.post_missing"); + } else if (missingReplyCharacters > 0) { + reason = I18n.t("composer.error.post_length", { + count: minimumPostLength, + }); + const tl = this.get("currentUser.trust_level"); + if (tl === 0 || tl === 1) { + reason += + "
    " + + I18n.t("composer.error.try_like", { + heart: iconHTML("heart", { + label: I18n.t("likes_lowercase", { count: 1 }), + }), + }); + } + } - preview - .querySelectorAll(".mention-group[data-reason]") - .forEach((mention) => { + if (reason) { + return EmberObject.create({ + failed: true, + reason, + lastShownAt: lastValidatedAt, + }); + } + }, + + _resetShouldBuildScrollMap() { + this.set("shouldBuildScrollMap", true); + }, + + @bind + _handleInputInteraction(event) { + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + + if (!$(preview).is(":visible")) { + return; + } + + preview.removeEventListener("scroll", this._handleInputOrPreviewScroll); + event.target.addEventListener("scroll", this._handleInputOrPreviewScroll); + }, + + @bind + _handleInputOrPreviewScroll(event) { + this._syncScroll( + this._syncEditorAndPreviewScroll, + $(event.target), + $(this.element.querySelector(".d-editor-preview-wrapper")) + ); + }, + + @bind + _handlePreviewInteraction(event) { + this.element + .querySelector(".d-editor-input") + ?.removeEventListener("scroll", this._handleInputOrPreviewScroll); + + event.target?.addEventListener( + "scroll", + this._handleInputOrPreviewScroll + ); + }, + + _syncScroll($callback, $input, $preview) { + if (!this.scrollMap || this.shouldBuildScrollMap) { + this.set("scrollMap", this._buildScrollMap($input, $preview)); + this.set("shouldBuildScrollMap", false); + } + + throttle(this, $callback, $input, $preview, this.scrollMap, 20); + }, + + // Adapted from https://github.com/markdown-it/markdown-it.github.io + _buildScrollMap($input, $preview) { + let sourceLikeDiv = $("
    ") + .css({ + position: "absolute", + height: "auto", + visibility: "hidden", + width: $input[0].clientWidth, + "font-size": $input.css("font-size"), + "font-family": $input.css("font-family"), + "line-height": $input.css("line-height"), + "white-space": $input.css("white-space"), + }) + .appendTo("body"); + + const linesMap = []; + let numberOfLines = 0; + + $input + .val() + .split("\n") + .forEach((text) => { + linesMap.push(numberOfLines); + + if (text.length === 0) { + numberOfLines++; + } else { + sourceLikeDiv.text(text); + + let height; + let lineHeight; + height = parseFloat(sourceLikeDiv.css("height")); + lineHeight = parseFloat(sourceLikeDiv.css("line-height")); + numberOfLines += Math.round(height / lineHeight); + } + }); + + linesMap.push(numberOfLines); + sourceLikeDiv.remove(); + + const previewOffsetTop = $preview.offset().top; + const offset = + $preview.scrollTop() - + previewOffsetTop - + ($input.offset().top - previewOffsetTop); + const nonEmptyList = []; + const scrollMap = []; + for (let i = 0; i < numberOfLines; i++) { + scrollMap.push(-1); + } + + nonEmptyList.push(0); + scrollMap[0] = 0; + + $preview.find(".preview-sync-line").each((_, element) => { + let $element = $(element); + let lineNumber = $element.data("line-number"); + let linesToTop = linesMap[lineNumber]; + if (linesToTop !== 0) { + nonEmptyList.push(linesToTop); + } + scrollMap[linesToTop] = Math.round($element.offset().top + offset); + }); + + nonEmptyList.push(numberOfLines); + scrollMap[numberOfLines] = $preview[0].scrollHeight; + + let position = 0; + + for (let i = 1; i < numberOfLines; i++) { + if (scrollMap[i] !== -1) { + position++; + continue; + } + + let top = nonEmptyList[position]; + let bottom = nonEmptyList[position + 1]; + + scrollMap[i] = ( + (scrollMap[bottom] * (i - top) + scrollMap[top] * (bottom - i)) / + (bottom - top) + ).toFixed(2); + } + + return scrollMap; + }, + + @bind + _throttledSyncEditorAndPreviewScroll(event) { + const $preview = $( + this.element.querySelector(".d-editor-preview-wrapper") + ); + + throttle( + this, + this._syncEditorAndPreviewScroll, + $(event.target), + $preview, + 20 + ); + }, + + _syncEditorAndPreviewScroll($input, $preview) { + if (!$input) { + return; + } + + if ($input.scrollTop() === 0) { + $preview.scrollTop(0); + return; + } + + const inputHeight = $input[0].scrollHeight; + const previewHeight = $preview[0].scrollHeight; + + if ($input.height() + $input.scrollTop() + 100 > inputHeight) { + // cheat, special case for bottom + $preview.scrollTop(previewHeight); + return; + } + + const scrollPosition = $input.scrollTop(); + const factor = previewHeight / inputHeight; + const desired = scrollPosition * factor; + $preview.scrollTop(desired + 50); + }, + + _renderUnseenMentions(preview, unseen) { + fetchUnseenMentions({ + names: unseen, + topicId: this.get("composer.topic.id"), + allowedNames: this.get("composer.targetRecipients")?.split(","), + }).then((response) => { + linkSeenMentions(preview, this.siteSettings); + this._warnMentionedGroups(preview); + this._warnCannotSeeMention(preview); + this._warnHereMention(response.here_count); + }); + }, + + _renderUnseenHashtags(preview) { + let unseen; + const hashtagContext = this.site.hashtag_configurations["topic-composer"]; + if (this.siteSettings.enable_experimental_hashtag_autocomplete) { + unseen = linkSeenHashtagsInContext(hashtagContext, preview); + } else { + unseen = linkSeenHashtags(preview); + } + + if (unseen.length > 0) { + if (this.siteSettings.enable_experimental_hashtag_autocomplete) { + fetchUnseenHashtagsInContext(hashtagContext, unseen).then(() => { + linkSeenHashtagsInContext(hashtagContext, preview); + }); + } else { + fetchUnseenHashtags(unseen).then(() => { + linkSeenHashtags(preview); + }); + } + } + }, + + @debounce(2000) + _warnMentionedGroups(preview) { + schedule("afterRender", () => { + preview + .querySelectorAll(".mention-group[data-mentionable-user-count]") + .forEach((mention) => { + const { name } = mention.dataset; + if ( + this.warnedGroupMentions.includes(name) || + this._isInQuote(mention) + ) { + return; + } + + this.warnedGroupMentions.push(name); + this.groupsMentioned({ + name, + userCount: mention.dataset.mentionableUserCount, + maxMentions: mention.dataset.maxMentions, + }); + }); + }); + }, + + // add a delay to allow for typing, so you don't open the warning right away + // previously we would warn after @bob even if you were about to mention @bob2 + @debounce(2000) + _warnCannotSeeMention(preview) { + if (this.composer.draftKey === Composer.NEW_PRIVATE_MESSAGE_KEY) { + return; + } + + preview.querySelectorAll(".mention[data-reason]").forEach((mention) => { const { name } = mention.dataset; if (this.warnedCannotSeeMentions.includes(name)) { return; @@ -565,374 +563,436 @@ export default Component.extend(ComposerUploadUppy, { this.cannotSeeMention({ name, reason: mention.dataset.reason, - notifiedCount: mention.dataset.notifiedUserCount, - isGroup: true, }); }); - }, - _warnHereMention(hereCount) { - if (!hereCount || hereCount === 0) { - return; - } + preview + .querySelectorAll(".mention-group[data-reason]") + .forEach((mention) => { + const { name } = mention.dataset; + if (this.warnedCannotSeeMentions.includes(name)) { + return; + } - this.hereMention(hereCount); - }, + this.warnedCannotSeeMentions.push(name); + this.cannotSeeMention({ + name, + reason: mention.dataset.reason, + notifiedCount: mention.dataset.notifiedUserCount, + isGroup: true, + }); + }); + }, - @bind - _handleImageScaleButtonClick(event) { - if (!event.target.classList.contains("scale-btn")) { - return; - } - - const index = parseInt( - event.target.closest(".button-wrapper").dataset.imageIndex, - 10 - ); - - const scale = event.target.dataset.scale; - const matchingPlaceholder = - this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); - - if (matchingPlaceholder) { - const match = matchingPlaceholder[index]; - - if (match) { - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![$1|$2, ${scale}%$4]($5)` - ); - - this.appEvents.trigger( - "composer:replace-text", - matchingPlaceholder[index], - replacement, - { regex: IMAGE_MARKDOWN_REGEX, index } - ); + _warnHereMention(hereCount) { + if (!hereCount || hereCount === 0) { + return; } - } - event.preventDefault(); - return; - }, + this.hereMention(hereCount); + }, - resetImageControls(buttonWrapper) { - const imageResize = buttonWrapper.querySelector(".scale-btn-container"); - const imageDelete = buttonWrapper.querySelector(".delete-image-button"); + @bind + _handleImageScaleButtonClick(event) { + if (!event.target.classList.contains("scale-btn")) { + return; + } - const readonlyContainer = buttonWrapper.querySelector( - ".alt-text-readonly-container" - ); - const editContainer = buttonWrapper.querySelector( - ".alt-text-edit-container" - ); + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); - imageResize.removeAttribute("hidden"); - imageDelete.removeAttribute("hidden"); + const scale = event.target.dataset.scale; + const matchingPlaceholder = + this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); - readonlyContainer.removeAttribute("hidden"); - buttonWrapper.removeAttribute("editing"); - editContainer.setAttribute("hidden", "true"); - }, + if (matchingPlaceholder) { + const match = matchingPlaceholder[index]; - commitAltText(buttonWrapper) { - const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10); - const matchingPlaceholder = - this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); - const match = matchingPlaceholder[index]; - const input = buttonWrapper.querySelector("input.alt-text-input"); - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![${input.value}|$2$3$4]($5)` - ); + if (match) { + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![$1|$2, ${scale}%$4]($5)` + ); - this.appEvents.trigger("composer:replace-text", match, replacement); + this.appEvents.trigger( + `${this.composerEventPrefix}:replace-text`, + matchingPlaceholder[index], + replacement, + { regex: IMAGE_MARKDOWN_REGEX, index } + ); + } + } - this.resetImageControls(buttonWrapper); - }, - - @bind - _handleAltTextInputKeypress(event) { - if (!event.target.classList.contains("alt-text-input")) { - return; - } - - if (event.key === "[" || event.key === "]") { event.preventDefault(); - } + return; + }, + + resetImageControls(buttonWrapper) { + const imageResize = buttonWrapper.querySelector(".scale-btn-container"); + const imageDelete = buttonWrapper.querySelector(".delete-image-button"); + + const readonlyContainer = buttonWrapper.querySelector( + ".alt-text-readonly-container" + ); + const editContainer = buttonWrapper.querySelector( + ".alt-text-edit-container" + ); + + imageResize.removeAttribute("hidden"); + imageDelete.removeAttribute("hidden"); + + readonlyContainer.removeAttribute("hidden"); + buttonWrapper.removeAttribute("editing"); + editContainer.setAttribute("hidden", "true"); + }, + + commitAltText(buttonWrapper) { + const index = parseInt( + buttonWrapper.getAttribute("data-image-index"), + 10 + ); + const matchingPlaceholder = + this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); + const match = matchingPlaceholder[index]; + const input = buttonWrapper.querySelector("input.alt-text-input"); + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![${input.value}|$2$3$4]($5)` + ); + + this.appEvents.trigger( + `${this.composerEventPrefix}:replace-text`, + match, + replacement + ); + + this.resetImageControls(buttonWrapper); + }, + + @bind + _handleAltTextInputKeypress(event) { + if (!event.target.classList.contains("alt-text-input")) { + return; + } + + if (event.key === "[" || event.key === "]") { + event.preventDefault(); + } + + if (event.key === "Enter") { + const buttonWrapper = event.target.closest(".button-wrapper"); + this.commitAltText(buttonWrapper); + } + }, + + @bind + _handleAltTextEditButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-btn")) { + return; + } + + const buttonWrapper = event.target.closest(".button-wrapper"); + const imageResize = buttonWrapper.querySelector(".scale-btn-container"); + const imageDelete = buttonWrapper.querySelector(".delete-image-button"); + + const readonlyContainer = buttonWrapper.querySelector( + ".alt-text-readonly-container" + ); + const altText = readonlyContainer.querySelector(".alt-text"); + + const editContainer = buttonWrapper.querySelector( + ".alt-text-edit-container" + ); + const editContainerInput = editContainer.querySelector(".alt-text-input"); + + buttonWrapper.setAttribute("editing", "true"); + imageResize.setAttribute("hidden", "true"); + imageDelete.setAttribute("hidden", "true"); + readonlyContainer.setAttribute("hidden", "true"); + editContainerInput.value = altText.textContent; + editContainer.removeAttribute("hidden"); + editContainerInput.focus(); + event.preventDefault(); + }, + + @bind + _handleAltTextOkButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-ok")) { + return; + } - if (event.key === "Enter") { const buttonWrapper = event.target.closest(".button-wrapper"); this.commitAltText(buttonWrapper); - } - }, + }, - @bind - _handleAltTextEditButtonClick(event) { - if (!event.target.classList.contains("alt-text-edit-btn")) { - return; - } - - const buttonWrapper = event.target.closest(".button-wrapper"); - const imageResize = buttonWrapper.querySelector(".scale-btn-container"); - const imageDelete = buttonWrapper.querySelector(".delete-image-button"); - - const readonlyContainer = buttonWrapper.querySelector( - ".alt-text-readonly-container" - ); - const altText = readonlyContainer.querySelector(".alt-text"); - - const editContainer = buttonWrapper.querySelector( - ".alt-text-edit-container" - ); - const editContainerInput = editContainer.querySelector(".alt-text-input"); - - buttonWrapper.setAttribute("editing", "true"); - imageResize.setAttribute("hidden", "true"); - imageDelete.setAttribute("hidden", "true"); - readonlyContainer.setAttribute("hidden", "true"); - editContainerInput.value = altText.textContent; - editContainer.removeAttribute("hidden"); - editContainerInput.focus(); - event.preventDefault(); - }, - - @bind - _handleAltTextOkButtonClick(event) { - if (!event.target.classList.contains("alt-text-edit-ok")) { - return; - } - - const buttonWrapper = event.target.closest(".button-wrapper"); - this.commitAltText(buttonWrapper); - }, - - @bind - _handleAltTextCancelButtonClick(event) { - if (!event.target.classList.contains("alt-text-edit-cancel")) { - return; - } - - const buttonWrapper = event.target.closest(".button-wrapper"); - this.resetImageControls(buttonWrapper); - }, - - @bind - _handleImageDeleteButtonClick(event) { - if (!event.target.classList.contains("delete-image-button")) { - return; - } - const index = parseInt( - event.target.closest(".button-wrapper").dataset.imageIndex, - 10 - ); - const matchingPlaceholder = - this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); - this.appEvents.trigger( - "composer:replace-text", - matchingPlaceholder[index], - "", - { regex: IMAGE_MARKDOWN_REGEX, index } - ); - }, - - _registerImageAltTextButtonClick(preview) { - preview.addEventListener("click", this._handleAltTextEditButtonClick); - preview.addEventListener("click", this._handleAltTextOkButtonClick); - preview.addEventListener("click", this._handleAltTextCancelButtonClick); - preview.addEventListener("click", this._handleImageDeleteButtonClick); - preview.addEventListener("keypress", this._handleAltTextInputKeypress); - }, - - @on("willDestroyElement") - _composerClosed() { - this._unbindMobileUploadButton(); - this.appEvents.trigger("composer:will-close"); - next(() => { - // need to wait a bit for the "slide down" transition of the composer - discourseLater( - () => this.appEvents.trigger("composer:closed"), - isTesting() ? 0 : 400 - ); - }); - - this.element - .querySelector(".d-editor-input") - ?.removeEventListener( - "scroll", - this._throttledSyncEditorAndPreviewScroll - ); - - const preview = this.element.querySelector(".d-editor-preview-wrapper"); - preview?.removeEventListener("click", this._handleImageScaleButtonClick); - preview?.removeEventListener("click", this._handleAltTextEditButtonClick); - preview?.removeEventListener("click", this._handleAltTextOkButtonClick); - preview?.removeEventListener("click", this._handleAltTextCancelButtonClick); - preview?.removeEventListener("keypress", this._handleAltTextInputKeypress); - }, - - onExpandPopupMenuOptions(toolbarEvent) { - const selected = toolbarEvent.selected; - toolbarEvent.selectText(selected.start, selected.end - selected.start); - this.storeToolbarState(toolbarEvent); - }, - - showPreview() { - this.send("togglePreview"); - }, - - _isInQuote(element) { - let parent = element.parentElement; - while (parent && !this._isPreviewRoot(parent)) { - if (this._isQuote(parent)) { - return true; + @bind + _handleAltTextCancelButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-cancel")) { + return; } - parent = parent.parentElement; - } + const buttonWrapper = event.target.closest(".button-wrapper"); + this.resetImageControls(buttonWrapper); + }, - return false; - }, + @bind + _handleImageDeleteButtonClick(event) { + if (!event.target.classList.contains("delete-image-button")) { + return; + } + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); + const matchingPlaceholder = + this.get("composer.reply").match(IMAGE_MARKDOWN_REGEX); + this.appEvents.trigger( + `${this.composerEventPrefix}:replace-text`, + matchingPlaceholder[index], + "", + { regex: IMAGE_MARKDOWN_REGEX, index } + ); + }, - _isPreviewRoot(element) { - return ( - element.tagName === "DIV" && - element.classList.contains("d-editor-preview") - ); - }, + @bind + _handleImageGridButtonClick(event) { + if (!event.target.classList.contains("wrap-image-grid-button")) { + return; + } - _isQuote(element) { - return element.tagName === "ASIDE" && element.classList.contains("quote"); - }, + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); + const reply = this.get("composer.reply"); + const matches = reply.match(IMAGE_MARKDOWN_REGEX); + const closingIndex = + index + parseInt(event.target.dataset.imageCount, 10) - 1; - _cursorIsOnEmptyLine() { - const textArea = this.element.querySelector(".d-editor-input"); - const selectionStart = textArea.selectionStart; - if (selectionStart === 0) { - return true; - } else if (textArea.value.charAt(selectionStart - 1) === "\n") { - return true; - } else { - return false; - } - }, + const textArea = this.element.querySelector(".d-editor-input"); + textArea.selectionStart = reply.indexOf(matches[index]); + textArea.selectionEnd = + reply.indexOf(matches[closingIndex]) + matches[closingIndex].length; - _findMatchingUploadHandler(fileName) { - return this.uploadHandlers.find((handler) => { - const ext = handler.extensions.join("|"); - const regex = new RegExp(`\\.(${ext})$`, "i"); - return regex.test(fileName); - }); - }, + this.appEvents.trigger( + `${this.composerEventPrefix}:apply-surround`, + "[grid]", + "[/grid]", + "grid_surround", + { useBlockMode: true } + ); + }, - actions: { - importQuote(toolbarEvent) { - this.importQuote(toolbarEvent); + _registerImageAltTextButtonClick(preview) { + preview.addEventListener("click", this._handleAltTextEditButtonClick); + preview.addEventListener("click", this._handleAltTextOkButtonClick); + preview.addEventListener("click", this._handleAltTextCancelButtonClick); + preview.addEventListener("click", this._handleImageDeleteButtonClick); + preview.addEventListener("keypress", this._handleAltTextInputKeypress); + preview.addEventListener("click", this._handleImageGridButtonClick); + }, + + @on("willDestroyElement") + _composerClosed() { + this._unbindMobileUploadButton(); + this.appEvents.trigger(`${this.composerEventPrefix}:will-close`); + next(() => { + // need to wait a bit for the "slide down" transition of the composer + discourseLater( + () => this.appEvents.trigger(`${this.composerEventPrefix}:closed`), + isTesting() ? 0 : 400 + ); + }); + + this.element + .querySelector(".d-editor-input") + ?.removeEventListener( + "scroll", + this._throttledSyncEditorAndPreviewScroll + ); + + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + preview?.removeEventListener("click", this._handleImageScaleButtonClick); + preview?.removeEventListener("click", this._handleAltTextEditButtonClick); + preview?.removeEventListener("click", this._handleAltTextOkButtonClick); + preview?.removeEventListener("click", this._handleImageDeleteButtonClick); + preview?.removeEventListener("click", this._handleImageGridButtonClick); + preview?.removeEventListener( + "click", + this._handleAltTextCancelButtonClick + ); + preview?.removeEventListener( + "keypress", + this._handleAltTextInputKeypress + ); }, onExpandPopupMenuOptions(toolbarEvent) { - this.onExpandPopupMenuOptions(toolbarEvent); + const selected = toolbarEvent.selected; + toolbarEvent.selectText(selected.start, selected.end - selected.start); + this.storeToolbarState(toolbarEvent); }, - togglePreview() { - this.togglePreview(); + showPreview() { + this.send("togglePreview"); }, - extraButtons(toolbar) { - toolbar.addButton({ - id: "quote", - group: "fontStyles", - icon: "far-comment", - sendAction: this.importQuote, - title: "composer.quote_post_title", - unshift: true, - }); + _isInQuote(element) { + let parent = element.parentElement; + while (parent && !this._isPreviewRoot(parent)) { + if (this._isQuote(parent)) { + return true; + } - if (this.allowUpload && this.uploadIcon && !this.site.mobileView) { - toolbar.addButton({ - id: "upload", - group: "insertions", - icon: this.uploadIcon, - title: "upload", - sendAction: this.showUploadModal, - }); + parent = parent.parentElement; } - toolbar.addButton({ - id: "options", - group: "extras", - icon: "cog", - title: "composer.options", - sendAction: this.onExpandPopupMenuOptions.bind(this), - popupMenu: true, - }); + return false; }, - previewUpdated(preview) { - // cache jquery objects for functions still using jquery - const $preview = $(preview); + _isPreviewRoot(element) { + return ( + element.tagName === "DIV" && + element.classList.contains("d-editor-preview") + ); + }, - // Paint mentions - const unseenMentions = linkSeenMentions(preview, this.siteSettings); - if (unseenMentions.length) { - discourseDebounce( - this, - this._renderUnseenMentions, - preview, - unseenMentions, - 450 - ); - } + _isQuote(element) { + return element.tagName === "ASIDE" && element.classList.contains("quote"); + }, - this._warnMentionedGroups(preview); - this._warnCannotSeeMention(preview); - - // Paint category, tag, and other data source hashtags - let unseenHashtags; - const hashtagContext = this.site.hashtag_configurations["topic-composer"]; - if (this.siteSettings.enable_experimental_hashtag_autocomplete) { - unseenHashtags = linkSeenHashtagsInContext(hashtagContext, preview); + _cursorIsOnEmptyLine() { + const textArea = this.element.querySelector(".d-editor-input"); + const selectionStart = textArea.selectionStart; + if (selectionStart === 0) { + return true; + } else if (textArea.value.charAt(selectionStart - 1) === "\n") { + return true; } else { - unseenHashtags = linkSeenHashtags(preview); + return false; } - if (unseenHashtags.length > 0) { - discourseDebounce(this, this._renderUnseenHashtags, preview, 450); - } - - // Paint oneboxes - const paintFunc = () => { - const post = this.get("composer.post"); - let refresh = false; - - //If we are editing a post, we'll refresh its contents once. - if (post && !post.get("refreshedPost")) { - refresh = true; - } - - const paintedCount = loadOneboxes( - preview, - ajax, - this.get("composer.topic.id"), - this.get("composer.category.id"), - this.siteSettings.max_oneboxes_per_post, - refresh - ); - - if (refresh && paintedCount > 0) { - post.set("refreshedPost", true); - } - }; - - discourseDebounce(this, paintFunc, 450); - - // Short upload urls need resolution - resolveAllShortUrls(ajax, this.siteSettings, preview); - - preview.addEventListener("click", this._handleImageScaleButtonClick); - this._registerImageAltTextButtonClick(preview); - - this.trigger("previewRefreshed", preview); - this.afterRefresh($preview); }, - }, -}); + + _findMatchingUploadHandler(fileName) { + return this.uploadHandlers.find((handler) => { + const ext = handler.extensions.join("|"); + const regex = new RegExp(`\\.(${ext})$`, "i"); + return regex.test(fileName); + }); + }, + + actions: { + importQuote(toolbarEvent) { + this.importQuote(toolbarEvent); + }, + + onExpandPopupMenuOptions(toolbarEvent) { + this.onExpandPopupMenuOptions(toolbarEvent); + }, + + togglePreview() { + this.togglePreview(); + }, + + extraButtons(toolbar) { + toolbar.addButton({ + id: "quote", + group: "fontStyles", + icon: "far-comment", + sendAction: this.importQuote, + title: "composer.quote_post_title", + unshift: true, + }); + + if (this.allowUpload && this.uploadIcon && !this.site.mobileView) { + toolbar.addButton({ + id: "upload", + group: "insertions", + icon: this.uploadIcon, + title: "upload", + sendAction: this.showUploadModal, + }); + } + + toolbar.addButton({ + id: "options", + group: "extras", + icon: "cog", + title: "composer.options", + sendAction: this.onExpandPopupMenuOptions.bind(this), + popupMenu: true, + }); + }, + + previewUpdated(preview) { + // cache jquery objects for functions still using jquery + const $preview = $(preview); + + // Paint mentions + const unseenMentions = linkSeenMentions(preview, this.siteSettings); + if (unseenMentions.length) { + discourseDebounce( + this, + this._renderUnseenMentions, + preview, + unseenMentions, + 450 + ); + } + + this._warnMentionedGroups(preview); + this._warnCannotSeeMention(preview); + + // Paint category, tag, and other data source hashtags + let unseenHashtags; + const hashtagContext = + this.site.hashtag_configurations["topic-composer"]; + if (this.siteSettings.enable_experimental_hashtag_autocomplete) { + unseenHashtags = linkSeenHashtagsInContext(hashtagContext, preview); + } else { + unseenHashtags = linkSeenHashtags(preview); + } + if (unseenHashtags.length > 0) { + discourseDebounce(this, this._renderUnseenHashtags, preview, 450); + } + + // Paint oneboxes + const paintFunc = () => { + const post = this.get("composer.post"); + let refresh = false; + + //If we are editing a post, we'll refresh its contents once. + if (post && !post.get("refreshedPost")) { + refresh = true; + } + + const paintedCount = loadOneboxes( + preview, + ajax, + this.get("composer.topic.id"), + this.get("composer.category.id"), + this.siteSettings.max_oneboxes_per_post, + refresh + ); + + if (refresh && paintedCount > 0) { + post.set("refreshedPost", true); + } + }; + + discourseDebounce(this, paintFunc, 450); + + // Short upload urls need resolution + resolveAllShortUrls(ajax, this.siteSettings, preview); + + preview.addEventListener("click", this._handleImageScaleButtonClick); + this._registerImageAltTextButtonClick(preview); + + this.trigger("previewRefreshed", preview); + this.afterRefresh($preview); + }, + }, + } +); diff --git a/app/assets/javascripts/discourse/app/components/composer-messages.hbs b/app/assets/javascripts/discourse/app/components/composer-messages.hbs new file mode 100644 index 00000000000..202cbfc62e7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/composer-messages.hbs @@ -0,0 +1,14 @@ +{{#each this.messages as |message|}} + + {{#if this.showShareModal}} + + {{/if}} +{{/each}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/composer-messages.js b/app/assets/javascripts/discourse/app/components/composer-messages.js index 275d1c534c5..28d7c1b5d57 100644 --- a/app/assets/javascripts/discourse/app/components/composer-messages.js +++ b/app/assets/javascripts/discourse/app/components/composer-messages.js @@ -1,110 +1,82 @@ import Component from "@ember/component"; +import { classNameBindings } from "@ember-decorators/component"; import EmberObject, { action } from "@ember/object"; import I18n from "I18n"; import LinkLookup from "discourse/lib/link-lookup"; import { not } from "@ember/object/computed"; -import { scheduleOnce } from "@ember/runloop"; -import showModal from "discourse/lib/show-modal"; import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { debounce } from "discourse-common/utils/decorators"; let _messagesCache = {}; -let _recipient_names = []; -export default Component.extend({ - classNameBindings: [":composer-popup-container", "hidden"], - checkedMessages: false, - messages: null, - messagesByTemplate: null, - queuedForTyping: null, - _lastSimilaritySearch: null, - _similarTopicsMessage: null, - _yourselfConfirm: null, - similarTopics: null, - usersNotSeen: null, +@classNameBindings(":composer-popup-container", "hidden") +export default class ComposerMessages extends Component { + @service modal; + @tracked showShareModal; - hidden: not("composer.viewOpenOrFullscreen"), + checkedMessages = false; + messages = null; + messagesByTemplate = null; + queuedForTyping = null; + similarTopics = null; + usersNotSeen = null; + recipientNames = []; + + @not("composer.viewOpenOrFullscreen") hidden; + + _lastSimilaritySearch = null; + _similarTopicsMessage = null; didInsertElement() { - this._super(...arguments); + super.didInsertElement(...arguments); + this.appEvents.on("composer:typed-reply", this, this._typedReply); this.appEvents.on("composer:opened", this, this._findMessages); this.appEvents.on("composer:find-similar", this, this._findSimilar); this.appEvents.on("composer-messages:close", this, this._closeTop); this.appEvents.on("composer-messages:create", this, this._create); - scheduleOnce("afterRender", this, this.reset); - }, + this.reset(); + } willDestroyElement() { + super.willDestroyElement(...arguments); + this.appEvents.off("composer:typed-reply", this, this._typedReply); this.appEvents.off("composer:opened", this, this._findMessages); this.appEvents.off("composer:find-similar", this, this._findSimilar); this.appEvents.off("composer-messages:close", this, this._closeTop); this.appEvents.off("composer-messages:create", this, this._create); - }, + } _closeTop() { - const messages = this.messages; - messages.popObject(); - this.set("messageCount", messages.get("length")); - }, + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.messages.popObject(); + this.set("messageCount", this.messages.length); + } _removeMessage(message) { - const messages = this.messages; - messages.removeObject(message); - this.set("messageCount", messages.get("length")); - }, + this.messages.removeObject(message); + this.set("messageCount", this.messages.length); + } - @action - closeMessage(message, event) { - event?.preventDefault(); - this._removeMessage(message); - }, + _create(info) { + if (this.isDestroying || this.isDestroyed) { + return; + } - actions: { - hideMessage(message) { - this._removeMessage(message); - // kind of hacky but the visibility depends on this - this.messagesByTemplate[message.get("templateName")] = undefined; - }, - - popup(message) { - const messagesByTemplate = this.messagesByTemplate; - const templateName = message.get("templateName"); - - if (!messagesByTemplate[templateName]) { - const messages = this.messages; - messages.pushObject(message); - this.set("messageCount", messages.get("length")); - messagesByTemplate[templateName] = message; - } - }, - - shareModal() { - const { topic } = this.composer; - const controller = showModal("share-topic", { model: topic.category }); - controller.setProperties({ - allowInvites: - topic.details.can_invite_to && - !topic.archived && - !topic.closed && - !topic.deleted, - topic, - }); - }, - - switchPM(message) { - this.composer.set("action", "privateMessage"); - this.composer.set("targetRecipients", message.reply_username); - this._removeMessage(message); - }, - }, + this.reset(); + this.popup(EmberObject.create(info)); + } // Resets all active messages. // For example if composing a new post. reset() { - if (this.isDestroying || this.isDestroyed) { - return; - } this.setProperties({ messages: [], messagesByTemplate: {}, @@ -112,106 +84,120 @@ export default Component.extend({ checkedMessages: false, similarTopics: [], }); - }, + } // Called after the user has typed a reply. // Some messages only get shown after being typed. - _typedReply() { + @debounce(INPUT_DELAY) + async _typedReply() { if (this.isDestroying || this.isDestroyed) { return; } - const composer = this.composer; - if (composer.get("privateMessage")) { - const recipients = composer.targetRecipientsArray; - const recipient_names = recipients + for (const msg of this.queuedForTyping) { + if (this.composer.whisper && msg.hide_if_whisper) { + return; + } + + this.popup(msg); + } + + if (this.composer.privateMessage) { + if ( + this.composer.targetRecipientsArray.length > 0 && + this.composer.targetRecipientsArray.every( + (r) => r.name === this.currentUser.username + ) + ) { + const message = this.composer.store.createRecord("composer-message", { + id: "yourself_confirm", + templateName: "education", + title: I18n.t("composer.yourself_confirm.title"), + body: I18n.t("composer.yourself_confirm.body"), + }); + + this.popup(message); + } + + const recipient_names = this.composer.targetRecipientsArray .filter((r) => r.type === "user") .map(({ name }) => name); if ( recipient_names.length > 0 && - recipient_names.length !== _recipient_names.length && - !recipient_names.every((v, i) => v === _recipient_names[i]) + recipient_names.length !== this.recipientNames.length && + !recipient_names.every((v, i) => v === this.recipientNames[i]) ) { - _recipient_names = recipient_names; + this.recipientNames = recipient_names; - ajax(`/composer_messages/user_not_seen_in_a_while`, { - type: "GET", - data: { - usernames: recipient_names, - }, - }).then((response) => { - if ( - response.user_count > 0 && - this.get("usersNotSeen") !== response.usernames.join("-") - ) { - this.set("usersNotSeen", response.usernames.join("-")); - this.messagesByTemplate["education"] = undefined; - - let usernames = []; - response.usernames.forEach((username, index) => { - usernames[ - index - ] = `@${username}`; - }); - - let body_key = "composer.user_not_seen_in_a_while.single"; - if (response.user_count > 1) { - body_key = "composer.user_not_seen_in_a_while.multiple"; - } - const message = composer.store.createRecord("composer-message", { - id: "user-not-seen", - templateName: "education", - body: I18n.t(body_key, { - usernames: usernames.join(", "), - time_ago: response.time_ago, - }), - }); - this.send("popup", message); + const response = await ajax( + `/composer_messages/user_not_seen_in_a_while`, + { + type: "GET", + data: { + usernames: recipient_names, + }, } - }); - } + ); - if ( - recipients.length > 0 && - recipients.every((r) => r.name === this.currentUser.get("username")) - ) { - const message = - this._yourselfConfirm || - composer.store.createRecord("composer-message", { - id: "yourself_confirm", - templateName: "education", - title: I18n.t("composer.yourself_confirm.title"), - body: I18n.t("composer.yourself_confirm.body"), + if (this.isDestroying || this.isDestroyed) { + return; + } + + if ( + response.user_count > 0 && + this.usersNotSeen !== response.usernames.join("-") + ) { + this.set("usersNotSeen", response.usernames.join("-")); + this.messagesByTemplate["education"] = undefined; + + let usernames = []; + response.usernames.forEach((username, index) => { + usernames[ + index + ] = `@${username}`; }); - this.send("popup", message); + + let body_key; + if (response.user_count === 1) { + body_key = "composer.user_not_seen_in_a_while.single"; + } else { + body_key = "composer.user_not_seen_in_a_while.multiple"; + } + + const message = this.composer.store.createRecord("composer-message", { + id: "user-not-seen", + templateName: "education", + body: I18n.t(body_key, { + usernames: usernames.join(", "), + time_ago: response.time_ago, + }), + }); + + this.popup(message); + } } } + } - this.queuedForTyping.forEach((msg) => { - if (composer.whisper && msg.hide_if_whisper) { - return; - } - this.send("popup", msg); - }); - }, - - _create(info) { - this.reset(); - this.send("popup", EmberObject.create(info)); - }, - - _findSimilar() { - const composer = this.composer; - - // We don't care about similar topics unless creating a topic - if (!composer.get("creatingTopic")) { + async _findSimilar() { + if (this.isDestroying || this.isDestroyed) { return; } - // TODO pass the 200 in from somewhere - const raw = (composer.get("reply") || "").slice(0, 200); - const title = composer.get("title") || ""; + // We don't care about similar topics unless creating a topic + if (!this.composer.creatingTopic) { + return; + } + + // We don't care about similar topics when creating with a form template + if (this.composer?.category?.form_template_ids.length > 0) { + return; + } + + // TODO: pass the 200 in from somewhere + const raw = (this.composer.reply || "").slice(0, 200); + const title = this.composer.title || ""; // Ensure we have at least a title if (title.length < this.siteSettings.min_title_similar_length) { @@ -223,79 +209,130 @@ export default Component.extend({ if (concat === this._lastSimilaritySearch) { return; } - this._lastSimilaritySearch = concat; - const similarTopics = this.similarTopics; - const message = - this._similarTopicsMessage || - composer.store.createRecord("composer-message", { + this._lastSimilaritySearch = concat; + this._similarTopicsMessage ||= this.composer.store.createRecord( + "composer-message", + { id: "similar_topics", templateName: "similar-topics", extraClass: "similar-topics", - }); - - this._similarTopicsMessage = message; - - composer.store.find("similar-topic", { title, raw }).then((topics) => { - similarTopics.clear(); - similarTopics.pushObjects(topics.get("content")); - - if (similarTopics.get("length") > 0) { - message.set("similarTopics", similarTopics); - this.send("popup", message); - } else if (message && !(this.isDestroyed || this.isDestroying)) { - this.send("hideMessage", message); } + ); + + const topics = await this.composer.store.find("similar-topic", { + title, + raw, }); - }, + + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.similarTopics.clear(); + this.similarTopics.pushObjects(topics.content); + + if (this.similarTopics.length > 0) { + this._similarTopicsMessage.set("similarTopics", this.similarTopics); + this.popup(this._similarTopicsMessage); + } else if (this._similarTopicsMessage) { + this.hideMessage(this._similarTopicsMessage); + } + } // Figure out if there are any messages that should be displayed above the composer. - _findMessages() { + async _findMessages() { + if (this.isDestroying || this.isDestroyed) { + return; + } + if (this.checkedMessages) { return; } - const composer = this.composer; - const args = { composer_action: composer.get("action") }; - const topicId = composer.get("topic.id"); - const postId = composer.get("post.id"); + const args = { composer_action: this.composer.action }; + const topicId = this.composer.topic?.id; + const postId = this.composer.post?.id; if (topicId) { args.topic_id = topicId; } + if (postId) { args.post_id = postId; } const cacheKey = `${args.composer_action}${args.topic_id}${args.post_id}`; - const processMessages = (messages) => { + let messages; + if (_messagesCache.cacheKey === cacheKey) { + messages = _messagesCache.messages; + } else { + messages = await this.composer.store.find("composer-message", args); if (this.isDestroying || this.isDestroyed) { return; } - // Checking composer messages on replies can give us a list of links to check for - // duplicates - if (messages.extras && messages.extras.duplicate_lookup) { - this.addLinkLookup(new LinkLookup(messages.extras.duplicate_lookup)); - } - - this.set("checkedMessages", true); - const queuedForTyping = this.queuedForTyping; - messages.forEach((msg) => - msg.wait_for_typing - ? queuedForTyping.addObject(msg) - : this.send("popup", msg) - ); - }; - - if (_messagesCache.cacheKey === cacheKey) { - processMessages(_messagesCache.messages); - } else { - composer.store.find("composer-message", args).then((messages) => { - _messagesCache = { messages, cacheKey }; - processMessages(messages); - }); + _messagesCache = { messages, cacheKey }; } - }, -}); + + // Checking composer messages on replies can give us a list of links to check for + // duplicates + if (messages.extras?.duplicate_lookup) { + this.addLinkLookup(new LinkLookup(messages.extras.duplicate_lookup)); + } + + this.set("checkedMessages", true); + + messages.forEach((msg) => { + if (msg.wait_for_typing) { + this.queuedForTyping.addObject(msg); + } else { + this.popup(msg); + } + }); + } + + @action + closeMessage(message, event) { + event?.preventDefault(); + this._removeMessage(message); + } + + @action + hideMessage(message) { + this._removeMessage(message); + + // kind of hacky but the visibility depends on this + this.messagesByTemplate[message.templateName] = undefined; + } + + @action + popup(message) { + if (!this.messagesByTemplate[message.templateName]) { + this.messages.pushObject(message); + this.set("messageCount", this.messages.length); + this.messagesByTemplate[message.templateName] = message; + } + } + + get shareModalData() { + const { topic } = this.composer; + return { + topic, + category: topic.category, + allowInvites: + topic.details.can_invite_to && + !topic.archived && + !topic.closed && + !topic.deleted, + }; + } + + @action + switchPM(message) { + this.composer.set("action", "privateMessage"); + this.composer.set("targetRecipients", message.reply_username); + this._removeMessage(message); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/components/composer-title.hbs b/app/assets/javascripts/discourse/app/components/composer-title.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/composer-title.hbs rename to app/assets/javascripts/discourse/app/components/composer-title.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/composer-toggles.hbs b/app/assets/javascripts/discourse/app/components/composer-toggles.hbs similarity index 86% rename from app/assets/javascripts/discourse/app/templates/components/composer-toggles.hbs rename to app/assets/javascripts/discourse/app/components/composer-toggles.hbs index 4fc18c2a428..eb94d61a518 100644 --- a/app/assets/javascripts/discourse/app/templates/components/composer-toggles.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-toggles.hbs @@ -1,9 +1,7 @@
    - + + + {{#if this.site.mobileView}} + {{yield}} {{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/create-topics-notice.js b/app/assets/javascripts/discourse/app/components/create-topics-notice.js deleted file mode 100644 index da6f089ef6a..00000000000 --- a/app/assets/javascripts/discourse/app/components/create-topics-notice.js +++ /dev/null @@ -1,118 +0,0 @@ -import discourseComputed, { observes } from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import I18n from "I18n"; -import LivePostCounts from "discourse/models/live-post-counts"; -import { alias } from "@ember/object/computed"; -import { htmlSafe } from "@ember/template"; -import { inject as service } from "@ember/service"; - -export default Component.extend({ - classNameBindings: ["hidden:hidden", ":create-topics-notice"], - - enabled: false, - router: service(), - - publicTopicCount: null, - publicPostCount: null, - - requiredTopics: 5, - requiredPosts: alias("siteSettings.tl1_requires_read_posts"), - - init() { - this._super(...arguments); - if (this.shouldSee) { - let topicCount = 0, - postCount = 0; - - // Use data we already have before fetching live stats - this.site.get("categories").forEach((c) => { - if (!c.get("read_restricted")) { - topicCount += c.get("topic_count"); - postCount += c.get("post_count"); - } - }); - - if (topicCount < this.requiredTopics || postCount < this.requiredPosts) { - this.set("enabled", true); - this.fetchLiveStats(); - } - } - }, - - @discourseComputed( - "siteSettings.show_create_topics_notice", - "router.currentRouteName" - ) - shouldSee(showCreateTopicsNotice, currentRouteName) { - return ( - this.currentUser?.get("admin") && - showCreateTopicsNotice && - !this.site.get("wizard_required") && - !currentRouteName.startsWith("wizard") - ); - }, - - @discourseComputed( - "enabled", - "shouldSee", - "publicTopicCount", - "publicPostCount" - ) - hidden(enabled, shouldSee, publicTopicCount, publicPostCount) { - return ( - !enabled || - !shouldSee || - publicTopicCount == null || - publicPostCount == null - ); - }, - - @discourseComputed( - "publicTopicCount", - "publicPostCount", - "topicTrackingState.incomingCount" - ) - message(publicTopicCount, publicPostCount) { - let msg = null; - - if ( - publicTopicCount < this.requiredTopics && - publicPostCount < this.requiredPosts - ) { - msg = "too_few_topics_and_posts_notice_MF"; - } else if (publicTopicCount < this.requiredTopics) { - msg = "too_few_topics_notice_MF"; - } else { - msg = "too_few_posts_notice_MF"; - } - - return htmlSafe( - I18n.messageFormat(msg, { - requiredTopics: this.requiredTopics, - requiredPosts: this.requiredPosts, - currentTopics: publicTopicCount, - currentPosts: publicPostCount, - }) - ); - }, - - @observes("topicTrackingState.incomingCount") - fetchLiveStats() { - if (!this.enabled) { - return; - } - - LivePostCounts.find().then((stats) => { - if (stats) { - this.set("publicTopicCount", stats.get("public_topic_count")); - this.set("publicPostCount", stats.get("public_post_count")); - if ( - this.publicTopicCount >= this.requiredTopics && - this.publicPostCount >= this.requiredPosts - ) { - this.set("enabled", false); // No more checks - } - } - }); - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/custom-html-container.js b/app/assets/javascripts/discourse/app/components/custom-html-container.js deleted file mode 100644 index 87d5ddb040f..00000000000 --- a/app/assets/javascripts/discourse/app/components/custom-html-container.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from "@ember/component"; - -export default Component.extend({}); diff --git a/app/assets/javascripts/discourse/app/components/custom-html.js b/app/assets/javascripts/discourse/app/components/custom-html.js index 3fdab2dc364..e8703a19898 100644 --- a/app/assets/javascripts/discourse/app/components/custom-html.js +++ b/app/assets/javascripts/discourse/app/components/custom-html.js @@ -1,6 +1,8 @@ import Component from "@ember/component"; import { getCustomHTML } from "discourse/helpers/custom-html"; import { getOwner } from "discourse-common/lib/get-owner"; +import { hbs } from "ember-cli-htmlbars"; +import deprecated from "discourse-common/lib/deprecated"; export default Component.extend({ triggerAppEvent: null, @@ -12,11 +14,15 @@ export default Component.extend({ if (html) { this.set("html", html); - this.set("layoutName", "components/custom-html-container"); + this.set("layout", hbs`{{this.html}}`); } else { const template = getOwner(this).lookup(`template:${name}`); if (template) { - this.set("layoutName", name); + deprecated( + "Defining an hbs template for CustomHTML rendering is deprecated. Use plugin outlets instead.", + { id: "discourse.custom_html_template" } + ); + this.set("layout", template); } } }, diff --git a/app/assets/javascripts/discourse/app/components/d-button.js b/app/assets/javascripts/discourse/app/components/d-button.gjs similarity index 66% rename from app/assets/javascripts/discourse/app/components/d-button.js rename to app/assets/javascripts/discourse/app/components/d-button.gjs index 248c129e5c1..207958d0fc4 100644 --- a/app/assets/javascripts/discourse/app/components/d-button.js +++ b/app/assets/javascripts/discourse/app/components/d-button.gjs @@ -1,9 +1,14 @@ import { inject as service } from "@ember/service"; import { action } from "@ember/object"; import { empty, equal, notEmpty } from "@ember/object/computed"; +import { htmlSafe } from "@ember/template"; import GlimmerComponentWithDeprecatedParentView from "discourse/components/glimmer-component-with-deprecated-parent-view"; +import icon from "discourse-common/helpers/d-icon"; import deprecated from "discourse-common/lib/deprecated"; +import concatClass from "discourse/helpers/concat-class"; import DiscourseURL from "discourse/lib/url"; +import or from "truth-helpers/helpers/or"; +import { on } from "@ember/modifier"; import I18n from "I18n"; const ACTION_AS_STRING_DEPRECATION_ARGS = [ @@ -12,6 +17,62 @@ const ACTION_AS_STRING_DEPRECATION_ARGS = [ ]; export default class DButton extends GlimmerComponentWithDeprecatedParentView { + + @service router; @notEmpty("args.icon") diff --git a/app/assets/javascripts/discourse/app/components/d-document.js b/app/assets/javascripts/discourse/app/components/d-document.js index 7e8fef1bd3a..9cdda954f43 100644 --- a/app/assets/javascripts/discourse/app/components/d-document.js +++ b/app/assets/javascripts/discourse/app/components/d-document.js @@ -50,15 +50,9 @@ export default Component.extend({ } let count = pluginCounterFunctions.reduce((sum, fn) => sum + fn(), 0); - if (this.currentUser.redesigned_user_menu_enabled) { - count += this.currentUser.all_unread_notifications_count; - if (this.currentUser.unseen_reviewable_count) { - count += this.currentUser.unseen_reviewable_count; - } - } else { - count += - this.currentUser.unread_notifications + - this.currentUser.unread_high_priority_notifications; + count += this.currentUser.all_unread_notifications_count; + if (this.currentUser.unseen_reviewable_count) { + count += this.currentUser.unseen_reviewable_count; } this.documentTitle.updateNotificationCount(count, { forced: opts?.forced }); }, diff --git a/app/assets/javascripts/discourse/app/components/d-editor.hbs b/app/assets/javascripts/discourse/app/components/d-editor.hbs new file mode 100644 index 00000000000..072b0a70175 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-editor.hbs @@ -0,0 +1,104 @@ +
    +
    + {{yield}} + {{#if this.showFormTemplateForm}} + {{#if (gt @formTemplateIds.length 1)}} + + {{/if}} + + + + {{else}} +
    + + + + + + +
    + {{/if}} +
    + +
    +
    + {{#unless this.siteSettings.enable_diffhtml_preview}} + {{html-safe this.preview}} + {{/unless}} +
    + + + +
    +
    + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index a622955b4c6..019a6780964 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -1,4 +1,5 @@ import { ajax } from "discourse/lib/ajax"; +import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts"; import { caretPosition, inCodeBlock, @@ -33,7 +34,7 @@ import showModal from "discourse/lib/show-modal"; import { siteDir } from "discourse/lib/text-direction"; import { translations } from "pretty-text/emoji/data"; import { wantsNewWindow } from "discourse/lib/intercept-click"; -import { action } from "@ember/object"; +import { action, computed } from "@ember/object"; import TextareaTextManipulation, { getHead, } from "discourse/mixins/textarea-text-manipulation"; @@ -180,15 +181,14 @@ class Toolbar { const title = I18n.t(button.title || `composer.${button.id}_title`); if (button.shortcut) { - const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); - const mod = mac ? "Meta" : "Ctrl"; - - const shortcutTitle = `${translateModKey(mod + "+")}${translateModKey( - button.shortcut - )}`; + const shortcutTitle = `${translateModKey( + PLATFORM_KEY_MODIFIER + "+" + )}${translateModKey(button.shortcut)}`; createdButton.title = `${title} (${shortcutTitle})`; - this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton; + this.shortcuts[ + `${PLATFORM_KEY_MODIFIER}+${button.shortcut}`.toLowerCase() + ] = createdButton; } else { createdButton.title = title; } @@ -228,6 +228,35 @@ export default Component.extend(TextareaTextManipulation, { processPreview: true, composerFocusSelector: "#reply-control .d-editor-input", + selectedFormTemplateId: computed("formTemplateIds", { + get() { + if (this._selectedFormTemplateId) { + return this._selectedFormTemplateId; + } + + return this.formTemplateIds?.[0]; + }, + + set(key, value) { + return (this._selectedFormTemplateId = value); + }, + }), + + @action + updateSelectedFormTemplateId(formTemplateId) { + this.selectedFormTemplateId = formTemplateId; + }, + + @discourseComputed("formTemplateIds", "replyingToTopic", "editingPost") + showFormTemplateForm(formTemplateIds, replyingToTopic, editingPost) { + // TODO(@keegan): Remove !editingPost once we add edit/draft support for form templates + if (formTemplateIds?.length > 0 && !replyingToTopic && !editingPost) { + return true; + } + + return false; + }, + @discourseComputed("placeholder") placeholderTranslated(placeholder) { if (placeholder) { @@ -275,6 +304,9 @@ export default Component.extend(TextareaTextManipulation, { this._itsatrap.bind("tab", () => this.indentSelection("right")); this._itsatrap.bind("shift+tab", () => this.indentSelection("left")); + this._itsatrap.bind(`${PLATFORM_KEY_MODIFIER}+shift+.`, () => + this.send("insertCurrentTime") + ); // disable clicking on links in the preview this.element @@ -285,6 +317,7 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.on("composer:insert-block", this, "insertBlock"); this.appEvents.on("composer:insert-text", this, "insertText"); this.appEvents.on("composer:replace-text", this, "replaceText"); + this.appEvents.on("composer:apply-surround", this, "_applySurround"); this.appEvents.on( "composer:indent-selected-text", this, @@ -325,6 +358,7 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.off("composer:insert-block", this, "insertBlock"); this.appEvents.off("composer:insert-text", this, "insertText"); this.appEvents.off("composer:replace-text", this, "replaceText"); + this.appEvents.off("composer:apply-surround", this, "_applySurround"); this.appEvents.off( "composer:indent-selected-text", this, @@ -419,10 +453,12 @@ export default Component.extend(TextareaTextManipulation, { ); previewPromise = loadScript("/javascripts/diffhtml.min.js").then(() => { - window.diff.innerHTML( - this.element.querySelector(".d-editor-preview"), - cookedElement.innerHTML - ); + const previewElement = + this.element.querySelector(".d-editor-preview"); + // This is a workaround for a known bug in diffHTML + // https://github.com/tbranyen/diffhtml/issues/217#issuecomment-1479956332 + window.diff.release(previewElement); + window.diff.innerHTML(previewElement, cookedElement.innerHTML); }); } @@ -525,7 +561,11 @@ export default Component.extend(TextareaTextManipulation, { if (term === "") { if (this.emojiStore.favorites.length) { - return resolve(this.emojiStore.favorites.slice(0, 5)); + return resolve( + this.emojiStore.favorites + .filter((f) => !this.site.denied_emojis?.includes(f)) + .slice(0, 5) + ); } else { return resolve([ "slight_smile", @@ -550,12 +590,13 @@ export default Component.extend(TextareaTextManipulation, { return resolve([allTranslations[full]]); } + const emojiDenied = this.get("site.denied_emojis") || []; const match = term.match(/^:?(.*?):t([2-6])?$/); if (match) { const name = match[1]; const scale = match[2]; - if (isSkinTonableEmoji(name)) { + if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) { if (scale) { return resolve([`${name}:t${scale}`]); } else { @@ -567,6 +608,7 @@ export default Component.extend(TextareaTextManipulation, { const options = emojiSearch(term, { maxResults: 5, diversity: this.emojiStore.diversity, + exclude: emojiDenied, }); return resolve(options); @@ -616,6 +658,11 @@ export default Component.extend(TextareaTextManipulation, { } }, + _applySurround(head, tail, exampleKey, opts) { + const selected = this.getSelected(); + this.applySurround(selected, head, tail, exampleKey, opts); + }, + _toggleDirection() { let currentDir = this._$textarea.attr("dir") ? this._$textarea.attr("dir") @@ -763,6 +810,15 @@ export default Component.extend(TextareaTextManipulation, { } }, + insertCurrentTime() { + const sel = this.getSelected("", { lineVal: true }); + const timezone = this.currentUser.user_option.timezone; + const time = moment().format("HH:mm:ss"); + const date = moment().format("YYYY-MM-DD"); + + this.addText(sel, `[date=${date} time=${time} timezone="${timezone}"]`); + }, + focusIn() { this.set("isEditorFocused", true); }, diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs new file mode 100644 index 00000000000..fc7d3ca867d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox.hbs @@ -0,0 +1,83 @@ + + {{#if this.isVisible}} +
    +
    + + + {{#if this.shouldDisplayCarousel}} + + {{/if}} + + +
    +
    + {{/if}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox.js b/app/assets/javascripts/discourse/app/components/d-lightbox.js new file mode 100644 index 00000000000..a2cb4d8a262 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox.js @@ -0,0 +1,490 @@ +import { + ANIMATION_DURATION, + KEYBOARD_SHORTCUTS, + LAYOUT_TYPES, + LIGHTBOX_APP_EVENT_NAMES, + LIGHTBOX_ELEMENT_ID, + SWIPE_DIRECTIONS, + TITLE_ELEMENT_ID, +} from "discourse/lib/lightbox/constants"; +import { + createDownloadLink, + getSwipeDirection, + openImageInNewTab, + preloadItemImages, + scrollParentToElementCenter, + setCarouselScrollPosition, + setSiteThemeColor, +} from "discourse/lib/lightbox/helpers"; + +import Component from "@glimmer/component"; +import { bind } from "discourse-common/utils/decorators"; +import discourseLater from "discourse-common/lib/later"; +import { htmlSafe } from "@ember/template"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class DLightbox extends Component { + @service appEvents; + + @tracked items = []; + @tracked isVisible = false; + @tracked isLoading = false; + @tracked currentIndex = 0; + @tracked currentItem = {}; + + @tracked isZoomed = false; + @tracked isRotated = false; + @tracked isFullScreen = false; + @tracked rotationAmount = 0; + + @tracked hasCarousel = false; + @tracked hasExpandedTitle = false; + + options = {}; + callbacks = {}; + willClose = false; + elementId = LIGHTBOX_ELEMENT_ID; + titleElementId = TITLE_ELEMENT_ID; + animationDuration = ANIMATION_DURATION; + scrollPosition = 0; + + get layoutType() { + return window.innerWidth > window.innerHeight + ? LAYOUT_TYPES.HORIZONTAL + : LAYOUT_TYPES.VERTICAL; + } + + get CSSVars() { + const base = "--d-lightbox-image"; + + const variables = [ + `${base}-animation-duration: ${this.animationDuration}ms;`, + ]; + + if (!this.currentItem) { + return htmlSafe(variables.join("")); + } + + const { width, height, aspectRatio, dominantColor, fullsizeURL, smallURL } = + this.currentItem; + + variables.push( + `${base}-rotation: ${this.rotationAmount}deg`, + `${base}-width: ${width}px`, + `${base}-height: ${height}px`, + `${base}-aspect-ratio: ${aspectRatio}`, + `${base}-dominant-color: #${dominantColor}`, + `${base}-full-size-url: url(${encodeURI(fullsizeURL)})`, + `${base}-small-url: url(${encodeURI(smallURL)})` + ); + + return htmlSafe(variables.filter(Boolean).join(";")); + } + + get HTMLClassList() { + const base = "d-lightbox"; + + const classNames = [base]; + + if (!this.isVisible) { + return classNames.join(""); + } + + classNames.push( + this.layoutType && `is-${this.layoutType}`, + this.isVisible && `is-visible`, + this.isLoading ? `is-loading` : `is-finished-loading`, + this.isFullScreen && `is-fullscreen`, + this.isZoomed && `is-zoomed`, + this.isRotated && `is-rotated`, + this.canZoom && `can-zoom`, + this.hasExpandedTitle && `has-expanded-title`, + this.hasCarousel && `has-carousel`, + this.hasLoadingError && `has-loading-error`, + this.willClose && `will-close`, + this.isRotated && + this.rotationAmount && + `is-rotated-${this.rotationAmount}` + ); + + return classNames.filter(Boolean).join(" "); + } + + get shouldDisplayMainImageArrows() { + return ( + !this.options.isMobile && + this.canNavigate && + !this.hasCarousel && + !this.isZoomed && + !this.isRotated + ); + } + + get shouldDisplayCarousel() { + return this.hasCarousel && !this.isZoomed && !this.isRotated; + } + + get shouldDisplayCarouselArrows() { + return ( + !this.options.isMobile && + this.totalItemCount >= this.options.minCarosuelArrowItemCount + ); + } + + get shouldDisplayTitle() { + return !this.hasLoadingError && !this.isZoomed && !this.isRotated; + } + + get totalItemCount() { + return this.items?.length || 0; + } + + get counterIndex() { + return this.currentIndex ? this.currentIndex + 1 : 1; + } + + get canNavigate() { + return this.items?.length > 1; + } + + get canZoom() { + return !this.hasLoadingError && this.currentItem?.canZoom; + } + + get canRotate() { + return !this.hasLoadingError; + } + + get canDownload() { + return !this.hasLoadingError && this.options.canDownload; + } + + get canFullscreen() { + return !this.hasLoadingError; + } + + get hasLoadingError() { + return this.currentItem?.hasLoadingError; + } + + get nextButtonIcon() { + return this.options.isRTL ? "chevron-left" : "chevron-right"; + } + + get previousButtonIcon() { + return this.options.isRTL ? "chevron-right" : "chevron-left"; + } + + get zoomButtonIcon() { + return this.isZoomed ? "search-minus" : "search-plus"; + } + + @bind + registerAppEventListeners() { + this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open); + this.appEvents.on(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close); + } + + @bind + deregisterAppEventListners() { + this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.OPEN, this.open); + this.appEvents.off(LIGHTBOX_APP_EVENT_NAMES.CLOSE, this.close); + } + + @bind + open({ items, startingIndex, callbacks, options }) { + this.options = options; + + this.items = items; + this.currentIndex = startingIndex; + this.callbacks = callbacks; + + this.isLoading = true; + this.isVisible = true; + this.scrollPosition = window.scrollY; + + this.#setCurrentItem(this.currentIndex); + + if ( + this.options.zoomOnOpen && + this.currentItem?.canZoom && + !this.currentItem?.isZoomed + ) { + this.toggleZoom(); + } + + this.callbacks.onOpen?.({ + items: this.items, + currentItem: this.currentItem, + }); + } + + @bind + close() { + this.willClose = true; + + discourseLater(this.cleanup, this.animationDuration); + + this.callbacks.onClose?.(); + } + + async #setCurrentItem(index) { + this.#onBeforeItemChange(); + + this.currentIndex = (index + this.totalItemCount) % this.totalItemCount; + this.currentItem = await preloadItemImages(this.items[this.currentIndex]); + + this.#onAfterItemChange(); + } + + #onBeforeItemChange() { + this.callbacks.onItemWillChange?.({ + currentItem: this.currentItem, + }); + + this.isLoading = true; + this.isZoomed = false; + this.isRotated = false; + } + + #onAfterItemChange() { + this.isLoading = false; + + if (this.currentItem.dominantColor) { + setSiteThemeColor(this.currentItem.dominantColor); + } + + setCarouselScrollPosition({ + behavior: "smooth", + }); + + this.callbacks.onItemDidChange?.({ + currentItem: this.currentItem, + }); + + const nextItem = this.items[this.currentIndex + 1]; + return nextItem ? preloadItemImages(nextItem) : false; + } + + @bind + centerZoomedBackgroundPosition(zoomedImageContainer) { + return this.options.isMobile + ? scrollParentToElementCenter({ + element: zoomedImageContainer, + isRTL: this.options.isRTL, + }) + : false; + } + + zoomOnMouseover(event) { + const zoomedImageContainer = event.target; + + const offsetX = event.offsetX; + const offsetY = event.offsetY; + + const x = (offsetX / zoomedImageContainer.offsetWidth) * 100; + const y = (offsetY / zoomedImageContainer.offsetHeight) * 100; + + zoomedImageContainer.style.backgroundPosition = x + "% " + y + "%"; + } + + @bind + toggleZoom() { + if (this.isLoading || !this.canZoom) { + return; + } + + this.isZoomed = !this.isZoomed; + document.querySelector(".d-lightbox__close-button")?.focus(); + } + + @bind + rotateImage() { + this.rotationAmount = (this.rotationAmount + 90) % 360; + this.isRotated = this.rotationAmount !== 0; + } + + @bind + toggleFullScreen() { + this.isFullScreen = !this.isFullScreen; + + return this.isFullScreen + ? document.documentElement.requestFullscreen() + : document.exitFullscreen(); + } + + @bind + downloadImage() { + return createDownloadLink(this.currentItem); + } + + @bind + openInNewTab() { + return openImageInNewTab(this.currentItem); + } + + @bind + reloadImage() { + this.#setCurrentItem(this.currentIndex); + } + + @bind + toggleCarousel() { + this.hasCarousel = !this.hasCarousel; + + requestAnimationFrame(setCarouselScrollPosition); + } + + @bind + showNextItem() { + this.#setCurrentItem(this.currentIndex + 1); + } + + @bind + showPreviousItem() { + this.#setCurrentItem(this.currentIndex - 1); + } + + @bind + showSelectedImage(event) { + const targetIndex = event.target.dataset?.lightboxItemIndex; + return targetIndex ? this.#setCurrentItem(Number(targetIndex)) : false; + } + + @bind + toggleExpandTitle() { + this.hasExpandedTitle = !this.hasExpandedTitle; + } + + @bind + onKeyup({ key }) { + if (KEYBOARD_SHORTCUTS.PREVIOUS.includes(key)) { + return this.showPreviousItem(); + } + + if (KEYBOARD_SHORTCUTS.NEXT.includes(key)) { + return this.showNextItem(); + } + + if (key === KEYBOARD_SHORTCUTS.CLOSE) { + return this.close(); + } + + if (key === KEYBOARD_SHORTCUTS.ZOOM) { + return this.toggleZoom(); + } + + if (key === KEYBOARD_SHORTCUTS.FULLSCREEN) { + return this.toggleFullScreen(); + } + + if (key === KEYBOARD_SHORTCUTS.ROTATE) { + return this.rotateImage(); + } + + if (key === KEYBOARD_SHORTCUTS.DOWNLOAD) { + return this.downloadImage(); + } + + if (key === KEYBOARD_SHORTCUTS.CAROUSEL) { + return this.toggleCarousel(); + } + + if (key === KEYBOARD_SHORTCUTS.TITLE) { + return this.toggleExpandTitle(); + } + + if (key === KEYBOARD_SHORTCUTS.NEWTAB) { + return this.openInNewTab(); + } + } + + @bind + onTouchstart(event = Event) { + if (this.isZoomed) { + return false; + } + + this.touchstartX = event.changedTouches[0].screenX; + this.touchstartY = event.changedTouches[0].screenY; + } + + @bind + async onTouchend(event) { + if (this.isZoomed) { + return false; + } + + event.stopPropagation(); + + const touchendY = event.changedTouches[0].screenY; + const touchendX = event.changedTouches[0].screenX; + + const swipeDirection = await getSwipeDirection({ + touchstartX: this.touchstartX, + touchstartY: this.touchstartY, + touchendX, + touchendY, + }); + + switch (swipeDirection) { + case SWIPE_DIRECTIONS.LEFT: + this.options.isRTL ? this.showNextItem() : this.showPreviousItem(); + break; + case SWIPE_DIRECTIONS.RIGHT: + this.options.isRTL ? this.showPreviousItem() : this.showNextItem(); + break; + case SWIPE_DIRECTIONS.UP: + this.close(); + break; + case SWIPE_DIRECTIONS.DOWN: + this.toggleCarousel(); + break; + } + } + + @bind + cleanup() { + if (this.isVisible) { + this.hasCarousel = !!document.querySelector(".d-lightbox.has-carousel"); + this.hasExpandedTitle = false; + this.isLoading = false; + this.items = []; + this.currentIndex = 0; + this.isZoomed = false; + this.isRotated = false; + this.rotationAmount = 0; + + if (this.isFullScreen) { + this.toggleFullScreen(); + this.isFullScreen = false; + } + + this.isVisible = false; + this.willClose = false; + + this.resetScrollPosition(); + + this.callbacks.onCleanup?.(); + + this.callbacks = {}; + this.options = {}; + } + } + + resetScrollPosition() { + if (window.scrollY !== this.scrollPosition) { + window.scrollTo({ + left: 0, + top: parseInt(this.scrollPosition, 10), + behavior: "instant", + }); + } + } + + willDestroy() { + super.willDestroy(...arguments); + this.cleanup(); + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs new file mode 100644 index 00000000000..5ef4361fd8f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/backdrop.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs new file mode 100644 index 00000000000..75348d76d23 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/body.hbs @@ -0,0 +1,59 @@ +
    + {{#if @shouldDisplayMainImageArrows}} + + {{/if}} + {{#if @isLoading}} + + {{loading-spinner size="large"}} + + {{else if @hasLoadingError}} + + + {{i18n "experimental_lightbox.image_load_error"}} + + {{else if @isZoomed}} +
    + {{else}} + + + {{/if}} + {{#if @shouldDisplayMainImageArrows}} + + {{/if}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs new file mode 100644 index 00000000000..c5867364eff --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/carousel.hbs @@ -0,0 +1,49 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs new file mode 100644 index 00000000000..3427e2b7dc2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/footer.hbs @@ -0,0 +1,48 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs new file mode 100644 index 00000000000..e9068d69288 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/header.hbs @@ -0,0 +1,54 @@ +
    + {{#if @canNavigate}} + + {{/if}} +
    + {{#if @canDownload}} +
    +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs new file mode 100644 index 00000000000..5220c12c72f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-lightbox/screen-reader-announcer.hbs @@ -0,0 +1,16 @@ +
    +

    + {{i18n + "experimental_lightbox.screen_reader_image_title" + current=@counterIndex + total=@totalItemCount + title=@currentItem.title + }} +

    +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.hbs b/app/assets/javascripts/discourse/app/components/d-modal-body.hbs new file mode 100644 index 00000000000..79681ae73bf --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.hbs @@ -0,0 +1,12 @@ +{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }} + +
    + {{yield}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js index f01bc23aa9a..0ba0ea4f801 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal-body.js +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js @@ -1,52 +1,54 @@ -import Component from "@ember/component"; -import { scheduleOnce } from "@ember/runloop"; -export default Component.extend({ - classNames: ["modal-body"], - fixed: false, - submitOnEnter: true, - dismissable: true, - attributeBindings: ["tabindex"], - tabindex: -1, +// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) - didInsertElement() { - this._super(...arguments); - this._modalAlertElement = document.getElementById("modal-alert"); - if (this._modalAlertElement) { - this._clearFlash(); +import Component from "@glimmer/component"; +import { disableImplicitInjections } from "discourse/lib/implicit-injections"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; +import { DEBUG } from "@glimmer/env"; + +const LEGACY_ERROR = + "d-modal-body should only be used inside a legacy controller-based d-modal. https://meta.discourse.org/t/268057"; + +function pick(object, keys) { + const result = {}; + for (const key of keys) { + if (key in object) { + result[key] = object[key]; } + } + return result; +} - let fixedParent = this.element.closest(".d-modal.fixed-modal"); - if (fixedParent) { - this.set("fixed", true); - $(fixedParent).modal("show"); - } +@disableImplicitInjections +export default class DModalBody extends Component { + @service appEvents; + @service modal; - scheduleOnce("afterRender", this, this._afterFirstRender); - this.appEvents.on("modal-body:flash", this, "_flash"); - this.appEvents.on("modal-body:clearFlash", this, "_clearFlash"); - }, + @tracked fixed = false; - willDestroyElement() { - this._super(...arguments); - this.appEvents.off("modal-body:flash", this, "_flash"); - this.appEvents.off("modal-body:clearFlash", this, "_clearFlash"); - this.appEvents.trigger("modal:body-dismissed"); - }, - - _afterFirstRender() { - const maxHeight = this.maxHeight; - if (maxHeight) { - const maxHeightFloat = parseFloat(maxHeight) / 100.0; - if (maxHeightFloat > 0) { - const viewPortHeight = $(window).height(); - this.element.style.maxHeight = - Math.floor(maxHeightFloat * viewPortHeight) + "px"; + @action + didInsert(element) { + if (element.closest(".d-modal:not(.d-modal-legacy)")) { + // eslint-disable-next-line no-console + console.error(LEGACY_ERROR); + if (DEBUG) { + throw new Error(LEGACY_ERROR); } } + this.appEvents.trigger("modal-body:clearFlash"); + + const fixedParent = element.closest(".d-modal.fixed-modal"); + if (fixedParent) { + this.fixed = true; + $(fixedParent).modal("show"); + this.modal.hidden = false; + } + this.appEvents.trigger( "modal:body-shown", - this.getProperties( + pick(this.args, [ "title", "rawTitle", "fixed", @@ -54,34 +56,15 @@ export default Component.extend({ "rawSubtitle", "submitOnEnter", "dismissable", - "headerClass" - ) + "headerClass", + "modalClass", + "titleAriaElementId", + ]) ); - }, + } - _clearFlash() { - if (this._modalAlertElement) { - this._modalAlertElement.innerHTML = ""; - this._modalAlertElement.classList.remove( - "alert", - "alert-error", - "alert-info", - "alert-success", - "alert-warning" - ); - } - }, - - _flash(msg) { - this._clearFlash(); - if (!this._modalAlertElement) { - return; - } - - this._modalAlertElement.classList.add( - "alert", - `alert-${msg.messageClass || "success"}` - ); - this._modalAlertElement.innerHTML = msg.text || ""; - }, -}); + @action + willDestroy() { + this.appEvents.trigger("modal:body-dismissed"); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/components/d-modal-cancel.hbs b/app/assets/javascripts/discourse/app/components/d-modal-cancel.hbs similarity index 70% rename from app/assets/javascripts/discourse/app/templates/components/d-modal-cancel.hbs rename to app/assets/javascripts/discourse/app/components/d-modal-cancel.hbs index f66de85d829..f409a846791 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-modal-cancel.hbs +++ b/app/assets/javascripts/discourse/app/components/d-modal-cancel.hbs @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-cancel.js b/app/assets/javascripts/discourse/app/components/d-modal-cancel.js deleted file mode 100644 index 0e6d50b17d4..00000000000 --- a/app/assets/javascripts/discourse/app/components/d-modal-cancel.js +++ /dev/null @@ -1,4 +0,0 @@ -import Component from "@ember/component"; -export default Component.extend({ - tagName: "", -}); diff --git a/app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs b/app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs new file mode 100644 index 00000000000..90a2c5f4665 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs @@ -0,0 +1,95 @@ +{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }} + +{{! template-lint-disable no-pointer-down-event-binding }} +{{! template-lint-disable no-invalid-interactive }} + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-legacy.js b/app/assets/javascripts/discourse/app/components/d-modal-legacy.js new file mode 100644 index 00000000000..ef8da04ba41 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-modal-legacy.js @@ -0,0 +1,253 @@ +// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) + +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { next, schedule } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; +import { disableImplicitInjections } from "discourse/lib/implicit-injections"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; + +@disableImplicitInjections +export default class DModal extends Component { + @service appEvents; + @service modal; + + @tracked wrapperElement; + @tracked modalBodyData = {}; + @tracked flash; + + get modalStyle() { + if (this.args.modalStyle === "inline-modal") { + return "inline-modal"; + } else { + return "fixed-modal"; + } + } + + get submitOnEnter() { + if ("submitOnEnter" in this.modalBodyData) { + return this.modalBodyData.submitOnEnter; + } else { + return true; + } + } + + get dismissable() { + if ("dismissable" in this.modalBodyData) { + return this.modalBodyData.dismissable; + } else { + return true; + } + } + + get title() { + if (this.modalBodyData.title) { + return I18n.t(this.modalBodyData.title); + } else if (this.modalBodyData.rawTitle) { + return this.modalBodyData.rawTitle; + } else { + return this.args.title; + } + } + + get subtitle() { + if (this.modalBodyData.subtitle) { + return I18n.t(this.modalBodyData.subtitle); + } + + return this.modalBodyData.rawSubtitle || this.args.subtitle; + } + + get headerClass() { + return this.modalBodyData.headerClass; + } + + get panels() { + return this.args.panels; + } + + get errors() { + return this.args.errors; + } + + @action + setupListeners(element) { + this.appEvents.on("modal:body-shown", this._modalBodyShown); + this.appEvents.on("modal-body:flash", this._flash); + this.appEvents.on("modal-body:clearFlash", this._clearFlash); + document.documentElement.addEventListener( + "keydown", + this._handleModalEvents + ); + this.wrapperElement = element; + } + + @action + cleanupListeners() { + this.appEvents.off("modal:body-shown", this._modalBodyShown); + this.appEvents.off("modal-body:flash", this._flash); + this.appEvents.off("modal-body:clearFlash", this._clearFlash); + document.documentElement.removeEventListener( + "keydown", + this._handleModalEvents + ); + } + + get ariaLabelledby() { + if (this.modalBodyData.titleAriaElementId) { + return this.modalBodyData.titleAriaElementId; + } else if (this.args.titleAriaElementId) { + return this.args.titleAriaElementId; + } else if (this.args.title) { + return "discourse-modal-title"; + } + } + + get modalClass() { + return this.modalBodyData.modalClass || this.args.modalClass; + } + + triggerClickOnEnter(e) { + if (!this.submitOnEnter) { + return false; + } + + // skip when in a form or a textarea element + if ( + e.target.closest("form") || + (document.activeElement && document.activeElement.nodeName === "TEXTAREA") + ) { + return false; + } + + return true; + } + + @action + handleMouseDown(e) { + if (!this.dismissable) { + return; + } + + if ( + e.target.classList.contains("modal-middle-container") || + e.target.classList.contains("modal-outer-container") + ) { + // Send modal close (which bubbles to ApplicationRoute) if clicked outside. + // We do this because some CSS of ours seems to cover the backdrop and makes + // it unclickable. + return this.args.closeModal?.("initiatedByClickOut"); + } + } + + @bind + _modalBodyShown(data) { + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (data.fixed) { + this.modal.hidden = false; + } + + this.modalBodyData = data; + + next(() => { + schedule("afterRender", () => { + this._trapTab(); + }); + }); + } + + @bind + _handleModalEvents(event) { + if (this.args.hidden) { + return; + } + + if (event.key === "Escape" && this.dismissable) { + next(() => this.args.closeModal("initiatedByESC")); + } + + if (event.key === "Enter" && this.triggerClickOnEnter(event)) { + this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click(); + event.preventDefault(); + } + + if (event.key === "Tab") { + this._trapTab(event); + } + } + + _trapTab(event) { + if (this.args.hidden) { + return true; + } + + const innerContainer = this.wrapperElement.querySelector( + ".modal-inner-container" + ); + if (!innerContainer) { + return; + } + + let focusableElements = + '[autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'; + + if (!event) { + // on first trap we don't allow to focus modal-close + // and apply manual focus only if we don't have any autofocus element + const autofocusedElement = innerContainer.querySelector("[autofocus]"); + if ( + !autofocusedElement || + document.activeElement !== autofocusedElement + ) { + // if there's not autofocus, or the activeElement, is not the autofocusable element + // attempt to focus the first of the focusable elements or just the modal-body + // to make it possible to scroll with arrow down/up + ( + autofocusedElement || + innerContainer.querySelector( + focusableElements + ", button:not(.modal-close)" + ) || + innerContainer.querySelector(".modal-body") + )?.focus(); + } + + return; + } + + focusableElements += ", button:enabled"; + + const firstFocusableElement = + innerContainer.querySelector(focusableElements); + const focusableContent = innerContainer.querySelectorAll(focusableElements); + const lastFocusableElement = focusableContent[focusableContent.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === firstFocusableElement) { + lastFocusableElement?.focus(); + event.preventDefault(); + } + } else { + if (document.activeElement === lastFocusableElement) { + ( + innerContainer.querySelector(".modal-close") || firstFocusableElement + )?.focus(); + event.preventDefault(); + } + } + } + + @bind + _clearFlash() { + this.flash = null; + } + + @bind + _flash(msg) { + this.flash = msg; + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-modal.hbs b/app/assets/javascripts/discourse/app/components/d-modal.hbs new file mode 100644 index 00000000000..467813988db --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-modal.hbs @@ -0,0 +1,102 @@ +{{! template-lint-disable no-pointer-down-event-binding }} +{{! template-lint-disable no-invalid-interactive }} + + + + + + {{#unless @inline}} + + {{/unless}} + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js index d238fc3bea2..c262a3b979a 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.js +++ b/app/assets/javascripts/discourse/app/components/d-modal.js @@ -1,176 +1,113 @@ -import Component from "@ember/component"; -import I18n from "I18n"; -import { next, schedule } from "@ember/runloop"; -import discourseComputed, { bind, on } from "discourse-common/utils/decorators"; +import Component from "@glimmer/component"; +import ClassicComponent from "@ember/component"; +import { action } from "@ember/object"; +import { cached, tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; -export default Component.extend({ - classNameBindings: [ - ":modal", - ":d-modal", - "modalClass", - "modalStyle", - "hasPanels", - ], - attributeBindings: [ - "data-keyboard", - "aria-modal", - "role", - "ariaLabelledby:aria-labelledby", - ], - submitOnEnter: true, - dismissable: true, - title: null, - titleAriaElementId: null, - subtitle: null, - role: "dialog", - headerClass: null, +export const CLOSE_INITIATED_BY_BUTTON = "initiatedByCloseButton"; +export const CLOSE_INITIATED_BY_ESC = "initiatedByESC"; +export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut"; +export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow"; - init() { - this._super(...arguments); +const FLASH_TYPES = ["success", "error", "warning", "info"]; - // If we need to render a second modal for any reason, we can't - // use `elementId` - if (this.modalStyle !== "inline-modal") { - this.set("elementId", "discourse-modal"); - this.set("modalStyle", "fixed-modal"); - } - }, +export default class DModal extends Component { + @service modal; + @tracked wrapperElement; - // We handle ESC ourselves - "data-keyboard": "false", - // Inform screenreaders of the modal - "aria-modal": "true", - - @discourseComputed("title", "titleAriaElementId") - ariaLabelledby(title, titleAriaElementId) { - if (titleAriaElementId) { - return titleAriaElementId; - } - if (title) { - return "discourse-modal-title"; - } - - return; - }, - - @on("didInsertElement") - setUp() { - this.appEvents.on("modal:body-shown", this, "_modalBodyShown"); + @action + setupListeners(element) { document.documentElement.addEventListener( "keydown", - this._handleModalEvents + this.handleDocumentKeydown ); - }, + this.wrapperElement = element; + this.trapTab(); + } - @on("willDestroyElement") - cleanUp() { - this.appEvents.off("modal:body-shown", this, "_modalBodyShown"); + @action + cleanupListeners() { document.documentElement.removeEventListener( "keydown", - this._handleModalEvents + this.handleDocumentKeydown ); - }, + } - triggerClickOnEnter(e) { - if (!this.submitOnEnter) { + get dismissable() { + if (!this.args.closeModal) { + return false; + } else if ("dismissable" in this.args) { + return this.args.dismissable; + } else { + return true; + } + } + + shouldTriggerClickOnEnter(event) { + if (this.args.submitOnEnter === false) { return false; } // skip when in a form or a textarea element if ( - e.target.closest("form") || - (document.activeElement && document.activeElement.nodeName === "TEXTAREA") + event.target.closest("form") || + document.activeElement?.nodeName === "TEXTAREA" ) { return false; } return true; - }, + } + + @action + handleMouseUp(e) { + if (e.button !== 0) { + return; // Non-default mouse button + } - mouseDown(e) { if (!this.dismissable) { return; } - const $target = $(e.target); + if ( - $target.hasClass("modal-middle-container") || - $target.hasClass("modal-outer-container") + e.target.classList.contains("modal-middle-container") || + e.target.classList.contains("modal-outer-container") ) { - // Send modal close (which bubbles to ApplicationRoute) if clicked outside. - // We do this because some CSS of ours seems to cover the backdrop and makes - // it unclickable. - return ( - this.attrs.closeModal && this.attrs.closeModal("initiatedByClickOut") - ); + return this.args.closeModal?.({ + initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE, + }); } - }, + } - _modalBodyShown(data) { - if (this.isDestroying || this.isDestroyed) { - return; - } - - if (data.fixed) { - this.element.classList.remove("hidden"); - } - - if (data.title) { - this.set("title", I18n.t(data.title)); - } else if (data.rawTitle) { - this.set("title", data.rawTitle); - } - - if (data.subtitle) { - this.set("subtitle", I18n.t(data.subtitle)); - } else if (data.rawSubtitle) { - this.set("subtitle", data.rawSubtitle); - } else { - // if no subtitle provided, makes sure the previous subtitle - // of another modal is not used - this.set("subtitle", null); - } - - if ("submitOnEnter" in data) { - this.set("submitOnEnter", data.submitOnEnter); - } - - if ("dismissable" in data) { - this.set("dismissable", data.dismissable); - } else { - this.set("dismissable", true); - } - - this.set("headerClass", data.headerClass || null); - - schedule("afterRender", () => { - this._trapTab(); - }); - }, - - @bind - _handleModalEvents(event) { - if (this.element.classList.contains("hidden")) { + @action + handleDocumentKeydown(event) { + if (this.args.hidden) { return; } if (event.key === "Escape" && this.dismissable) { - next(() => this.attrs.closeModal("initiatedByESC")); + this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_ESC }); } - if (event.key === "Enter" && this.triggerClickOnEnter(event)) { - this.element?.querySelector(".modal-footer .btn-primary")?.click(); + + if (event.key === "Enter" && this.shouldTriggerClickOnEnter(event)) { + this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click(); event.preventDefault(); } - if (event.key === "Tab") { - this._trapTab(event); - } - }, - _trapTab(event) { - if (this.element.classList.contains("hidden")) { + if (event.key === "Tab") { + this.trapTab(event); + } + } + + @action + trapTab(event) { + if (this.args.hidden) { return true; } - const innerContainer = this.element.querySelector(".modal-inner-container"); + const innerContainer = this.wrapperElement.querySelector( + ".modal-inner-container" + ); if (!innerContainer) { return; } @@ -190,18 +127,21 @@ export default Component.extend({ // attempt to focus the first of the focusable elements or just the modal-body // to make it possible to scroll with arrow down/up ( + autofocusedElement || innerContainer.querySelector( focusableElements + ", button:not(.modal-close)" - ) || innerContainer.querySelector(".modal-body") + ) || + innerContainer.querySelector(".modal-body") )?.focus(); } return; } - focusableElements = focusableElements + ", button:enabled"; + focusableElements += ", button:enabled"; + const firstFocusableElement = - innerContainer.querySelectorAll(focusableElements)?.[0]; + innerContainer.querySelector(focusableElements); const focusableContent = innerContainer.querySelectorAll(focusableElements); const lastFocusableElement = focusableContent[focusableContent.length - 1]; @@ -218,5 +158,31 @@ export default Component.extend({ event.preventDefault(); } } - }, -}); + } + + @action + handleCloseButton() { + this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_BUTTON }); + } + + @action + validateFlashType(type) { + if (type && !FLASH_TYPES.includes(type)) { + throw `@flashType must be one of ${FLASH_TYPES.join(", ")}`; + } + } + + // Could be optimised to remove classic component once RFC389 is implemented + // https://rfcs.emberjs.com/id/0389-dynamic-tag-names + @cached + get dynamicElement() { + const tagName = this.args.tagName || "div"; + if (!["div", "form"].includes(tagName)) { + throw `@tagName must be form or div`; + } + + return class WrapperComponent extends ClassicComponent { + tagName = tagName; + }; + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-navigation-item.hbs b/app/assets/javascripts/discourse/app/components/d-navigation-item.hbs new file mode 100644 index 00000000000..66b1018c4fb --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-navigation-item.hbs @@ -0,0 +1,10 @@ +
  • + + {{yield}} + +
  • \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-navigation-item.js b/app/assets/javascripts/discourse/app/components/d-navigation-item.js index bb32a384900..3aee34f7a31 100644 --- a/app/assets/javascripts/discourse/app/components/d-navigation-item.js +++ b/app/assets/javascripts/discourse/app/components/d-navigation-item.js @@ -1,17 +1,29 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; -export default Component.extend({ - tagName: "li", +export default class DNavigationItem extends Component { + @service router; - route: null, + get ariaCurrent() { + // when there are multiple levels of navigation + // we want the active parent to get `aria-current="page"` + // and the active child to get `aria-current="location"` + if ( + this.args.ariaCurrentContext === "parentNav" && + this.router.currentRouteName !== this.args.route && // not the current route + this.router.currentRoute.parent.name.includes(this.args.route) // but is the current parent route + ) { + return "page"; + } - router: service(), + if (this.router.currentRouteName !== this.args.route) { + return null; + } - attributeBindings: ["ariaCurrent:aria-current", "title"], - - ariaCurrent: computed("router.currentRouteName", "route", function () { - return this.router.currentRouteName === this.route ? "page" : null; - }), -}); + if (this.args.ariaCurrentContext === "subNav") { + return "location"; + } else { + return "page"; + } + } +} diff --git a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs b/app/assets/javascripts/discourse/app/components/d-navigation.hbs similarity index 93% rename from app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs rename to app/assets/javascripts/discourse/app/components/d-navigation.hbs index 07b8ba8d15b..e91c42db8ba 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs +++ b/app/assets/javascripts/discourse/app/components/d-navigation.hbs @@ -51,8 +51,7 @@ + > + {{#if this.createTopicButtonDisabled}} + {{i18n "topic.create_disabled_category"}} + {{/if}} + + + + + {{this.computedLabel}} + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-toggle-switch.js b/app/assets/javascripts/discourse/app/components/d-toggle-switch.js new file mode 100644 index 00000000000..0bd175dd11d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-toggle-switch.js @@ -0,0 +1,15 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; + +export default class DToggleSwitch extends Component { + get computedLabel() { + if (this.args.label) { + return I18n.t(this.args.label); + } + return this.args.translatedLabel; + } + + get checked() { + return this.args.state ? "true" : "false"; + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-tooltip.gjs b/app/assets/javascripts/discourse/app/components/d-tooltip.gjs new file mode 100644 index 00000000000..1f0a9719f59 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-tooltip.gjs @@ -0,0 +1,41 @@ +import Component from "@glimmer/component"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import tippy from "tippy.js"; + +export default class DiscourseTooltip extends Component { + + + @service capabilities; + + #tippyInstance; + + willDestroy() { + super.willDestroy(...arguments); + this.#tippyInstance.destroy(); + } + + stopPropagation(instance, event) { + event.preventDefault(); + event.stopPropagation(); + } + + @action + initTippy(element) { + this.#tippyInstance = tippy(element.parentElement, { + content: element, + interactive: this.args.interactive ?? false, + trigger: this.capabilities.touch ? "click" : "mouseenter", + theme: this.args.theme || "d-tooltip", + arrow: this.args.arrow ? iconHTML("tippy-rounded-arrow") : false, + placement: this.args.placement || "bottom-start", + onTrigger: this.stopPropagation, + onUntrigger: this.stopPropagation, + }); + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-tooltip.js b/app/assets/javascripts/discourse/app/components/d-tooltip.js deleted file mode 100644 index cb88fd9cefc..00000000000 --- a/app/assets/javascripts/discourse/app/components/d-tooltip.js +++ /dev/null @@ -1,36 +0,0 @@ -import Component from "@ember/component"; -import { schedule } from "@ember/runloop"; -import tippy from "tippy.js"; -import Ember from "ember"; - -export default class DiscourseTooltip extends Component { - tagName = ""; - - didInsertElement() { - this._super(...arguments); - this._initTippy(); - } - - willDestroyElement() { - this._super(...arguments); - this._tippyInstance.destroy(); - } - - _initTippy() { - schedule("afterRender", () => { - // Ember.ViewUtils.getViewBounds is a private API, - // but it's not going to be dropped without a public deprecation warning, - // see: https://stackoverflow.com/a/50125938/3206146 - const viewBounds = Ember.ViewUtils.getViewBounds(this); - const element = viewBounds.firstNode; - const parent = viewBounds.parentElement; - this._tippyInstance = tippy(parent, { - content: element, - trigger: this.capabilities.touch ? "click" : "mouseenter", - theme: "d-tooltip", - arrow: false, - placement: "bottom-start", - }); - }); - } -} diff --git a/app/assets/javascripts/discourse/app/templates/components/date-input.hbs b/app/assets/javascripts/discourse/app/components/date-input.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/date-input.hbs rename to app/assets/javascripts/discourse/app/components/date-input.hbs diff --git a/app/assets/javascripts/discourse/app/components/date-input.js b/app/assets/javascripts/discourse/app/components/date-input.js index 16f12351120..61192bc5e59 100644 --- a/app/assets/javascripts/discourse/app/components/date-input.js +++ b/app/assets/javascripts/discourse/app/components/date-input.js @@ -54,7 +54,7 @@ export default Component.extend({ if (this._picker && this.date) { const parsedDate = this.date instanceof moment ? this.date : moment(this.date); - this._picker.setDate(parsedDate.toDate(), true); + this._picker.setDate(parsedDate, true); } }); }); @@ -66,7 +66,7 @@ export default Component.extend({ if (this._picker && this.date) { const parsedDate = this.date instanceof moment ? this.date : moment(this.date); - this._picker.setDate(parsedDate.toDate(), true); + this._picker.setDate(parsedDate, true); } if (this._picker && this.relativeDate) { @@ -75,7 +75,7 @@ export default Component.extend({ ? this.relativeDate : moment(this.relativeDate); - this._picker.setMinDate(parsedRelativeDate.toDate(), true); + this._picker.setMinDate(parsedRelativeDate, true); } if (this._picker && !this.date) { diff --git a/app/assets/javascripts/discourse/app/components/date-picker-future.js b/app/assets/javascripts/discourse/app/components/date-picker-future.js index d975498fea8..87e8602f554 100644 --- a/app/assets/javascripts/discourse/app/components/date-picker-future.js +++ b/app/assets/javascripts/discourse/app/components/date-picker-future.js @@ -1,8 +1,6 @@ import DatePicker from "discourse/components/date-picker"; export default DatePicker.extend({ - layoutName: "components/date-picker", - _opts() { return { defaultDate: this.defaultDate || moment().add(1, "day").toDate(), diff --git a/app/assets/javascripts/discourse/app/components/date-picker-past.js b/app/assets/javascripts/discourse/app/components/date-picker-past.js index d3e90be43f0..7580be5912f 100644 --- a/app/assets/javascripts/discourse/app/components/date-picker-past.js +++ b/app/assets/javascripts/discourse/app/components/date-picker-past.js @@ -1,8 +1,6 @@ import DatePicker from "discourse/components/date-picker"; export default DatePicker.extend({ - layoutName: "components/date-picker", - _opts() { return { defaultDate: diff --git a/app/assets/javascripts/discourse/app/templates/components/date-picker.hbs b/app/assets/javascripts/discourse/app/components/date-picker.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/date-picker.hbs rename to app/assets/javascripts/discourse/app/components/date-picker.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/date-time-input-range.hbs b/app/assets/javascripts/discourse/app/components/date-time-input-range.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/date-time-input-range.hbs rename to app/assets/javascripts/discourse/app/components/date-time-input-range.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/date-time-input.hbs b/app/assets/javascripts/discourse/app/components/date-time-input.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/date-time-input.hbs rename to app/assets/javascripts/discourse/app/components/date-time-input.hbs diff --git a/app/assets/javascripts/discourse/app/components/date-time-input.js b/app/assets/javascripts/discourse/app/components/date-time-input.js index 864c1aeacbb..718f700b22c 100644 --- a/app/assets/javascripts/discourse/app/components/date-time-input.js +++ b/app/assets/javascripts/discourse/app/components/date-time-input.js @@ -66,7 +66,7 @@ export default Component.extend({ ); }, - @computed + @computed("timezone") get resolvedTimezone() { return this.timezone || moment.tz.guess(); }, diff --git a/app/assets/javascripts/discourse/app/templates/components/desktop-notification-config.hbs b/app/assets/javascripts/discourse/app/components/desktop-notification-config.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/desktop-notification-config.hbs rename to app/assets/javascripts/discourse/app/components/desktop-notification-config.hbs diff --git a/app/assets/javascripts/discourse/app/components/dialog-messages/group-delete.hbs b/app/assets/javascripts/discourse/app/components/dialog-messages/group-delete.hbs new file mode 100644 index 00000000000..b6d45b96cb0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/dialog-messages/group-delete.hbs @@ -0,0 +1,20 @@ +{{#if @model.members.length}} +

    + {{d-icon "users"}} + {{i18n "admin.groups.delete_details" count=@model.members.length}} +

    +{{/if}} +{{#if @model.message_count}} +

    + {{d-icon "envelope"}} + {{i18n + "admin.groups.delete_with_messages_confirm" + count=@model.message_count + }} +

    +{{/if}} + +

    + {{d-icon "exclamation-triangle"}} + {{i18n "admin.groups.delete_warning"}} +

    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.hbs b/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.hbs new file mode 100644 index 00000000000..47faec691fc --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.hbs @@ -0,0 +1,32 @@ +{{i18n "user.second_factor.delete_confirm_header"}} + +
      + {{#each @model.totps as |totp|}} +
    • {{totp.name}}
    • + {{/each}} + + {{#each @model.security_keys as |sk|}} +
    • {{sk.name}}
    • + {{/each}} + + {{#if this.currentUser.second_factor_backup_enabled}} +
    • {{i18n "user.second_factor_backup.title"}}
    • + {{/if}} +
    + +

    + {{html-safe + (i18n + "user.second_factor.delete_confirm_instruction" + confirm=this.disabledString + ) + }} +

    + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.js b/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.js new file mode 100644 index 00000000000..267352f6890 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/dialog-messages/second-factor-confirm-phrase.js @@ -0,0 +1,22 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class SecondFactorConfirmPhrase extends Component { + @service dialog; + @service currentUser; + + @tracked confirmPhraseInput = ""; + disabledString = I18n.t("user.second_factor.disable"); + + @action + onConfirmPhraseInput() { + if (this.confirmPhraseInput === this.disabledString) { + this.dialog.set("confirmButtonDisabled", false); + } else { + this.dialog.set("confirmButtonDisabled", true); + } + } +} diff --git a/app/assets/javascripts/discourse/app/components/directory-item.hbs b/app/assets/javascripts/discourse/app/components/directory-item.hbs new file mode 100644 index 00000000000..e05a5367c79 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/directory-item.hbs @@ -0,0 +1,38 @@ +
    + +
    + +{{#each this.columns as |column|}} + {{#if (directory-column-is-user-field column=column)}} +
    + + {{column.name}} + + {{directory-item-user-field-value item=this.item column=column}} +
    + {{else}} +
    + + + {{#if column.icon}} + {{d-icon column.icon}} + {{/if}} + {{directory-item-label item=this.item column=column}} + + + {{directory-item-value item=this.item column=column}} +
    + {{/if}} + +{{/each}} + +{{#if this.showTimeRead}} +
    + + {{i18n "directory.time_read"}} + + + {{format-duration this.item.time_read}} + +
    +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/directory-item.js b/app/assets/javascripts/discourse/app/components/directory-item.js index 0b557d6672a..f1b4e15a829 100644 --- a/app/assets/javascripts/discourse/app/components/directory-item.js +++ b/app/assets/javascripts/discourse/app/components/directory-item.js @@ -2,7 +2,8 @@ import Component from "@ember/component"; import { propertyEqual } from "discourse/lib/computed"; export default Component.extend({ - tagName: "tr", + tagName: "div", + classNames: ["directory-table__row"], classNameBindings: ["me"], me: propertyEqual("item.user.id", "currentUser.id"), columns: null, diff --git a/app/assets/javascripts/discourse/app/components/directory-table.hbs b/app/assets/javascripts/discourse/app/components/directory-table.hbs new file mode 100644 index 00000000000..f9aa74ba30a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/directory-table.hbs @@ -0,0 +1,37 @@ + + <:header> + + {{#each this.columns as |column|}} + + {{/each}} + + {{#if this.showTimeRead}} +
    +
    + {{i18n "directory.time_read"}} +
    +
    + {{/if}} + + <:body> + {{#each this.items as |item|}} + + {{/each}} + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/directory-table.js b/app/assets/javascripts/discourse/app/components/directory-table.js index 08cb163562c..cf031759516 100644 --- a/app/assets/javascripts/discourse/app/components/directory-table.js +++ b/app/assets/javascripts/discourse/app/components/directory-table.js @@ -2,109 +2,31 @@ import Component from "@ember/component"; import { action } from "@ember/object"; export default Component.extend({ - lastScrollPosition: 0, - ticking: false, - _topHorizontalScrollBar: null, - _tableContainer: null, _table: null, - _fakeScrollContent: null, didInsertElement() { this._super(...arguments); this.setProperties({ - _tableContainer: this.element.querySelector(".directory-table-container"), - _topHorizontalScrollBar: this.element.querySelector( - ".directory-table-top-scroll" - ), - _fakeScrollContent: this.element.querySelector( - ".directory-table-top-scroll-fake-content" - ), _table: this.element.querySelector(".directory-table"), + _columnCount: this.showTimeRead + ? this.attrs.columns.value.length + 1 + : this.attrs.columns.value.length, }); - this._tableContainer.addEventListener("scroll", this.onBottomScroll); - this._topHorizontalScrollBar.addEventListener("scroll", this.onTopScroll); - - // Set active header might have already scrolled the _tableContainer. - // Call onHorizontalScroll manually to scroll the _topHorizontalScrollBar - this.onResize(); - this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar); - window.addEventListener("resize", this.onResize); - }, - - @action - onResize() { - if ( - this._tableContainer.getBoundingClientRect().bottom < window.innerHeight - ) { - // Bottom of the table is visible. Hide the scrollbar - this._fakeScrollContent.style.height = 0; - } else { - this._fakeScrollContent.style.width = `${this._table.offsetWidth}px`; - this._fakeScrollContent.style.height = "1px"; - } - }, - - @action - onTopScroll() { - this.onHorizontalScroll(this._topHorizontalScrollBar, this._tableContainer); - }, - - @action - onBottomScroll() { - this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar); - }, - - @action - onHorizontalScroll(primary, replica) { - if ( - this.isDestroying || - this.isDestroyed || - this.lastScrollPosition === primary.scrollLeft - ) { - return; - } - - this.set("lastScrollPosition", primary.scrollLeft); - - if (!this.ticking) { - window.requestAnimationFrame(() => { - if (!this.isDestroying && !this.isDestroyed) { - replica.scrollLeft = this.lastScrollPosition; - this.set("ticking", false); - } - }); - - this.set("ticking", true); - } - }, - - willDestroyElement() { - this._tableContainer.removeEventListener("scroll", this.onBottomScroll); - this._topHorizontalScrollBar.removeEventListener( - "scroll", - this.onTopScroll - ); - window.removeEventListener("resize", this.onResize); + this._table.style.gridTemplateColumns = `minmax(13em, 3fr) repeat(${this._columnCount}, minmax(max-content, 1fr))`; }, @action setActiveHeader(header) { // After render, scroll table left to ensure the order by column is visible - if (!this._tableContainer) { - this.set( - "_tableContainer", - document.querySelector(".directory-table-container") - ); + if (!this._table) { + this.set("_table", document.querySelector(".directory-table")); } const scrollPixels = - header.offsetLeft + - header.offsetWidth + - 10 - - this._tableContainer.offsetWidth; + header.offsetLeft + header.offsetWidth + 10 - this._table.offsetWidth; if (scrollPixels > 0) { - this._tableContainer.scrollLeft = scrollPixels; + this._table.scrollLeft = scrollPixels; } }, }); diff --git a/app/assets/javascripts/discourse/app/templates/components/disabled-icon.hbs b/app/assets/javascripts/discourse/app/components/disabled-icon.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/disabled-icon.hbs rename to app/assets/javascripts/discourse/app/components/disabled-icon.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/discourse-banner.hbs b/app/assets/javascripts/discourse/app/components/discourse-banner.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/discourse-banner.hbs rename to app/assets/javascripts/discourse/app/components/discourse-banner.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/discourse-linked-text.hbs b/app/assets/javascripts/discourse/app/components/discourse-linked-text.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/discourse-linked-text.hbs rename to app/assets/javascripts/discourse/app/components/discourse-linked-text.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/discourse-tag-bound.hbs b/app/assets/javascripts/discourse/app/components/discourse-tag-bound.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/discourse-tag-bound.hbs rename to app/assets/javascripts/discourse/app/components/discourse-tag-bound.hbs diff --git a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js index 4821ade3736..9a1e6038c07 100644 --- a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js +++ b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js @@ -1,5 +1,4 @@ import { observes, on } from "discourse-common/utils/decorators"; -import { next, schedule, scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import LoadMore from "discourse/mixins/load-more"; import UrlRefresh from "discourse/mixins/url-refresh"; @@ -10,29 +9,18 @@ export default Component.extend(UrlRefresh, LoadMore, { eyelineSelector: ".topic-list-item", documentTitle: service(), - @on("didInsertElement") - @observes("model") - _readjustScrollPosition() { - const scrollTo = this.session.topicListScrollPosition; - if (scrollTo >= 0) { - schedule("afterRender", () => { - if (this.element && !this.isDestroying && !this.isDestroyed) { - next(() => window.scrollTo(0, scrollTo)); - } - }); - } else { - scheduleOnce("afterRender", this, this.loadMoreUnlessFull); - } - }, - @on("didInsertElement") _monitorTrackingState() { - this.topicTrackingState.onStateChange(() => this._updateTrackingTopics()); + this.stateChangeCallbackId = this.topicTrackingState.onStateChange(() => + this._updateTrackingTopics() + ); }, @on("willDestroyElement") _removeTrackingStateChangeMonitor() { - this.topicTrackingState.offStateChange(this.stateChangeCallbackId); + if (this.stateChangeCallbackId) { + this.topicTrackingState.offStateChange(this.stateChangeCallbackId); + } }, _updateTrackingTopics() { @@ -44,10 +32,6 @@ export default Component.extend(UrlRefresh, LoadMore, { this.documentTitle.updateContextCount(this.incomingCount); }, - saveScrollPosition() { - this.session.set("topicListScrollPosition", $(window).scrollTop()); - }, - actions: { loadMore() { this.documentTitle.updateContextCount(0); @@ -60,7 +44,6 @@ export default Component.extend(UrlRefresh, LoadMore, { ) { this.addTopicsToBulkSelect(newTopics); } - schedule("afterRender", () => this.saveScrollPosition()); if (moreTopicsUrl && $(window).height() >= $(document).height()) { this.send("loadMore"); } diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-general.hbs b/app/assets/javascripts/discourse/app/components/edit-category-general.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-general.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-general.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-images.hbs b/app/assets/javascripts/discourse/app/components/edit-category-images.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-images.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-images.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-panel.hbs b/app/assets/javascripts/discourse/app/components/edit-category-panel.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-panel.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-panel.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-security.hbs b/app/assets/javascripts/discourse/app/components/edit-category-security.hbs similarity index 93% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-security.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-security.hbs index acb0ae027b9..31ec87b07e9 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-security.hbs +++ b/app/assets/javascripts/discourse/app/components/edit-category-security.hbs @@ -55,8 +55,9 @@ {{/unless}}
    - \ No newline at end of file +
    + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/app/components/edit-category-settings.hbs similarity index 89% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-settings.hbs index 5d1ab2c2375..2a36e0531d3 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/app/components/edit-category-settings.hbs @@ -4,11 +4,12 @@ -
    {{/if}} @@ -34,10 +35,11 @@ {{i18n "category.num_featured_topics"}} {{/if}} - @@ -151,7 +153,6 @@
    @@ -185,10 +186,23 @@ - + + +
    + +
    @@ -342,26 +356,32 @@ - + + + {{/if}} {{#unless this.emailInEnabled}} {{/unless}} - +
    + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-tab.hbs b/app/assets/javascripts/discourse/app/components/edit-category-tab.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-tab.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-tab.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-tags.hbs b/app/assets/javascripts/discourse/app/components/edit-category-tags.hbs similarity index 99% rename from app/assets/javascripts/discourse/app/templates/components/edit-category-tags.hbs rename to app/assets/javascripts/discourse/app/components/edit-category-tags.hbs index 5b39eadf2d2..d367e5a3554 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-tags.hbs +++ b/app/assets/javascripts/discourse/app/components/edit-category-tags.hbs @@ -82,4 +82,4 @@ @class="add-required-tag-group" /> - + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/edit-category-topic-template.hbs b/app/assets/javascripts/discourse/app/components/edit-category-topic-template.hbs new file mode 100644 index 00000000000..24264bace93 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/edit-category-topic-template.hbs @@ -0,0 +1,38 @@ +{{#if this.siteSettings.experimental_form_templates}} +
    + +
    + + {{#if this.showFormTemplate}} +
    + + +

    + {{#if this.currentUser.staff}} + + {{i18n "admin.form_templates.edit_category.select_template_help"}} + + {{/if}} +

    +
    + {{else}} + + {{/if}} +{{else}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/edit-category-topic-template.js b/app/assets/javascripts/discourse/app/components/edit-category-topic-template.js index b9125ebf1e0..c921f5f65a1 100644 --- a/app/assets/javascripts/discourse/app/components/edit-category-topic-template.js +++ b/app/assets/javascripts/discourse/app/components/edit-category-topic-template.js @@ -1,15 +1,40 @@ import { buildCategoryPanel } from "discourse/components/edit-category-panel"; -import { observes } from "discourse-common/utils/decorators"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; import { schedule } from "@ember/runloop"; +import { action, computed } from "@ember/object"; export default buildCategoryPanel("topic-template", { - // Modals are defined using the singleton pattern. - // Opening the insert link modal will destroy the edit category modal. - showInsertLinkButton: false, + showFormTemplate: computed("category.form_template_ids", { + get() { + return Boolean(this.category.form_template_ids.length); + }, + set(key, value) { + return value; + }, + }), - @observes("activeTab") + @discourseComputed("showFormTemplate") + templateTypeToggleLabel(showFormTemplate) { + if (showFormTemplate) { + return "admin.form_templates.edit_category.toggle_form_template"; + } + + return "admin.form_templates.edit_category.toggle_freeform"; + }, + + @action + toggleTemplateType() { + this.toggleProperty("showFormTemplate"); + + if (!this.showFormTemplate) { + // Clear associated form templates if switching to freeform + this.set("category.form_template_ids", []); + } + }, + + @observes("activeTab", "showFormTemplate") _activeTabChanged() { - if (this.activeTab) { + if (this.activeTab && !this.showFormTemplate) { schedule("afterRender", () => this.element.querySelector(".d-editor-input").focus() ); diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs b/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/edit-topic-timer-form.hbs rename to app/assets/javascripts/discourse/app/components/edit-topic-timer-form.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/emoji-group-buttons.hbs b/app/assets/javascripts/discourse/app/components/emoji-group-buttons.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/emoji-group-buttons.hbs rename to app/assets/javascripts/discourse/app/components/emoji-group-buttons.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/emoji-group-sections.hbs b/app/assets/javascripts/discourse/app/components/emoji-group-sections.hbs similarity index 100% rename from app/assets/javascripts/discourse/app/templates/components/emoji-group-sections.hbs rename to app/assets/javascripts/discourse/app/components/emoji-group-sections.hbs diff --git a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs b/app/assets/javascripts/discourse/app/components/emoji-picker.hbs similarity index 96% rename from app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs rename to app/assets/javascripts/discourse/app/components/emoji-picker.hbs index c2dc26b2f27..12cd404d667 100644 --- a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.hbs @@ -1,10 +1,10 @@ {{#if this.isActive}} - {{! template-lint-disable no-invalid-interactive no-down-event-binding }} + {{! template-lint-disable no-invalid-interactive no-pointer-down-event-binding }}
    - {{! template-lint-enable no-invalid-interactive no-down-event-binding }} + {{! template-lint-enable no-invalid-interactive no-pointer-down-event-binding }}
    {{#if this.recentEmojis.length}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs b/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs deleted file mode 100644 index ac429bd300b..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs +++ /dev/null @@ -1,87 +0,0 @@ -
    -
    - {{yield}} - -
    - - - - - - -
    -
    - -
    -
    - {{#unless this.siteSettings.enable_diffhtml_preview}} - {{html-safe this.preview}} - {{/unless}} -
    - -
    -
    - - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs b/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs deleted file mode 100644 index 22481b8c348..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs +++ /dev/null @@ -1,56 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/d-navigation-item.hbs b/app/assets/javascripts/discourse/app/templates/components/d-navigation-item.hbs deleted file mode 100644 index d3fc03d783d..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/d-navigation-item.hbs +++ /dev/null @@ -1,3 +0,0 @@ - - {{yield}} - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/d-tooltip.hbs b/app/assets/javascripts/discourse/app/templates/components/d-tooltip.hbs deleted file mode 100644 index 97b23804af4..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/d-tooltip.hbs +++ /dev/null @@ -1,3 +0,0 @@ -
    - {{yield}} -
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs deleted file mode 100644 index 4561d5ecb68..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs +++ /dev/null @@ -1,16 +0,0 @@ - -{{#each this.columns as |column|}} - - {{#if (directory-column-is-user-field column=column)}} - {{directory-item-user-field-value item=this.item column=column}} - {{else}} - {{directory-item-value item=this.item column=column}} - {{/if}} - -{{/each}} - -{{#if this.showTimeRead}} - {{format-duration - this.item.time_read - }} -{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs deleted file mode 100644 index d8adc30add1..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs +++ /dev/null @@ -1,39 +0,0 @@ -
    -
    -
    - -
    - - - - {{#each this.columns as |column|}} - - {{/each}} - - {{#if this.showTimeRead}} - - {{/if}} - - - {{#each this.items as |item|}} - - {{/each}} - -
    {{i18n "directory.time_read"}}
    -
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/discovery-topics-list.hbs b/app/assets/javascripts/discourse/app/templates/components/discovery-topics-list.hbs deleted file mode 100644 index 5bd46512095..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/discovery-topics-list.hbs +++ /dev/null @@ -1 +0,0 @@ -{{yield (hash saveScrollPosition=this.saveScrollPosition)}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-topic-template.hbs b/app/assets/javascripts/discourse/app/templates/components/edit-category-topic-template.hbs deleted file mode 100644 index 265c0693d87..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-topic-template.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/json-editor.hbs b/app/assets/javascripts/discourse/app/templates/components/json-editor.hbs deleted file mode 100644 index 4ccd6b8c5de..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/json-editor.hbs +++ /dev/null @@ -1,16 +0,0 @@ - -
    -
    - - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/plugin-outlet.hbs b/app/assets/javascripts/discourse/app/templates/components/plugin-outlet.hbs deleted file mode 100644 index f5d83c4bbf4..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/plugin-outlet.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{#each this.connectors as |c|}} - -{{/each}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs b/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs deleted file mode 100644 index 2eb7e71912f..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs +++ /dev/null @@ -1,70 +0,0 @@ -
    - {{#if this.embedQuoteButton}} - - {{/if}} - - {{#if this.siteSettings.enable_fast_edit}} - {{#if this._canEditPost}} - - {{/if}} - {{/if}} - - {{#if this.quoteSharingEnabled}} - - {{#if this.quoteSharingShowLabel}} - - {{/if}} - - - {{#each this.quoteSharingSources as |source|}} - - {{/each}} - - - - {{/if}} -
    - -
    - {{#if this.siteSettings.enable_fast_edit}} - {{#if this._displayFastEditInput}} -
    - ` + ); + + assert + .dom(this.element) + .includesText("/50", "initial value appears as expected"); + + await fillIn("textarea", "Hello World, this is a longer string"); + + assert + .dom(this.element) + .includesText("36/50", "updated value appears as expected"); + }); + + test("exceeding max length", async function (assert) { + this.max = 10; + this.value = "Hello World"; + + await render( + hbs`` + ); + + assert.dom(".char-counter.exceeded").exists("exceeded class is applied"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/composer-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/composer-editor-test.js index 28405f9cd36..f652474fb8e 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/composer-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/composer-editor-test.js @@ -3,6 +3,7 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { fillIn, render } from "@ember/test-helpers"; import { hbs } from "ember-cli-htmlbars"; import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import { query } from "discourse/tests/helpers/qunit-helpers"; module("Integration | Component | ComposerEditor", function (hooks) { setupRenderingTest(hooks); @@ -43,4 +44,22 @@ module("Integration | Component | ComposerEditor", function (hooks) { await fillIn("textarea", "@user-no @user-ok @user-nope"); }); + + test("preview sanitizes HTML", async function (assert) { + this.set("model", {}); + this.set("noop", () => {}); + + await render(hbs` + + `); + + await fillIn(".d-editor-input", `">`); + assert.strictEqual( + query(".d-editor-preview").innerHTML.trim(), + '

    ">

    ' + ); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-document-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-document-test.js index 885818fbdbc..0e626cb72e7 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-document-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-document-test.js @@ -19,10 +19,9 @@ function triggerTitleUpdate(appEvents) { module("Integration | Component | d-document", function (hooks) { setupRenderingTest(hooks); - test("when experimental user menu is enabled", async function (assert) { + test("with user menu", async function (assert) { const titleBefore = document.title; try { - this.currentUser.redesigned_user_menu_enabled = true; this.currentUser.user_option.title_count_mode = "notifications"; await render(hbs``); assert.strictEqual( @@ -46,32 +45,4 @@ module("Integration | Component | d-document", function (hooks) { document.title = titleBefore; } }); - - test("when experimental user menu is disabled", async function (assert) { - const titleBefore = document.title; - try { - this.currentUser.redesigned_user_menu_enabled = false; - this.currentUser.user_option.title_count_mode = "notifications"; - await render(hbs``); - assert.strictEqual( - getTitleCount(), - null, - "title doesn't have a count initially" - ); - - this.currentUser.unread_high_priority_notifications = 1; - this.currentUser.unread_notifications = 2; - this.currentUser.all_unread_notifications_count = 4; - this.currentUser.unseen_reviewable_count = 8; - triggerTitleUpdate(this.currentUser.appEvents); - - assert.strictEqual( - getTitleCount(), - 3, - "count in the title is the sum of unread_notifications and unread_high_priority_notifications" - ); - } finally { - document.title = titleBefore; - } - }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js index 3a6fbdbf8f7..8d46a09206b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js @@ -2,7 +2,6 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { click, fillIn, render, settled } from "@ember/test-helpers"; import { - chromeTest, exists, paste, query, @@ -45,16 +44,6 @@ module("Integration | Component | d-editor", function (hooks) { ); }); - test("preview sanitizes HTML", async function (assert) { - await render(hbs``); - - await fillIn(".d-editor-input", `">`); - assert.strictEqual( - query(".d-editor-preview").innerHTML.trim(), - '

    ">

    ' - ); - }); - test("updating the value refreshes the preview", async function (assert) { this.set("value", "evil trout"); @@ -81,7 +70,7 @@ module("Integration | Component | d-editor", function (hooks) { } function testCase(title, testFunc) { - chromeTest(title, async function (assert) { + test(title, async function (assert) { this.set("value", "hello world."); await render(hbs``); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js new file mode 100644 index 00000000000..874c75731e4 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/d-lightbox-test.js @@ -0,0 +1,85 @@ +import { click, render, settled } from "@ember/test-helpers"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { module, test } from "qunit"; + +import domFromString from "discourse-common/lib/dom-from-string"; +import { generateLightboxMarkup } from "discourse/tests/helpers/lightbox-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { setupLightboxes } from "discourse/lib/lightbox"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { SELECTORS } from "discourse/lib/lightbox/constants"; + +module("Integration | Component | d-lightbox", function (hooks) { + setupRenderingTest(hooks); + + test("it renders according to state", async function (assert) { + await render(hbs``); + + // lightbox container exists but is not visible + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).exists(); + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-visible"); + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("tabindex", "-1"); + assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist(); + + // it is hidden from screen readers + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("aria-hidden"); + + const container = domFromString(generateLightboxMarkup())[0]; + await setupLightboxes({ + container, + selector: SELECTORS.DEFAULT_ITEM_SELECTOR, + }); + + const lightboxedElement = container.querySelector( + SELECTORS.DEFAULT_ITEM_SELECTOR + ); + await click(lightboxedElement); + + await settled(); + + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasClass("is-visible"); + + assert + .dom(SELECTORS.LIGHTBOX_CONTAINER) + .hasClass(/^(is-vertical|is-horizontal)$/); + + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-zoomed"); + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-rotated"); + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-fullscreen"); + + assert.dom(SELECTORS.LIGHTBOX_CONTENT).exists(); + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveAria("hidden"); + + // the content is tabbable + assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("tabindex", "0"); + + // the content has a document role + assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("role", "document"); + + // the content has an aria-labelledby attribute + assert.dom(SELECTORS.LIGHTBOX_CONTENT).hasAttribute("aria-labelledby"); + + assert.strictEqual( + query(SELECTORS.LIGHTBOX_CONTENT) + .getAttribute("style") + .match(/--d-lightbox/g).length > 0, + true, + "the content has the correct css variables added" + ); + + // it has focus traps for keyboard navigation + assert.dom(SELECTORS.FOCUS_TRAP).exists(); + + await click(SELECTORS.CLOSE_BUTTON); + await settled(); + + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).doesNotHaveClass("is-visible"); + assert.dom(SELECTORS.LIGHTBOX_CONTENT).doesNotExist(); + + // it is not tabbable + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("tabindex", "-1"); + + // it is hidden from screen readers + assert.dom(SELECTORS.LIGHTBOX_CONTAINER).hasAttribute("aria-hidden"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js new file mode 100644 index 00000000000..3bb63656186 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js @@ -0,0 +1,114 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { click, render, settled } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; + +module("Integration | Component | d-modal", function (hooks) { + setupRenderingTest(hooks); + + test("title and subtitle", async function (assert) { + await render( + hbs`` + ); + assert.dom(".d-modal .title h3").hasText("Modal Title"); + assert.dom(".d-modal .subtitle").hasText("Modal Subtitle"); + }); + + test("named blocks", async function (assert) { + await render( + hbs` + + <:aboveHeader>aboveHeaderContent + <:headerAboveTitle>headerAboveTitleContent + <:headerBelowTitle>headerBelowTitleContent + <:belowHeader>belowHeaderContent + <:body>bodyContent + <:footer>footerContent + <:belowFooter>belowFooterContent + + ` + ); + + assert.dom(".d-modal").includesText("aboveHeaderContent"); + assert.dom(".d-modal").includesText("headerAboveTitleContent"); + assert.dom(".d-modal").includesText("headerBelowTitleContent"); + assert.dom(".d-modal").includesText("belowHeaderContent"); + assert.dom(".d-modal").includesText("bodyContent"); + assert.dom(".d-modal").includesText("footerContent"); + assert.dom(".d-modal").includesText("belowFooterContent"); + }); + + test("flash", async function (assert) { + await render(hbs``); + assert.dom(".d-modal .alert").hasText("Some message"); + }); + + test("flash type", async function (assert) { + await render( + hbs`` + ); + assert.dom(".d-modal .alert").hasClass("alert-success"); + }); + + test("dismissable", async function (assert) { + let closeModalCalled = false; + this.closeModal = () => (closeModalCalled = true); + this.set("dismissable", false); + + await render( + hbs`` + ); + + assert + .dom(".d-modal .modal-close") + .doesNotExist("close button is not shown when dismissable=false"); + + this.set("dismissable", true); + await settled(); + assert + .dom(".d-modal .modal-close") + .exists("close button is visible when dismissable=true"); + + await click(".d-modal .modal-close"); + assert.true( + closeModalCalled, + "closeModal is called when close button clicked" + ); + + closeModalCalled = false; + }); + + test("header and body classes", async function (assert) { + await render( + hbs`` + ); + + assert.dom(".d-modal .modal-header").hasClass("my-header-class"); + assert.dom(".d-modal .modal-body").hasClass("my-body-class"); + }); + + test("as a form", async function (assert) { + let submittedFormData; + this.handleSubmit = (event) => { + event.preventDefault(); + submittedFormData = new FormData(event.currentTarget); + }; + + await render( + hbs` + + <:body> + + + <:footer> + + + + ` + ); + + assert.dom("form.d-modal").exists(); + await click(".d-modal button[type=submit]"); + assert.deepEqual(submittedFormData.get("name"), "John Doe"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-toggle-switch-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-toggle-switch-test.js new file mode 100644 index 00000000000..cd7e82d7360 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/d-toggle-switch-test.js @@ -0,0 +1,66 @@ +import { module, test } from "qunit"; +import { render } from "@ember/test-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { hbs } from "ember-cli-htmlbars"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; + +module("Integration | Component | d-toggle-switch", function (hooks) { + setupRenderingTest(hooks); + + test("it renders a toggle button in a disabled state", async function (assert) { + this.set("state", false); + + await render(hbs``); + + assert.ok(exists(".d-toggle-switch"), "it renders a toggle switch"); + assert.strictEqual( + query(".d-toggle-switch__checkbox").getAttribute("aria-checked"), + "false" + ); + }); + + test("it renders a toggle button in a enabled state", async function (assert) { + this.set("state", true); + + await render(hbs``); + + assert.ok(exists(".d-toggle-switch"), "it renders a toggle switch"); + assert.strictEqual( + query(".d-toggle-switch__checkbox").getAttribute("aria-checked"), + "true" + ); + }); + + test("it renders a checkmark icon when enabled", async function (assert) { + this.set("state", true); + + await render(hbs``); + assert.ok(exists(".d-toggle-switch__checkbox-slider .d-icon-check")); + }); + + test("it renders a label for the button", async function (assert) { + I18n.translations[I18n.locale].js.test = { fooLabel: "foo" }; + this.set("state", true); + await render( + hbs`` + ); + + this.set("label", "test.fooLabel"); + + assert.strictEqual( + query(".d-toggle-switch__checkbox-label").innerText, + I18n.t("test.fooLabel") + ); + + this.setProperties({ + label: null, + translatedLabel: "bar", + }); + + assert.strictEqual( + query(".d-toggle-switch__checkbox-label").innerText, + "bar" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/date-input-test.js b/app/assets/javascripts/discourse/tests/integration/components/date-input-test.js index af0ce661bbc..e2b03834622 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/date-input-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/date-input-test.js @@ -54,4 +54,20 @@ module("Integration | Component | date-input", function (hooks) { assert.ok(this.date.isSame(moment("2019-02-02"))); }); + + test("always shows date in timezone of input timestamp", async function (assert) { + this.setProperties({ + date: moment.tz("2023-05-05T10:00:00", "ETC/GMT-12"), + }); + + await render( + hbs`` + ); + assert.strictEqual(dateInput().value, "2023-05-05"); + + this.setProperties({ + date: moment.tz("2023-05-05T10:00:00", "ETC/GMT+12"), + }); + assert.strictEqual(dateInput().value, "2023-05-05"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/date-time-input-range-test.js b/app/assets/javascripts/discourse/tests/integration/components/date-time-input-range-test.js index d668975eedf..dda9417e41b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/date-time-input-range-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/date-time-input-range-test.js @@ -50,11 +50,25 @@ module("Integration | Component | date-time-input-range", function (hooks) { await fillIn(toDateInput(), "2019-01-30"); await toTimeSelectKit.expand(); rows = toTimeSelectKit.rows(); - assert.equal(rows[0].dataset.name, "00:00"); assert.equal(rows[rows.length - 1].dataset.name, "23:45"); }); + test("setting relativeDate results in correct intervals (4x 15m then 30m)", async function (assert) { + this.setProperties({ state: { from: DEFAULT_DATE_TIME, to: null } }); + + await render( + hbs`` + ); + + await fillIn(toDateInput(), "2019-01-29"); + const toTimeSelectKit = selectKit(".to .d-time-input .select-kit"); + await toTimeSelectKit.expand(); + let rows = toTimeSelectKit.rows(); + assert.equal(rows[4].dataset.name, "15:45"); + assert.equal(rows[5].dataset.name, "16:15"); + }); + test("timezone support", async function (assert) { this.setProperties({ state: { diff --git a/app/assets/javascripts/discourse/tests/integration/components/date-time-input-test.js b/app/assets/javascripts/discourse/tests/integration/components/date-time-input-test.js index 52cdbc6f242..d64680a1ab2 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/date-time-input-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/date-time-input-test.js @@ -63,4 +63,23 @@ module("Integration | Component | date-time-input", function (hooks) { assert.notOk(exists(timeInput())); }); + + test("supports swapping timezone without changing visible date/time", async function (assert) { + this.setProperties({ + date: moment.tz("2023-05-05T12:00:00", "Europe/London"), + timezone: "Europe/London", + onChange: setDate, + }); + + await render( + hbs`` + ); + dateInput().dispatchEvent(new Event("change")); + assert.strictEqual(this.date.format(), "2023-05-05T12:00:00+01:00"); + + this.setProperties({ timezone: "Australia/Sydney" }); + + dateInput().dispatchEvent(new Event("change")); + assert.strictEqual(this.date.format(), "2023-05-05T12:00:00+10:00"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/dialog-holder-test.js b/app/assets/javascripts/discourse/tests/integration/components/dialog-holder-test.js index a1ed2606e0c..681fa5dbbce 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/dialog-holder-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/dialog-holder-test.js @@ -10,6 +10,8 @@ import { } from "@ember/test-helpers"; import { hbs } from "ember-cli-htmlbars"; import { query } from "discourse/tests/helpers/qunit-helpers"; +import GroupDeleteDialogMessage from "discourse/components/dialog-messages/group-delete"; +import SecondFactorConfirmPhrase from "discourse/components/dialog-messages/second-factor-confirm-phrase"; module("Integration | Component | dialog-holder", function (hooks) { setupRenderingTest(hooks); @@ -89,7 +91,7 @@ module("Integration | Component | dialog-holder", function (hooks) { ); // dismiss by pressing Esc - await triggerKeyEvent(document, "keydown", "Escape"); + await triggerKeyEvent(document.activeElement, "keydown", "Escape"); assert.ok(cancelCallbackCalled, "cancel callback called"); assert.ok(query("#dialog-holder"), "element is still in DOM"); @@ -394,17 +396,41 @@ module("Integration | Component | dialog-holder", function (hooks) { ".btn-primary element is not present in the dialog" ); }); - test("delete confirm with confirmation phase", async function (assert) { + + test("delete confirm with confirmation phrase component", async function (assert) { await render(hbs``); this.dialog.deleteConfirm({ - message: "A delete confirm message", - confirmPhrase: "test", + bodyComponent: SecondFactorConfirmPhrase, + confirmButtonDisabled: true, }); await settled(); assert.strictEqual(query(".btn-danger").disabled, true); - await fillIn("#confirm-phrase", "test"); + await fillIn("#confirm-phrase", "Disa"); + assert.strictEqual(query(".btn-danger").disabled, true); + await fillIn("#confirm-phrase", "Disable"); assert.strictEqual(query(".btn-danger").disabled, false); }); + + test("delete confirm with a component and model", async function (assert) { + await render(hbs``); + const message_count = 5; + + this.dialog.deleteConfirm({ + bodyComponent: GroupDeleteDialogMessage, + bodyComponentModel: { + message_count, + }, + }); + await settled(); + + assert.strictEqual( + query(".dialog-body p:first-child").innerText.trim(), + I18n.t("admin.groups.delete_with_messages_confirm", { + count: message_count, + }), + "correct message is shown in dialog" + ); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/checkbox-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/checkbox-test.js new file mode 100644 index 00000000000..18945954ca8 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/checkbox-test.js @@ -0,0 +1,43 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; + +module( + "Integration | Component | form-template-field | checkbox", + function (hooks) { + setupRenderingTest(hooks); + + test("renders a checkbox input", async function (assert) { + await render(hbs``); + + assert.ok( + exists( + ".form-template-field[data-field-type='checkbox'] input[type='checkbox']" + ), + "A checkbox component exists" + ); + }); + + test("renders a checkbox with a label", async function (assert) { + const attributes = { + label: "Click this box", + }; + this.set("attributes", attributes); + + await render( + hbs`` + ); + + assert.ok( + exists( + ".form-template-field[data-field-type='checkbox'] input[type='checkbox']" + ), + "A checkbox component exists" + ); + + assert.dom(".form-template-field__label").hasText("Click this box"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/dropdown-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/dropdown-test.js new file mode 100644 index 00000000000..f48cb8cfb6c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/dropdown-test.js @@ -0,0 +1,86 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; + +module( + "Integration | Component | form-template-field | dropdown", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + }); + + test("renders a dropdown with choices", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + this.set("choices", choices); + + await render( + hbs`` + ); + assert.ok( + exists(".form-template-field__dropdown"), + "A dropdown component exists" + ); + + const dropdown = queryAll( + ".form-template-field__dropdown option:not(.form-template-field__dropdown-placeholder)" + ); + assert.strictEqual(dropdown.length, 3, "it has 3 choices"); + assert.strictEqual( + dropdown[0].value, + "Choice 1", + "it has the correct name for choice 1" + ); + assert.strictEqual( + dropdown[1].value, + "Choice 2", + "it has the correct name for choice 2" + ); + assert.strictEqual( + dropdown[2].value, + "Choice 3", + "it has the correct name for choice 3" + ); + }); + + test("renders a dropdown with choices and attributes", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + const attributes = { + none_label: "Select a choice", + filterable: true, + }; + + this.set("choices", choices); + this.set("attributes", attributes); + + await render( + hbs`` + ); + assert.ok( + exists(".form-template-field__dropdown"), + "A dropdown component exists" + ); + + assert.strictEqual( + query(".form-template-field__dropdown-placeholder").innerText, + attributes.none_label, + "None label is correct" + ); + }); + + test("doesn't render a label when attribute is missing", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + this.set("choices", choices); + + await render( + hbs`` + ); + + assert.dom(".form-template-field__label").doesNotExist(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js new file mode 100644 index 00000000000..6c835794e9b --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js @@ -0,0 +1,61 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; + +module( + "Integration | Component | form-template-field | input", + function (hooks) { + setupRenderingTest(hooks); + + test("renders a text input", async function (assert) { + await render(hbs``); + + assert.ok( + exists( + ".form-template-field[data-field-type='input'] input[type='text']" + ), + "A text input component exists" + ); + }); + + test("renders a text input with attributes", async function (assert) { + const attributes = { + label: "My text label", + placeholder: "Enter text here", + }; + this.set("attributes", attributes); + + await render( + hbs`` + ); + + assert.ok( + exists( + ".form-template-field[data-field-type='input'] input[type='text']" + ), + "A text input component exists" + ); + + assert.dom(".form-template-field__label").hasText("My text label"); + assert.strictEqual( + query(".form-template-field__input").placeholder, + "Enter text here" + ); + }); + + test("doesn't render a label when attribute is missing", async function (assert) { + const attributes = { + placeholder: "Enter text here", + }; + this.set("attributes", attributes); + + await render( + hbs`` + ); + + assert.dom(".form-template-field__label").doesNotExist(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/multi-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/multi-select-test.js new file mode 100644 index 00000000000..e8dd72cea1c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/multi-select-test.js @@ -0,0 +1,87 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; + +module( + "Integration | Component | form-template-field | multi-select", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + }); + + test("renders a multi-select dropdown with choices", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + + this.set("choices", choices); + + await render( + hbs`` + ); + assert.ok( + exists(".form-template-field__multi-select"), + "A multiselect component exists" + ); + + const dropdown = queryAll( + ".form-template-field__multi-select option:not(.form-template-field__multi-select-placeholder)" + ); + assert.strictEqual(dropdown.length, 3, "it has 3 choices"); + assert.strictEqual( + dropdown[0].value, + "Choice 1", + "it has the correct name for choice 1" + ); + assert.strictEqual( + dropdown[1].value, + "Choice 2", + "it has the correct name for choice 2" + ); + assert.strictEqual( + dropdown[2].value, + "Choice 3", + "it has the correct name for choice 3" + ); + }); + + test("renders a multi-select with choices and attributes", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + const attributes = { + none_label: "Select a choice", + filterable: true, + }; + + this.set("choices", choices); + this.set("attributes", attributes); + + await render( + hbs`` + ); + assert.ok( + exists(".form-template-field__multi-select"), + "A multiselect dropdown component exists" + ); + + assert.strictEqual( + query(".form-template-field__multi-select-placeholder").innerText, + attributes.none_label, + "None label is correct" + ); + }); + + test("doesn't render a label when attribute is missing", async function (assert) { + const choices = ["Choice 1", "Choice 2", "Choice 3"]; + this.set("choices", choices); + + await render( + hbs`` + ); + + assert.dom(".form-template-field__label").doesNotExist(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js new file mode 100644 index 00000000000..b79e4730f2d --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js @@ -0,0 +1,57 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; + +module( + "Integration | Component | form-template-field | textarea", + function (hooks) { + setupRenderingTest(hooks); + + test("renders a textarea input", async function (assert) { + await render(hbs``); + + assert.ok( + exists(".form-template-field__textarea"), + "A textarea input component exists" + ); + }); + + test("renders a text input with attributes", async function (assert) { + const attributes = { + label: "My text label", + placeholder: "Enter text here", + }; + this.set("attributes", attributes); + + await render( + hbs`` + ); + + assert.ok( + exists(".form-template-field__textarea"), + "A textarea input component exists" + ); + + assert.dom(".form-template-field__label").hasText("My text label"); + assert.strictEqual( + query(".form-template-field__textarea").placeholder, + "Enter text here" + ); + }); + + test("doesn't render a label when attribute is missing", async function (assert) { + const attributes = { + placeholder: "Enter text here", + }; + this.set("attributes", attributes); + + await render( + hbs`` + ); + + assert.dom(".form-template-field__label").doesNotExist(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js new file mode 100644 index 00000000000..908ee7ca53e --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js @@ -0,0 +1,73 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; + +module( + "Integration | Component | form-template-field | wrapper", + function (hooks) { + setupRenderingTest(hooks); + + test("does not render a component when template content has invalid YAML", async function (assert) { + this.set("content", `- type: checkbox\n attributes;invalid`); + await render( + hbs`` + ); + + assert.notOk( + exists(".form-template-field"), + "A form template field should not exist" + ); + assert.ok(exists(".alert"), "An alert message should exist"); + }); + + test("renders a component based on the component type found in the content YAML", async function (assert) { + const content = `- type: checkbox\n- type: input\n- type: textarea\n- type: dropdown\n- type: upload\n- type: multi-select`; + const componentTypes = [ + "checkbox", + "input", + "textarea", + "dropdown", + "upload", + "multi-select", + ]; + this.set("content", content); + + await render( + hbs`` + ); + + componentTypes.forEach((componentType) => { + assert.ok( + exists(`.form-template-field[data-field-type='${componentType}']`), + `${componentType} component exists` + ); + }); + }); + + test("renders a component based on the component type found in the content YAML when passed ids", async function (assert) { + pretender.get("/form-templates/1.json", () => { + return response({ + form_template: { + id: 1, + name: "Bug Reports", + template: + '- type: checkbox\n choices:\n - "Option 1"\n - "Option 2"\n - "Option 3"\n attributes:\n label: "Enter question here"\n description: "Enter description here"\n validations:\n required: true', + }, + }); + }); + + this.set("formTemplateId", [1]); + await render( + hbs`` + ); + + assert.ok( + exists(`.form-template-field[data-field-type='checkbox']`), + `Checkbox component renders` + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/html-safe-helper-test.js b/app/assets/javascripts/discourse/tests/integration/components/html-safe-helper-test.js index 35a5df3ea30..32d70d8d08b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/html-safe-helper-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/html-safe-helper-test.js @@ -10,7 +10,7 @@ module("Integration | Component | html-safe-helper", function (hooks) { test("default", async function (assert) { this.set("string", "

    biscuits

    "); - await render(hbs`{{html-safe string}}`); + await render(hbs`{{html-safe this.string}}`); assert.ok(exists("p.cookies"), "it displays the string as html"); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/input-size-test.js b/app/assets/javascripts/discourse/tests/integration/components/input-size-test.js index a1d8049056b..bf24a842ddb 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/input-size-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/input-size-test.js @@ -1,7 +1,7 @@ -import { module } from "qunit"; +import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { render } from "@ember/test-helpers"; -import { chromeTest, query } from "discourse/tests/helpers/qunit-helpers"; +import { query } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; module( @@ -9,28 +9,24 @@ module( function (hooks) { setupRenderingTest(hooks); - // these tests fail on Firefox 78 in CI, skipping for now - chromeTest( - "icon only button, icon and text button, text only button", - async function (assert) { - await render( - hbs` ` - ); + test("icon only button, icon and text button, text only button", async function (assert) { + await render( + hbs` ` + ); - assert.strictEqual( - query(".btn:nth-child(1)").offsetHeight, - query(".btn:nth-child(2)").offsetHeight, - "have equal height" - ); - assert.strictEqual( - query(".btn:nth-child(1)").offsetHeight, - query(".btn:nth-child(3)").offsetHeight, - "have equal height" - ); - } - ); + assert.strictEqual( + query(".btn:nth-child(1)").offsetHeight, + query(".btn:nth-child(2)").offsetHeight, + "have equal height" + ); + assert.strictEqual( + query(".btn:nth-child(1)").offsetHeight, + query(".btn:nth-child(3)").offsetHeight, + "have equal height" + ); + }); - chromeTest("button + text input", async function (assert) { + test("button + text input", async function (assert) { await render( hbs` ` ); @@ -42,7 +38,7 @@ module( ); }); - chromeTest("combo box + input", async function (assert) { + test("combo box + input", async function (assert) { await render( hbs` ` ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.js b/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.js new file mode 100644 index 00000000000..4d44b2c426d --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/plugin-outlet-test.js @@ -0,0 +1,292 @@ +import { module, test } from "qunit"; +import { count, exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { click, render, settled } from "@ember/test-helpers"; +import { action } from "@ember/object"; +import { extraConnectorClass } from "discourse/lib/plugin-connectors"; +import { hbs } from "ember-cli-htmlbars"; +import { registerTemporaryModule } from "discourse/tests/helpers/temporary-module-helper"; +import { getOwner } from "discourse-common/lib/get-owner"; +import Component from "@glimmer/component"; +import templateOnly from "@ember/component/template-only"; +import { withSilencedDeprecationsAsync } from "discourse-common/lib/deprecated"; + +const PREFIX = "discourse/plugins/some-plugin/templates/connectors"; + +module("Integration | Component | plugin-outlet", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + extraConnectorClass("test-name/hello", { + actions: { + sayHello() { + this.set("hello", `${this.hello || ""}hello!`); + }, + }, + }); + + extraConnectorClass("test-name/hi", { + setupComponent() { + this.appEvents.on("hi:sayHi", this, this.say); + }, + + teardownComponent() { + this.appEvents.off("hi:sayHi", this, this.say); + }, + + @action + say() { + this.set("hi", "hi!"); + }, + + @action + sayHi() { + this.appEvents.trigger("hi:sayHi"); + }, + }); + + extraConnectorClass("test-name/conditional-render", { + shouldRender(args, context) { + return args.shouldDisplay || context.siteSettings.always_display; + }, + }); + + registerTemporaryModule( + `${PREFIX}/test-name/hello`, + hbs`{{this.username}} + + + {{this.hello}}` + ); + registerTemporaryModule( + `${PREFIX}/test-name/hi`, + hbs` + {{this.hi}}` + ); + registerTemporaryModule( + `${PREFIX}/test-name/conditional-render`, + hbs`I only render sometimes` + ); + }); + + test("Renders a template into the outlet", async function (assert) { + this.set("shouldDisplay", false); + await render( + hbs`` + ); + assert.strictEqual(count(".hello-username"), 1, "renders the hello outlet"); + assert.false( + exists(".conditional-render"), + "doesn't render conditional outlet" + ); + + await click(".say-hello"); + assert.strictEqual( + query(".hello-result").innerText, + "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( + query(".hi-result").innerText, + "hi!", + "actions delegate properly" + ); + }); + + test("Reevaluates shouldRender for argument changes", async function (assert) { + this.set("shouldDisplay", false); + await render( + hbs`` + ); + assert.false( + exists(".conditional-render"), + "doesn't render conditional outlet" + ); + + this.set("shouldDisplay", true); + await settled(); + assert.true(exists(".conditional-render"), "renders conditional outlet"); + }); + + test("Reevaluates shouldRender for other autotracked changes", async function (assert) { + this.set("shouldDisplay", false); + await render( + hbs`` + ); + assert.false( + exists(".conditional-render"), + "doesn't render conditional outlet" + ); + + getOwner(this).lookup("service:site-settings").always_display = true; + await settled(); + assert.true(exists(".conditional-render"), "renders conditional outlet"); + }); + + test("Other outlets are not re-rendered", async function (assert) { + this.set("shouldDisplay", false); + await render( + hbs`` + ); + + const otherOutletElement = query(".hello-username"); + otherOutletElement.someUniqueProperty = true; + + this.set("shouldDisplay", true); + await settled(); + assert.true(exists(".conditional-render"), "renders conditional outlet"); + + assert.true( + query(".hello-username").someUniqueProperty, + "other outlet is left untouched" + ); + }); +}); + +module( + "Integration | Component | plugin-outlet | connector class definitions", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + registerTemporaryModule( + `${PREFIX}/test-name/my-connector`, + hbs`{{@outletArgs.hello}}{{this.hello}}` + ); + }); + + test("uses classic PluginConnector by default", async function (assert) { + await render( + hbs`` + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText("world"); + }); + + test("uses templateOnly by default when @defaultGlimmer=true", async function (assert) { + await render( + hbs`` + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText(""); // `this.` unavailable in templateOnly components + }); + + test("uses simple object if provided", async function (assert) { + this.set("someBoolean", true); + + extraConnectorClass("test-name/my-connector", { + shouldRender(args) { + return args.someBoolean; + }, + + setupComponent(args, component) { + component.reopen({ + get hello() { + return args.hello + " from setupComponent"; + }, + }); + }, + }); + + await render( + hbs`` + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText("world from setupComponent"); + + this.set("someBoolean", false); + await settled(); + + assert.dom(".outletArgHelloValue").doesNotExist(); + }); + + test("ignores classic hooks for glimmer components", async function (assert) { + extraConnectorClass("test-name/my-connector", { + setupComponent(args, component) { + component.reopen({ + get hello() { + return args.hello + " from setupComponent"; + }, + }); + }, + }); + + await withSilencedDeprecationsAsync( + "discourse.plugin-outlet-classic-hooks", + async () => { + await render( + hbs`` + ); + } + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText(""); + }); + + test("uses custom component class if provided", async function (assert) { + this.set("someBoolean", true); + + extraConnectorClass( + "test-name/my-connector", + class MyOutlet extends Component { + static shouldRender(args) { + return args.someBoolean; + } + + get hello() { + return this.args.outletArgs.hello + " from custom component"; + } + } + ); + + await render( + hbs`` + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText("world from custom component"); + + this.set("someBoolean", false); + await settled(); + + assert.dom(".outletArgHelloValue").doesNotExist(); + }); + + test("uses custom templateOnly() if provided", async function (assert) { + this.set("someBoolean", true); + + extraConnectorClass( + "test-name/my-connector", + Object.assign(templateOnly(), { + shouldRender(args) { + return args.someBoolean; + }, + }) + ); + + await render( + hbs`` + ); + + assert.dom(".outletArgHelloValue").hasText("world"); + assert.dom(".thisHelloValue").hasText(""); // `this.` unavailable in templateOnly components + + this.set("someBoolean", false); + await settled(); + + assert.dom(".outletArgHelloValue").doesNotExist(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/relative-time-picker-test.js b/app/assets/javascripts/discourse/tests/integration/components/relative-time-picker-test.js index 5cc16d0ec17..f34169e9d84 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/relative-time-picker-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/relative-time-picker-test.js @@ -20,6 +20,14 @@ module("Integration | Component | relative-time-picker", function (hooks) { assert.strictEqual(prefilledDuration, "5"); }); + test("prefills and preselects null minutes", async function (assert) { + await render(hbs``); + + const prefilledDuration = query(".relative-time-duration").value; + assert.strictEqual(this.subject.header().value(), "mins"); + assert.strictEqual(prefilledDuration, ""); + }); + test("prefills and preselects hours based on translated minutes", async function (assert) { await render(hbs``); @@ -60,6 +68,14 @@ module("Integration | Component | relative-time-picker", function (hooks) { assert.strictEqual(prefilledDuration, "5"); }); + test("prefills and preselects null hours", async function (assert) { + await render(hbs``); + + const prefilledDuration = query(".relative-time-duration").value; + assert.strictEqual(this.subject.header().value(), "hours"); + assert.strictEqual(prefilledDuration, ""); + }); + test("prefills and preselects minutes based on translated hours", async function (assert) { await render(hbs``); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/combo-box-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/combo-box-test.js index 106985059b7..8899ed8e7d4 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/combo-box-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/combo-box-test.js @@ -75,8 +75,8 @@ module("Integration | Component | select-kit/combo-box", function (hooks) { @value={{this.value}} @content={{this.content}} @options={{hash - caretUpIcon=caretUpIcon - caretDownIcon=caretDownIcon + caretUpIcon=this.caretUpIcon + caretDownIcon=this.caretDownIcon }} /> `); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/email-group-user-chooser-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/email-group-user-chooser-test.js index 386d5f1efcd..8425ea5d67e 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/email-group-user-chooser-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/email-group-user-chooser-test.js @@ -21,7 +21,73 @@ module( await this.subject.expand(); await paste(query(".filter-input"), "foo,bar"); - assert.equal(this.subject.header().value(), "foo,bar"); + assert.strictEqual(this.subject.header().value(), "foo,bar"); + + await paste(query(".filter-input"), "evil,trout"); + assert.strictEqual(this.subject.header().value(), "foo,bar,evil,trout"); + + await paste(query(".filter-input"), "names with spaces"); + assert.strictEqual( + this.subject.header().value(), + "foo,bar,evil,trout,names,with,spaces" + ); + + await paste(query(".filter-input"), "@osama,@sam"); + assert.strictEqual( + this.subject.header().value(), + "foo,bar,evil,trout,names,with,spaces,osama,sam" + ); + + await paste(query(".filter-input"), "new\nlines"); + assert.strictEqual( + this.subject.header().value(), + "foo,bar,evil,trout,names,with,spaces,osama,sam,new,lines" + ); + }); + + test("excluding usernames", async function (assert) { + pretender.get("/u/search/users", () => { + const users = [ + { + username: "osama", + avatar_template: + "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png", + }, + { + username: "joshua", + avatar_template: + "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png", + }, + { + username: "sam", + avatar_template: + "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png", + }, + ]; + return response({ users }); + }); + + this.set("excludedUsernames", ["osama", "joshua"]); + await render( + hbs`` + ); + + await this.subject.expand(); + await this.subject.fillInFilter("a"); + + let suggestions = this.subject.displayedContent().mapBy("id"); + assert.deepEqual(suggestions, ["sam"]); + + this.set("excludedUsernames", ["osama"]); + await render( + hbs`` + ); + + await this.subject.expand(); + await this.subject.fillInFilter("a"); + + suggestions = this.subject.displayedContent().mapBy("id").sort(); + assert.deepEqual(suggestions, ["joshua", "sam"]); }); test("doesn't show user status by default", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js new file mode 100644 index 00000000000..09d248f862d --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js @@ -0,0 +1,51 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +module( + "Integration | Component | select-kit/form-template-chooser", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + pretender.get("/form-templates.json", () => { + return response({ + form_templates: [ + { id: 1, name: "template 1", template: "test: true" }, + { id: 2, name: "template 2", template: "test: false" }, + ], + }); + }); + }); + + test("displays form templates", async function (assert) { + await render(hbs``); + + await this.subject.expand(); + + assert.strictEqual(this.subject.rowByIndex(0).value(), "1"); + assert.strictEqual(this.subject.rowByIndex(1).value(), "2"); + }); + + test("displays selected value", async function (assert) { + this.set("value", [1]); + + await render(hbs``); + + assert.strictEqual(this.subject.header().name(), "template 1"); + }); + + test("when no templates are available, the select is disabled", async function (assert) { + pretender.get("/form-templates.json", () => { + return response({ form_templates: [] }); + }); + + await render(hbs``); + assert.ok(this.subject.isDisabled()); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-test.js index b652adc4699..89b80b856cf 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-test.js @@ -129,7 +129,7 @@ module( this.set("input", moment("2032-01-01 11:10")); await render( - hbs`` + hbs`` ); await fillIn(".time-input", "11:15"); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/mini-tag-chooser-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/mini-tag-chooser-test.js index 4824aea118a..29a68d284a3 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/mini-tag-chooser-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/mini-tag-chooser-test.js @@ -1,6 +1,6 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; +import { click, render, triggerKeyEvent } from "@ember/test-helpers"; import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; import { hbs } from "ember-cli-htmlbars"; @@ -148,3 +148,83 @@ module( }); } ); + +module( + "Integration | Component | select-kit/mini-tag-chooser useHeaderFilter=true", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + }); + + test("displays tags and filter in header", async function (assert) { + this.set("value", ["apple", "orange", "potato"]); + + await render( + hbs`` + ); + + assert.strictEqual(this.subject.header().value(), "apple,orange,potato"); + + assert.dom(".select-kit-header--filter").exists(); + assert.dom(".select-kit-header button[data-name='apple']").exists(); + assert.dom(".select-kit-header button[data-name='orange']").exists(); + assert.dom(".select-kit-header button[data-name='potato']").exists(); + + const filterInput = ".select-kit-header .filter-input"; + await click(filterInput); + + await triggerKeyEvent(filterInput, "keydown", "ArrowDown"); + await triggerKeyEvent(filterInput, "keydown", "Enter"); + + assert.dom(".select-kit-header button[data-name='monkey']").exists(); + + await triggerKeyEvent(filterInput, "keydown", "Backspace"); + + assert + .dom(".select-kit-header button[data-name='monkey']") + .doesNotExist(); + + await this.subject.fillInFilter("foo"); + await triggerKeyEvent(filterInput, "keydown", "Backspace"); + + assert.dom(".select-kit-header button[data-name='potato']").exists(); + }); + + test("removing a tag does not display the dropdown", async function (assert) { + this.set("value", ["apple", "orange", "potato"]); + + await render( + hbs`` + ); + + assert.strictEqual(this.subject.header().value(), "apple,orange,potato"); + + await click(".select-kit-header button[data-name='apple']"); + + assert.dom(".select-kit-collection").doesNotExist(); + assert.dom(".select-kit-header button[data-name='apple']").doesNotExist(); + assert.strictEqual(this.subject.header().value(), "orange,potato"); + + assert + .dom(".select-kit-header .filter-input") + .hasAttribute( + "placeholder", + "", + "Placeholder is empty when there is a selection" + ); + + await click(".select-kit-header button[data-name='orange']"); + await click(".select-kit-header button[data-name='potato']"); + + assert + .dom(".select-kit-header .filter-input") + .hasAttribute( + "placeholder", + "Search...", + "Placeholder is back to default when there is no selection" + ); + }); + } +); 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 cc8a13006d4..165303a677d 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 @@ -1,6 +1,6 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; +import { render, tab } from "@ember/test-helpers"; import I18n from "I18n"; import { hbs } from "ember-cli-htmlbars"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -63,6 +63,54 @@ module("Integration | Component | select-kit/single-select", function (hooks) { ); }); + test("accessibility", async function (assert) { + setDefaultState(this); + + await render(hbs``); + + await this.subject.expand(); + + const content = this.subject.displayedContent(); + assert.strictEqual(content.length, 3, "it shows rows"); + + assert + .dom(".select-kit-header") + .isFocused("it should focus the header first"); + + await tab(); + + assert + .dom(".select-kit-row:first-child") + .isFocused("it should focus the first row next"); + + await tab(); + + assert + .dom(".select-kit-row:nth-child(2)") + .isFocused("tab moves focus to 2nd row"); + + await tab(); + + assert + .dom(".select-kit-row:nth-child(3)") + .isFocused("tab moves focus to 3rd row"); + + await tab(); + + assert.notOk( + this.subject.isExpanded(), + "when there are no more rows, Tab collapses the dropdown" + ); + + await this.subject.expand(); + + assert.ok(this.subject.isExpanded(), "dropdown is expanded again"); + + await tab({ backwards: true }); + + assert.notOk(this.subject.isExpanded(), "Shift+Tab collapses the dropdown"); + }); + test("value", async function (assert) { setDefaultState(this); @@ -393,6 +441,24 @@ module("Integration | Component | select-kit/single-select", function (hooks) { ); }); + test("row index", async function (assert) { + this.setProperties({ + content: [ + { id: 1, name: "john" }, + { id: 2, name: "jane" }, + ], + value: null, + }); + + await render( + hbs`` + ); + await this.subject.expand(); + + assert.dom('.select-kit-row[data-index="0"][data-value="1"]').exists(); + assert.dom('.select-kit-row[data-index="1"][data-value="2"]').exists(); + }); + test("options.verticalOffset", async function (assert) { setDefaultState(this, { verticalOffset: -50 }); await render(hbs` @@ -411,4 +477,33 @@ module("Integration | Component | select-kit/single-select", function (hooks) { assert.ok(header.bottom > body.top, "it correctly offsets the body"); }); + + test("options.expandedOnInsert", async function (assert) { + setDefaultState(this); + await render(hbs` + + `); + + assert.dom(".single-select.is-expanded").exists(); + }); + + test("options.formName", async function (assert) { + setDefaultState(this); + await render(hbs` + + `); + + assert + .dom('input[name="foo"]') + .hasAttribute("type", "hidden") + .hasAttribute("value", "1"); + }); }); 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 d605269d556..452aa104590 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 @@ -85,7 +85,7 @@ module( }); test("notification reason text - user mailing list mode", async function (assert) { - this.currentUser.set("mailing_list_mode", true); + this.currentUser.set("user_option.mailing_list_mode", true); this.set("topic", buildTopic.call(this, { level: 2 })); await render(hbs` diff --git a/app/assets/javascripts/discourse/tests/integration/components/sidebar/section-link-test.js b/app/assets/javascripts/discourse/tests/integration/components/sidebar/section-link-test.js index 1267cff3962..dafb801d1ab 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/sidebar/section-link-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/sidebar/section-link-test.js @@ -6,30 +6,55 @@ import { render } from "@ember/test-helpers"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { query } from "discourse/tests/helpers/qunit-helpers"; +function containsExactly(assert, expectation, actual, message) { + assert.deepEqual( + Array.from(expectation).sort(), + Array.from(actual).sort(), + message + ); +} + module("Integration | Component | sidebar | section-link", function (hooks) { setupRenderingTest(hooks); test("default class attribute for link", async function (assert) { - const template = hbs``; + const template = hbs``; await render(template); - assert.strictEqual( - query("a").className, - "sidebar-section-link sidebar-section-link-test sidebar-row ember-view", + containsExactly( + assert, + query("a").classList, + ["ember-view", "sidebar-row", "sidebar-section-link"], "has the right class attribute for the link" ); }); test("custom class attribute for link", async function (assert) { - const template = hbs``; + const template = hbs``; await render(template); - assert.strictEqual( - query("a").className, - "sidebar-section-link sidebar-section-link-test sidebar-row 123 abc ember-view", + containsExactly( + assert, + query("a").classList, + ["123", "abc", "ember-view", "sidebar-row", "sidebar-section-link"], "has the right class attribute for the link" ); }); + + test("target attribute for link", async function (assert) { + const template = hbs``; + await render(template); + + assert.strictEqual(query("a").target, "_self"); + }); + + test("target attribute for link when user set external links in new tab", async function (assert) { + this.currentUser.user_option.external_links_in_new_tab = true; + const template = hbs``; + await render(template); + + assert.strictEqual(query("a").target, "_blank"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js index 7027735604f..4dc3e8203fd 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js @@ -21,7 +21,6 @@ module("Integration | Component | site-header", function (hooks) { test("unread notifications count rerenders when user's notifications count is updated", async function (assert) { this.currentUser.set("all_unread_notifications_count", 1); - this.currentUser.set("redesigned_user_menu_enabled", true); await render(hbs``); let unreadBadge = query( @@ -39,6 +38,7 @@ module("Integration | Component | site-header", function (hooks) { }); test("hamburger menu icon shows pending reviewables count", async function (assert) { + this.siteSettings.navigation_menu = "legacy"; this.currentUser.set("reviewable_count", 1); await render(hbs``); let pendingReviewablesBadge = query( @@ -47,15 +47,14 @@ module("Integration | Component | site-header", function (hooks) { assert.strictEqual(pendingReviewablesBadge.textContent, "1"); }); - test("hamburger menu icon doesn't show pending reviewables count when revamped user menu is enabled", async function (assert) { + test("hamburger menu icon doesn't show pending reviewables count for non-legacy navigation menu", async function (assert) { this.currentUser.set("reviewable_count", 1); - this.currentUser.set("redesigned_user_menu_enabled", true); + this.siteSettings.navigation_menu = "sidebar"; await render(hbs``); assert.ok(!exists(".hamburger-dropdown .badge-notification")); }); test("clicking outside the revamped menu closes it", async function (assert) { - this.currentUser.set("redesigned_user_menu_enabled", true); await render(hbs``); await click(".header-dropdown-toggle.current-user"); assert.ok(exists(".user-menu.revamped")); @@ -67,9 +66,11 @@ module("Integration | Component | site-header", function (hooks) { await render(hbs``); function getProperty() { - return getComputedStyle(document.body).getPropertyValue( + const rawValue = getComputedStyle(document.body).getPropertyValue( "--header-offset" ); + const roundedValue = Math.floor(parseFloat(rawValue)); + return roundedValue + "px"; } document.querySelector(".d-header").style.height = 90 + "px"; @@ -82,7 +83,6 @@ module("Integration | Component | site-header", function (hooks) { }); test("arrow up/down keys move focus between the tabs", async function (assert) { - this.currentUser.set("redesigned_user_menu_enabled", true); this.currentUser.set("can_send_private_messages", true); await render(hbs``); await click(".header-dropdown-toggle.current-user"); @@ -128,7 +128,6 @@ module("Integration | Component | site-header", function (hooks) { }); test("new personal messages bubble is prioritized over unseen reviewables and regular notifications bubbles", async function (assert) { - this.currentUser.set("redesigned_user_menu_enabled", true); this.currentUser.set("all_unread_notifications_count", 5); this.currentUser.set("new_personal_messages_notifications_count", 2); this.currentUser.set("unseen_reviewable_count", 3); @@ -169,7 +168,6 @@ module("Integration | Component | site-header", function (hooks) { }); test("unseen reviewables bubble is prioritized over regular notifications", async function (assert) { - this.currentUser.set("redesigned_user_menu_enabled", true); this.currentUser.set("all_unread_notifications_count", 5); this.currentUser.set("new_personal_messages_notifications_count", 0); this.currentUser.set("unseen_reviewable_count", 3); @@ -209,7 +207,6 @@ module("Integration | Component | site-header", function (hooks) { }); test("regular notifications bubble is shown if there are neither new personal messages nor unseen reviewables", async function (assert) { - this.currentUser.set("redesigned_user_menu_enabled", true); this.currentUser.set("all_unread_notifications_count", 5); this.currentUser.set("new_personal_messages_notifications_count", 0); this.currentUser.set("unseen_reviewable_count", 0); diff --git a/app/assets/javascripts/discourse/tests/integration/components/site-setting-test.js b/app/assets/javascripts/discourse/tests/integration/components/site-setting-test.js index 58b7c2dbfcc..63d3e426afc 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/site-setting-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/site-setting-test.js @@ -1,8 +1,9 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; +import { click, fillIn, render } from "@ember/test-helpers"; import { query } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; module("Integration | Component | site-setting", function (hooks) { setupRenderingTest(hooks); @@ -18,4 +19,44 @@ module("Integration | Component | site-setting", function (hooks) { assert.strictEqual(query(".formatted-selection").innerText, "a.com, b.com"); }); + + test("Error response with html_message is rendered as HTML", async function (assert) { + this.set("setting", { + setting: "test_setting", + value: "", + type: "input-setting-string", + }); + + const message = "

    Unable to update site settings

    "; + + pretender.put("/admin/site_settings/test_setting", () => { + return response(422, { html_message: true, errors: [message] }); + }); + + await render(hbs``); + await fillIn(query(".setting input"), "value"); + await click(query(".setting .d-icon-check")); + + assert.strictEqual(query(".validation-error h1").outerHTML, message); + }); + + test("Error response without html_message is not rendered as HTML", async function (assert) { + this.set("setting", { + setting: "test_setting", + value: "", + type: "input-setting-string", + }); + + const message = "

    Unable to update site settings

    "; + + pretender.put("/admin/site_settings/test_setting", () => { + return response(422, { errors: [message] }); + }); + + await render(hbs``); + await fillIn(query(".setting input"), "value"); + await click(query(".setting .d-icon-check")); + + assert.strictEqual(query(".validation-error h1"), null); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js index 06114f47f1c..735b7e5e58a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js @@ -54,6 +54,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { admin: true, moderator: false, trust_level: 2, + flair_group_id: 12, }); setupSiteGroups(this); @@ -73,6 +74,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { admin: false, moderator: true, trust_level: 2, + flair_group_id: 12, }); setupSiteGroups(this); @@ -92,6 +94,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { admin: false, moderator: false, trust_level: 2, + flair_group_id: 12, }); setupSiteGroups(this); @@ -106,11 +109,26 @@ module("Integration | Component | user-avatar-flair", function (hooks) { ); }); + test("avatar flair for trust level when set to none", async function (assert) { + this.set("args", { + admin: false, + moderator: false, + trust_level: 2, + flair_group_id: null, + }); + setupSiteGroups(this); + + await render(hbs``); + + assert.ok(!exists(".avatar-flair"), "it does not render a flair"); + }); + test("avatar flair for trust level with fallback", async function (assert) { this.set("args", { admin: false, moderator: false, trust_level: 3, + flair_group_id: 13, }); setupSiteGroups(this); @@ -130,6 +148,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { admin: false, moderator: false, trust_level: 3, + flair_group_id: 13, }); // Groups not serialized for anon on login_required this.site.groups = undefined; @@ -148,6 +167,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { flair_url: "fa-times", flair_bg_color: "123456", flair_color: "B0B0B0", + flair_group_id: 41, primary_group_name: "Band Geeks", }); setupSiteGroups(this); @@ -168,6 +188,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) { admin: false, moderator: false, trust_level: 1, + flair_group_id: 11, }); await render(hbs``); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-invited-show-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-invited-show-test.js new file mode 100644 index 00000000000..686a340ad64 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/user-invited-show-test.js @@ -0,0 +1,19 @@ +import { visit } from "@ember/test-helpers"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("User invites", function (needs) { + needs.user(); + + test("hides delete button based on can_delete_invite", async function (assert) { + await visit("/u/eviltrout/invited"); + + assert.dom("table.user-invite-list tbody tr").exists({ count: 2 }); + assert + .dom("table.user-invite-list tbody tr:nth-child(1) button.cancel") + .exists(); + assert + .dom("table.user-invite-list tbody tr:nth-child(2) button.cancel") + .doesNotExist(); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js index 36026d25119..c48d39df7a1 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js @@ -222,7 +222,8 @@ module("Integration | Component | user-menu", function (hooks) { }, ]; } else if ( - queryParams.filter_by_types === "mentioned,posted,quoted,replied" + queryParams.filter_by_types === + "mentioned,group_mentioned,posted,quoted,replied" ) { data = [ { @@ -280,7 +281,7 @@ module("Integration | Component | user-menu", function (hooks) { assert.ok(exists("#quick-access-replies.quick-access-panel")); assert.strictEqual( queryParams.filter_by_types, - "mentioned,posted,quoted,replied", + "mentioned,group_mentioned,posted,quoted,replied", "request params has filter_by_types set to `mentioned`, `posted`, `quoted` and `replied`" ); assert.strictEqual(queryParams.silent, "true"); @@ -303,4 +304,23 @@ module("Integration | Component | user-menu", function (hooks) { ); assert.strictEqual(queryAll("#quick-access-review-queue ul li").length, 8); }); + + test("count on the likes tab", async function (assert) { + this.currentUser.set("grouped_unread_notifications", { + [NOTIFICATION_TYPES.liked]: 1, + [NOTIFICATION_TYPES.liked_consolidated]: 2, + [NOTIFICATION_TYPES.reaction]: 3, + [NOTIFICATION_TYPES.bookmark_reminder]: 10, + }); + await render(template); + + const likesCountBadge = query( + "#user-menu-button-likes .badge-notification" + ); + assert.strictEqual( + likesCountBadge.textContent, + (1 + 2 + 3).toString(), + "combines unread counts for `liked`, `liked_consolidated` and `reaction` types" + ); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js deleted file mode 100644 index e0484728125..00000000000 --- a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js +++ /dev/null @@ -1,67 +0,0 @@ -import { module, test } from "qunit"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; -import { query } from "discourse/tests/helpers/qunit-helpers"; -import { hbs } from "ember-cli-htmlbars"; -import { withSilencedDeprecationsAsync } from "discourse-common/lib/deprecated"; - -function paste(element, text) { - let e = new Event("paste"); - e.clipboardData = { getData: () => text }; - element.dispatchEvent(e); -} - -module("Integration | Component | user-selector", function (hooks) { - setupRenderingTest(hooks); - - test("pasting a list of usernames", async function (assert) { - this.set("usernames", "evil,trout"); - - await withSilencedDeprecationsAsync( - "discourse.user-selector-component", - async () => { - await render( - hbs`` - ); - } - ); - - let element = query(".test-selector"); - - assert.strictEqual(this.get("usernames"), "evil,trout"); - paste(element, "zip,zap,zoom"); - assert.strictEqual(this.get("usernames"), "evil,trout,zip,zap,zoom"); - paste(element, "evil,abc,abc,abc"); - assert.strictEqual(this.get("usernames"), "evil,trout,zip,zap,zoom,abc"); - - this.set("usernames", ""); - paste(element, "names with spaces"); - assert.strictEqual(this.get("usernames"), "names,with,spaces"); - - this.set("usernames", null); - paste(element, "@eviltrout,@codinghorror sam"); - assert.strictEqual(this.get("usernames"), "eviltrout,codinghorror,sam"); - - this.set("usernames", null); - paste(element, "eviltrout\nsam\ncodinghorror"); - assert.strictEqual(this.get("usernames"), "eviltrout,sam,codinghorror"); - }); - - test("excluding usernames", async function (assert) { - this.set("usernames", "mark"); - this.set("excludedUsernames", ["jeff", "sam", "robin"]); - - await withSilencedDeprecationsAsync( - "discourse.user-selector-component", - async () => { - await render( - hbs`` - ); - } - ); - - let element = query(".test-selector"); - paste(element, "roman,penar,jeff,robin"); - assert.strictEqual(this.get("usernames"), "mark,roman,penar"); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js index 15e174b83aa..9cf52c8fcd0 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-status-message-test.js @@ -13,6 +13,7 @@ module("Integration | Component | user-status-message", function (hooks) { hooks.beforeEach(function () { this.currentUser.user_option.timezone = "UTC"; + this.status = { emoji: "tooth", description: "off to dentist" }; }); hooks.afterEach(function () { @@ -22,24 +23,22 @@ module("Integration | Component | user-status-message", function (hooks) { }); test("it renders user status emoji", async function (assert) { - this.set("status", { emoji: "tooth", description: "off to dentist" }); await render(hbs``); assert.ok(exists("img.emoji[alt='tooth']"), "the status emoji is shown"); }); test("it doesn't render status description by default", async function (assert) { - this.set("status", { emoji: "tooth", description: "off to dentist" }); await render(hbs``); assert.notOk(exists(".user-status-message-description")); }); test("it renders status description if enabled", async function (assert) { - this.set("status", { emoji: "tooth", description: "off to dentist" }); await render(hbs` `); + assert.equal( query(".user-status-message-description").innerText.trim(), "off to dentist" @@ -52,11 +51,7 @@ module("Integration | Component | user-status-message", function (hooks) { this.currentUser.user_option.timezone, true ); - this.set("status", { - emoji: "tooth", - description: "off to dentist", - ends_at: "2100-02-01T12:30:00.000Z", - }); + this.status.ends_at = "2100-02-01T12:30:00.000Z"; await render(hbs``); @@ -75,11 +70,7 @@ module("Integration | Component | user-status-message", function (hooks) { this.currentUser.user_option.timezone, true ); - this.set("status", { - emoji: "tooth", - description: "off to dentist", - ends_at: "2100-02-02T12:30:00.000Z", - }); + this.status.ends_at = "2100-02-02T12:30:00.000Z"; await render(hbs``); @@ -98,11 +89,7 @@ module("Integration | Component | user-status-message", function (hooks) { this.currentUser.user_option.timezone, true ); - this.set("status", { - emoji: "tooth", - description: "off to dentist", - ends_at: null, - }); + this.status.ends_at = null; await render(hbs``); @@ -113,11 +100,6 @@ module("Integration | Component | user-status-message", function (hooks) { }); test("it shows tooltip by default", async function (assert) { - this.set("status", { - emoji: "tooth", - description: "off to dentist", - }); - await render(hbs``); await mouseenter(); @@ -127,11 +109,6 @@ module("Integration | Component | user-status-message", function (hooks) { }); test("it doesn't show tooltip if disabled", async function (assert) { - this.set("status", { - emoji: "tooth", - description: "off to dentist", - }); - await render( hbs`` ); @@ -144,14 +121,20 @@ module("Integration | Component | user-status-message", function (hooks) { test("doesn't blow up with an anonymous user", async function (assert) { this.owner.unregister("service:current-user"); - this.set("status", { - emoji: "tooth", - description: "off to dentist", - ends_at: "2100-02-02T12:30:00.000Z", - }); + this.status.ends_at = "2100-02-02T12:30:00.000Z"; await render(hbs``); assert.dom(".user-status-message").exists(); }); + + test("accepts a custom css class", async function (assert) { + this.set("status", { emoji: "tooth", description: "off to dentist" }); + + await render( + hbs`` + ); + + assert.dom(".user-status-message.foo").exists(); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/button-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/button-test.js index 81bfad21ccb..0b9fdebede3 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/button-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/button-test.js @@ -63,4 +63,12 @@ module("Integration | Component | Widget | button", function (hooks) { assert.strictEqual(query("button").title, "foo bar"); }); + + test("translatedLabel skips no-text class in icon", async function (assert) { + this.set("args", { icon: "plus", translatedLabel: "foo bar" }); + + await render(hbs``); + + assert.ok(!exists("button.btn.btn-icon.no-text"), "skips no-text class"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/default-notification-item-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/default-notification-item-test.js index 509f09bcd66..afd515e5de4 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/default-notification-item-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/default-notification-item-test.js @@ -55,7 +55,11 @@ module( assert.ok(!exists("li.read")); await triggerEvent("li", "mouseup", { button: 1, which: 2 }); - assert.strictEqual(count("li.read"), 1); + assert.strictEqual( + count("li.read"), + 1, + "only one item is marked as read" + ); assert.strictEqual(requests, 1); }); } diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js index 6f7ca2c89a5..3ad97ae96c9 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js @@ -495,7 +495,7 @@ module("Integration | Component | Widget | post", function (hooks) { }); test("cooked content hidden", async function (assert) { - this.set("args", { cooked_hidden: true }); + this.set("args", { cooked_hidden: true, canSeeHiddenPost: true }); this.set("expandHidden", () => (this.unhidden = true)); await render(hbs` @@ -506,6 +506,17 @@ module("Integration | Component | Widget | post", function (hooks) { assert.ok(this.unhidden, "triggers the action"); }); + test(`cooked content hidden - can't view hidden post`, async function (assert) { + this.set("args", { cooked_hidden: true, canSeeHiddenPost: false }); + this.set("expandHidden", () => (this.unhidden = true)); + + await render(hbs` + + `); + + assert.ok(!exists(".topic-body .expand-hidden"), "button is not displayed"); + }); + test("expand first post", async function (assert) { const store = getOwner(this).lookup("service:store"); this.set("args", { expandablePost: true }); @@ -829,12 +840,12 @@ module("Integration | Component | Widget | post", function (hooks) { assert.ok(!exists(".toggle-summary")); }); - test("topic map - has summary", async function (assert) { - this.set("args", { showTopicMap: true, hasTopicSummary: true }); - this.set("showSummary", () => (this.summaryToggled = true)); + test("topic map - has top replies summary", async function (assert) { + this.set("args", { showTopicMap: true, hasTopRepliesSummary: true }); + this.set("showTopReplies", () => (this.summaryToggled = true)); await render( - hbs`` + hbs`` ); assert.strictEqual(count(".toggle-summary"), 1); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js deleted file mode 100644 index 9e3dbb26168..00000000000 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { module, test } from "qunit"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; -import { query } from "discourse/tests/helpers/qunit-helpers"; -import { hbs } from "ember-cli-htmlbars"; - -const CONTENT_DIV_SELECTOR = "li > a > div"; - -module( - "Integration | Component | Widget | quick-access-item", - function (hooks) { - setupRenderingTest(hooks); - - test("content attribute is escaped", async function (assert) { - this.set("args", { content: "bold" }); - - await render( - hbs`` - ); - - const contentDiv = query(CONTENT_DIV_SELECTOR); - assert.strictEqual(contentDiv.innerText, "bold"); - }); - - test("escapedContent attribute is not escaped", async function (assert) { - this.set("args", { escapedContent: ""quote"" }); - - await render( - hbs`` - ); - - const contentDiv = query(CONTENT_DIV_SELECTOR); - assert.strictEqual(contentDiv.innerText, '"quote"'); - }); - } -); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/render-glimmer-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/render-glimmer-test.js index 53d8b8d62d8..6d9df71943a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/render-glimmer-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/render-glimmer-test.js @@ -4,9 +4,11 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { click, fillIn, render } from "@ember/test-helpers"; import { hbs } from "ember-cli-htmlbars"; import widgetHbs from "discourse/widgets/hbs-compiler"; -import Widget from "discourse/widgets/widget"; +import Widget, { deleteFromRegistry } from "discourse/widgets/widget"; import ClassicComponent from "@ember/component"; -import RenderGlimmer from "discourse/widgets/render-glimmer"; +import RenderGlimmer, { + registerWidgetShim, +} from "discourse/widgets/render-glimmer"; import { bind } from "discourse-common/utils/decorators"; class DemoWidget extends Widget { @@ -126,12 +128,18 @@ module("Integration | Component | Widget | render-glimmer", function (hooks) { this.registry.register("widget:demo-widget", DemoWidget); this.registry.register("widget:toggle-demo-widget", ToggleDemoWidget); this.registry.register("component:demo-component", DemoComponent); + registerWidgetShim( + "render-glimmer-test-shim", + "div.my-wrapper", + hbs`{{@data.attr1}}` + ); }); hooks.afterEach(function () { this.registry.unregister("widget:demo-widget"); this.registry.unregister("widget:toggle-demo-widget"); this.registry.unregister("component:demo-component"); + deleteFromRegistry("render-glimmer-test-shim"); }); test("argument handling", async function (assert) { @@ -150,12 +158,6 @@ module("Integration | Component | Widget | render-glimmer", function (hooks) { ); await fillIn("input.dynamic-value-input", "somedynamicvalue"); - assert.strictEqual( - query("div.glimmer-content").innerText, - "arg1=val1 dynamicArg=", - "changed arguments do not change before rerender" - ); - await click(".my-widget button"); assert.strictEqual( query("div.glimmer-content").innerText, @@ -192,16 +194,10 @@ module("Integration | Component | Widget | render-glimmer", function (hooks) { DemoComponent.eventLog = []; await fillIn("input.dynamic-value-input", "somedynamicvalue"); - assert.deepEqual( - DemoComponent.eventLog, - [], - "component is not notified of attr change before widget rerender" - ); - await click(".my-widget button"); assert.deepEqual( DemoComponent.eventLog, - ["didReceiveAttrs"], + ["didReceiveAttrs", "didReceiveAttrs"], // once for input, once for event "component is notified of attr change during widget rerender" ); @@ -310,4 +306,13 @@ module("Integration | Component | Widget | render-glimmer", function (hooks) { await click(".toggleButton"); assert.strictEqual(query("div.glimmer-wrapper").innerText, "One"); }); + + test("registerWidgetShim can register a fake widget", async function (assert) { + await render( + hbs`` + ); + + assert.dom("div.my-wrapper span.shim-content").exists(); + assert.dom("div.my-wrapper span.shim-content").hasText("val1"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-participant-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-participant-test.js index b49ea9f2267..c52b9f454c9 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-participant-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/topic-participant-test.js @@ -34,6 +34,7 @@ module( flair_name: "devs", flair_url: "/images/d-logo-sketch-small.png", flair_bg_color: "222", + flair_group_id: "41", }); await render( diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/user-menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/user-menu-test.js deleted file mode 100644 index 5290dd6628f..00000000000 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/user-menu-test.js +++ /dev/null @@ -1,229 +0,0 @@ -import { module, test } from "qunit"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { click, render } from "@ember/test-helpers"; -import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; -import { hbs } from "ember-cli-htmlbars"; -import sinon from "sinon"; -import DiscourseURL from "discourse/lib/url"; -import I18n from "I18n"; - -module("Integration | Component | Widget | user-menu", function (hooks) { - setupRenderingTest(hooks); - - test("basics", async function (assert) { - await render(hbs``); - - assert.ok(exists(".user-menu")); - assert.ok(exists(".user-preferences-link")); - assert.ok(exists(".user-notifications-link")); - assert.ok(exists(".user-bookmarks-link")); - assert.ok(exists(".quick-access-panel")); - assert.ok(exists(".notifications-dismiss")); - }); - - test("notifications", async function (assert) { - await render(hbs``); - - const links = queryAll(".quick-access-panel li a"); - - assert.strictEqual(links.length, 6); - assert.ok(links[1].href.includes("/t/a-slug/123")); - - assert.ok( - links[2].href.includes( - "/u/eviltrout/notifications/likes-received?acting_username=aquaman" - ) - ); - - assert.strictEqual( - links[2].text, - `aquaman ${I18n.t("notifications.liked_consolidated_description", { - count: 5, - })}` - ); - - assert.ok(links[3].href.includes("/u/test2/messages/group/test")); - assert.ok( - links[3].innerHTML.includes( - I18n.t("notifications.group_message_summary", { - count: 5, - group_name: "test", - }) - ) - ); - - assert.ok(links[4].href.includes("/u/test1")); - assert.ok( - links[4].innerHTML.includes( - I18n.t("notifications.invitee_accepted", { username: "test1" }) - ) - ); - - assert.ok(links[5].href.includes("/g/test")); - assert.ok( - links[5].innerHTML.includes( - I18n.t("notifications.membership_request_accepted", { - group_name: "test", - }) - ) - ); - - const routeToStub = sinon.stub(DiscourseURL, "routeTo"); - await click(".user-notifications-link"); - assert.ok( - routeToStub.calledWith(query(".user-notifications-link").dataset.url), - "a second click should redirect to the full notifications page" - ); - }); - - test("log out", async function (assert) { - this.set("logout", () => (this.loggedOut = true)); - - await render( - hbs`` - ); - - await click(".user-preferences-link"); - - assert.ok(exists(".logout")); - - await click(".logout button"); - assert.ok(this.loggedOut); - }); - - test("private messages - disabled", async function (assert) { - this.currentUser.setProperties({ - admin: false, - moderator: false, - can_send_private_messages: false, - }); - - await render(hbs``); - - assert.ok(!exists(".user-pms-link")); - }); - - test("private messages - enabled", async function (assert) { - this.currentUser.setProperties({ - admin: false, - moderator: false, - can_send_private_messages: true, - }); - - await render(hbs``); - - const userPmsLink = query(".user-pms-link").dataset.url; - assert.ok(userPmsLink); - await click(".user-pms-link"); - - const message = query(".quick-access-panel li a"); - assert.ok(message); - - assert.ok( - message.href.includes("/t/bug-can-not-render-emoji-properly/174/2"), - "should link to the next unread post" - ); - assert.ok( - message.innerHTML.includes("mixtape"), - "should include the last poster's username" - ); - assert.ok( - message.innerHTML.match(//), - "should correctly render emoji in message title" - ); - - const routeToStub = sinon.stub(DiscourseURL, "routeTo"); - await click(".user-pms-link"); - assert.ok( - routeToStub.calledWith(userPmsLink), - "a second click should redirect to the full private messages page" - ); - }); - - test("bookmarks", async function (assert) { - await render(hbs``); - - await click(".user-bookmarks-link"); - - const allBookmarks = queryAll(".quick-access-panel li a"); - const bookmark = allBookmarks[0]; - - assert.ok( - bookmark.href.includes("/t/yelling-topic-title/119"), - "the Post bookmark should have a link to the topic" - ); - assert.ok( - bookmark.innerHTML.includes("someguy"), - "should include the last poster's username" - ); - assert.ok( - bookmark.innerHTML.match(//), - "should correctly render emoji in bookmark title" - ); - assert.ok( - bookmark.innerHTML.includes("d-icon-bookmark"), - "should use the correct icon based on no reminder_at present" - ); - - const routeToStub = sinon.stub(DiscourseURL, "routeTo"); - await click(".user-bookmarks-link"); - assert.ok( - routeToStub.calledWith(query(".user-bookmarks-link").dataset.url), - "a second click should redirect to the full bookmarks page" - ); - - const nonPostBookmarkableBookmark = allBookmarks[1]; - assert.ok( - nonPostBookmarkableBookmark.href.includes("chat/message/2437"), - "bookmarkable_type that is not Post or Topic should use bookmarkable_url for the item link" - ); - assert.ok( - nonPostBookmarkableBookmark.innerHTML.includes( - "d-icon-discourse-bookmark-clock" - ), - "should use the correct icon based on reminder_at present" - ); - }); - - test("anonymous", async function (assert) { - this.currentUser.setProperties({ is_anonymous: false, trust_level: 3 }); - this.siteSettings.allow_anonymous_posting = true; - this.siteSettings.anonymous_posting_min_trust_level = 3; - this.set("toggleAnonymous", () => (this.anonymous = true)); - - await render(hbs` - - `); - - await click(".user-preferences-link"); - assert.ok(exists(".enable-anonymous")); - - await click(".enable-anonymous"); - assert.ok(this.anonymous); - }); - - test("anonymous - disabled", async function (assert) { - this.siteSettings.allow_anonymous_posting = false; - - await render(hbs``); - - await click(".user-preferences-link"); - assert.ok(!exists(".enable-anonymous")); - }); - - test("anonymous - switch back", async function (assert) { - this.currentUser.setProperties({ is_anonymous: true }); - this.siteSettings.allow_anonymous_posting = true; - this.set("toggleAnonymous", () => (this.anonymous = false)); - - await render(hbs` - - `); - - await click(".user-preferences-link"); - assert.ok(exists(".disable-anonymous")); - - await click(".disable-anonymous"); - assert.notOk(this.anonymous); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/widget-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/widget-test.js index f5406662703..8cefc8d900b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/widget-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/widget-test.js @@ -1,6 +1,6 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { click, render } from "@ember/test-helpers"; +import { click, render, settled } from "@ember/test-helpers"; import { count, exists, query } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; import widgetHbs from "discourse/widgets/hbs-compiler"; @@ -9,6 +9,7 @@ import { Promise } from "rsvp"; import { createWidget } from "discourse/widgets/widget"; import { next } from "@ember/runloop"; import { withPluginApi } from "discourse/lib/plugin-api"; +import { h } from "virtual-dom"; module("Integration | Component | Widget | base", function (hooks) { setupRenderingTest(hooks); @@ -32,6 +33,24 @@ module("Integration | Component | Widget | base", function (hooks) { assert.strictEqual(query(".test").innerText, "Hello Robin"); }); + test("widget rerenders when args change", async function (assert) { + createWidget("hello-test", { + tagName: "div.test", + template: widgetHbs`Hello {{attrs.name}}`, + }); + + this.set("args", { name: "Robin" }); + + await render(hbs``); + + assert.strictEqual(query(".test").innerText, "Hello Robin"); + + this.set("args", { name: "David" }); + await settled(); + + assert.strictEqual(query(".test").innerText, "Hello David"); + }); + test("widget services", async function (assert) { createWidget("service-test", { tagName: "div.base-url-test", @@ -394,4 +413,75 @@ module("Integration | Component | Widget | base", function (hooks) { "renders container with overridden tagName" ); }); + + test("avoids rerendering on prepend", async function (assert) { + createWidget("prepend-test", { + tagName: "div.test", + html(attrs) { + const result = []; + result.push( + this.attach("button", { + label: "rerender", + className: "rerender", + action: "dummyAction", + }) + ); + result.push( + h( + "div", + attrs.array.map((val) => h(`span.val.${val}`, { key: val }, val)) + ) + ); + return result; + }, + dummyAction() {}, + }); + + const array = ["ElementOne", "ElementTwo"]; + this.set("args", { array }); + + await render( + hbs`` + ); + + const startElements = Array.from(document.querySelectorAll("span.val")); + assert.deepEqual( + startElements.map((e) => e.innerText), + ["ElementOne", "ElementTwo"] + ); + const elementOneBefore = startElements[0]; + + const parent = elementOneBefore.parentNode; + const observer = new MutationObserver(function (mutations) { + assert.notOk( + mutations.some((m) => + Array.from(m.addedNodes).includes(elementOneBefore) + ) + ); + }); + observer.observe(parent, { childList: true }); + + array.unshift( + "PrependedElementOne", + "PrependedElementTwo", + "PrependedElementThree" + ); + + await click(".rerender"); + + const endElements = Array.from(document.querySelectorAll("span.val")); + assert.deepEqual( + endElements.map((e) => e.innerText), + [ + "PrependedElementOne", + "PrependedElementTwo", + "PrependedElementThree", + "ElementOne", + "ElementTwo", + ] + ); + const elementOneAfter = endElements[3]; + + assert.strictEqual(elementOneBefore, elementOneAfter); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/helpers/replace-emoji-test.js b/app/assets/javascripts/discourse/tests/integration/helpers/replace-emoji-test.js new file mode 100644 index 00000000000..2ce8e73be3c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/helpers/replace-emoji-test.js @@ -0,0 +1,29 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; + +module("Integration | Helper | replace-emoji", function (hooks) { + setupRenderingTest(hooks); + + test("it replaces the emoji", async function (assert) { + await render(hbs`{{replace-emoji "some text :heart:"}}`); + + assert.dom(`span`).includesText("some text"); + assert.dom(`.emoji[title="heart"]`).exists(); + }); + + test("it escapes the text", async function (assert) { + await render( + hbs`{{replace-emoji ""}}` + ); + + assert.dom(`span`).hasText(""); + }); + + test("it renders html-safe text", async function (assert) { + await render(hbs`{{replace-emoji (html-safe "safe text")}}`); + + assert.dom(`span`).hasText("safe text"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index 570f1f00e26..a16401e2ac9 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -39,6 +39,7 @@ import { addModuleExcludeMatcher } from "ember-cli-test-loader/test-support/inde import SiteSettingService from "discourse/services/site-settings"; import jQuery from "jquery"; import { setupDeprecationCounter } from "discourse/tests/helpers/deprecation-counter"; +import { configureRaiseOnDeprecation } from "discourse/tests/helpers/raise-on-deprecation"; const Plugin = $.fn.modal; const Modal = Plugin.Constructor; @@ -124,31 +125,39 @@ function setupToolbar() { ) ); - QUnit.config.urlConfig.push({ - id: "qunit_skip_core", - label: "Skip Core", - value: "1", - }); - - QUnit.config.urlConfig.push({ - id: "qunit_skip_plugins", - label: "Skip Plugins", - value: "1", - }); - const pluginNames = new Set(); - Object.keys(requirejs.entries).forEach((moduleName) => { - const found = moduleName.match(/\/plugins\/([\w-]+)\//); - if (found && moduleName.match(/\-test/)) { - pluginNames.add(found[1]); - } - }); + document + .querySelector("#dynamic-test-js") + ?.content.querySelectorAll("script[data-discourse-plugin]") + .forEach((script) => pluginNames.add(script.dataset.discoursePlugin)); QUnit.config.urlConfig.push({ - id: "qunit_single_plugin", - label: "Plugin", - value: Array.from(pluginNames), + id: "target", + label: "Target", + value: ["core", "plugins", "all", "-----", ...Array.from(pluginNames)], + }); + + QUnit.begin(() => { + const select = document.querySelector( + `#qunit-testrunner-toolbar [name=target]` + ); + + const testingThemeId = parseInt( + document.querySelector("script[data-theme-id]")?.dataset.themeId, + 10 + ); + if (testingThemeId) { + select.innerHTML = ``; + select.disabled = true; + return; + } + + select.value ||= "core"; + select.querySelector("option:not([value])").remove(); + select.querySelector("option[value=-----]").disabled = true; + select.querySelector("option[value=all]").innerText = + "all (not recommended)"; }); // Abort tests when the qunit controls are clicked @@ -201,6 +210,7 @@ export default function setupTests(config) { setupDeprecationCounter(QUnit); QUnit.config.hidepassed = true; + QUnit.config.testTimeout = 60_000; sinon.config = { injectIntoThis: false, @@ -344,20 +354,9 @@ export default function setupTests(config) { QUnit.config.autostart = false; } - let skipCore = - getUrlParameter("qunit_single_plugin") || - getUrlParameter("qunit_skip_core") === "1"; + handleLegacyParameters(); - let singlePlugin = getUrlParameter("qunit_single_plugin"); - let skipPlugins = !singlePlugin && getUrlParameter("qunit_skip_plugins"); - - if (skipCore && !getUrlParameter("qunit_skip_core")) { - replaceUrlParameter("qunit_skip_core", "1"); - } - - if (!skipPlugins && getUrlParameter("qunit_skip_plugins")) { - replaceUrlParameter("qunit_skip_plugins", null); - } + const target = getUrlParameter("target") || "core"; const shouldLoadModule = (name) => { if (!/\-test/.test(name)) { @@ -368,13 +367,15 @@ export default function setupTests(config) { const isCore = !isPlugin; const pluginName = name.match(/\/plugins\/([\w-]+)\//)?.[1]; - if (skipCore && isCore) { + const loadCore = target === "core" || target === "all"; + const loadAllPlugins = target === "plugins" || target === "all"; + + if (isCore && !loadCore) { return false; - } else if (skipPlugins && isPlugin) { - return false; - } else if (singlePlugin && singlePlugin !== pluginName) { + } else if (isPlugin && !(loadAllPlugins || pluginName === target)) { return false; } + return true; }; @@ -386,6 +387,13 @@ export default function setupTests(config) { setupToolbar(); reportMemoryUsageAfterTests(); patchFailedAssertion(); + + const hasPluginJs = !!document.querySelector("script[data-discourse-plugin]"); + const hasThemeJs = !!document.querySelector("script[data-theme-id]"); + + if (!hasPluginJs && !hasThemeJs) { + configureRaiseOnDeprecation(); + } } function getUrlParameter(name) { @@ -393,28 +401,6 @@ function getUrlParameter(name) { return queryParams.get(name); } -function replaceUrlParameter(name, value) { - const queryParams = new URLSearchParams(window.location.search); - if (value === null) { - queryParams.delete(name); - } else { - queryParams.set(name, value); - } - history.replaceState(null, null, "?" + queryParams.toString()); - - QUnit.begin(() => { - QUnit.config[name] = value; - const formElement = document.querySelector( - `#qunit-testrunner-toolbar [name=${name}]` - ); - if (formElement?.type === "checkbox") { - formElement.checked = !!value; - } else if (formElement) { - formElement.value = value; - } - }); -} - function patchFailedAssertion() { const oldPushResult = QUnit.assert.pushResult; @@ -430,3 +416,19 @@ function patchFailedAssertion() { oldPushResult.call(this, resultInfo); }; } + +function handleLegacyParameters() { + for (const param of [ + "qunit_single_plugin", + "qunit_skip_core", + "qunit_skip_plugins", + ]) { + if (getUrlParameter(param)) { + QUnit.begin(() => { + throw new Error( + `${param} is no longer supported. Use the 'target' parameter instead` + ); + }); + } + } +} diff --git a/app/assets/javascripts/discourse/tests/test-boot-ember-cli.js b/app/assets/javascripts/discourse/tests/test-boot-ember-cli.js index c16a0c17680..fd8ffc4f209 100644 --- a/app/assets/javascripts/discourse/tests/test-boot-ember-cli.js +++ b/app/assets/javascripts/discourse/tests/test-boot-ember-cli.js @@ -25,7 +25,8 @@ document.addEventListener("discourse-booted", () => { } const params = new URLSearchParams(window.location.search); - const skipCore = params.get("qunit_skip_core") === "1"; + const target = params.get("target"); + const testingCore = !target || target === "core"; const disableAutoStart = params.get("qunit_disable_auto_start") === "1"; Ember.ENV.LOG_STACKTRACE_ON_DEPRECATION = false; @@ -60,7 +61,7 @@ document.addEventListener("discourse-booted", () => { setupTestContainer: false, loadTests: false, startTests: !disableAutoStart, - setupEmberOnerrorValidation: !skipCore, + setupEmberOnerrorValidation: testingCore, setupTestIsolationValidation: true, }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js index 8de26d68855..9f929804391 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js @@ -50,4 +50,40 @@ module("Unit | Controller | admin-customize-themes-show", function (hooks) { "returns theme's repo URL to branch" ); }); + + test("displays settings editor button with settings", function (assert) { + const theme = Theme.create({ + id: 2, + default: true, + name: "default", + settings: [{}], + }); + const controller = this.owner.lookup( + "controller:admin-customize-themes-show" + ); + controller.setProperties({ model: theme }); + assert.deepEqual( + controller.hasSettings, + true, + "sets the hasSettings property to true with settings" + ); + }); + + test("hides settings editor button with no settings", function (assert) { + const theme = Theme.create({ + id: 2, + default: true, + name: "default", + settings: [], + }); + const controller = this.owner.lookup( + "controller:admin-customize-themes-show" + ); + controller.setProperties({ model: theme }); + assert.deepEqual( + controller.hasSettings, + false, + "sets the hasSettings property to true with settings" + ); + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/admin-site-settings-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/admin-site-settings-test.js new file mode 100644 index 00000000000..f4f7c159884 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/controllers/admin-site-settings-test.js @@ -0,0 +1,42 @@ +import { module, test } from "qunit"; +import { setupTest } from "ember-qunit"; +import SiteSetting from "admin/models/site-setting"; + +module("Unit | Controller | admin-site-settings", function (hooks) { + setupTest(hooks); + + test("can perform fuzzy search", async function (assert) { + const controller = this.owner.lookup("controller:admin-site-settings"); + const settings = await SiteSetting.findAll(); + + let results = controller.performSearch("top_menu", settings); + assert.deepEqual(results[0].siteSettings.length, 1); + + results = controller.performSearch("tmenu", settings); + assert.deepEqual(results[0].siteSettings.length, 1); + + const settings2 = [ + { + name: "Required", + nameKey: "required", + siteSettings: [ + SiteSetting.create({ + description: "", + value: "", + setting: "hpello world", + }), + SiteSetting.create({ + description: "", + value: "", + setting: "hello world", + }), + ], + }, + ]; + + results = controller.performSearch("hello world", settings2); + assert.deepEqual(results[0].siteSettings.length, 2); + // ensures hello world shows up before fuzzy hpello world + assert.deepEqual(results[0].siteSettings[0].setting, "hello world"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/history-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/history-test.js deleted file mode 100644 index cb3670e43de..00000000000 --- a/app/assets/javascripts/discourse/tests/unit/controllers/history-test.js +++ /dev/null @@ -1,120 +0,0 @@ -import { module, test } from "qunit"; -import { setupTest } from "ember-qunit"; - -module("Unit | Controller | history", function (hooks) { - setupTest(hooks); - - test("displayEdit", async function (assert) { - const controller = this.owner.lookup("controller:history"); - - controller.setProperties({ - model: { last_revision: 3, current_revision: 3, can_edit: false }, - topicController: {}, - }); - - assert.strictEqual( - controller.displayEdit, - false, - "it should not display edit button when user cannot edit the post" - ); - - controller.set("model.can_edit", true); - - assert.strictEqual( - controller.displayEdit, - true, - "it should display edit button when user can edit the post" - ); - - controller.set("topicController", null); - assert.strictEqual( - controller.displayEdit, - false, - "it should not display edit button when there is not topic controller" - ); - controller.set("topicController", {}); - - controller.set("model.current_revision", 2); - assert.strictEqual( - controller.displayEdit, - false, - "it should only display the edit button on the latest revision" - ); - - const html = `
    -

    " width="276" height="183">

    -
    - - - - - - - - - - - - - - -
    ColumnTest
    OsamaTesting
    `; - - const expectedOutput = `
    -

    " width="276" height="183">

    -
    - - - - - - - - - - - - - - -
    ColumnTest
    OsamaTesting
    `; - - controller.setProperties({ - viewMode: "side_by_side", - model: { - body_changes: { - side_by_side: html, - }, - }, - }); - - await controller.bodyDiffChanged(); - - const output = controller.bodyDiff; - assert.strictEqual( - output, - expectedOutput, - "it keeps HTML safe and doesn't strip onebox tags" - ); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/user-notifications-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/user-notifications-test.js index 7dca41ee75d..f408d41af5c 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/user-notifications-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/user-notifications-test.js @@ -3,9 +3,7 @@ import { setupTest } from "ember-qunit"; import sinon from "sinon"; import pretender, { response } from "discourse/tests/helpers/create-pretender"; import EmberObject from "@ember/object"; -import * as showModal from "discourse/lib/show-modal"; import User from "discourse/models/user"; -import I18n from "I18n"; module("Unit | Controller | user-notifications", function (hooks) { setupTest(hooks); @@ -58,29 +56,4 @@ module("Unit | Controller | user-notifications", function (hooks) { assert.strictEqual(markRead, true); }); - - test("Shows modal when has high priority notifications", function (assert) { - let capturedProperties; - sinon - .stub(showModal, "default") - .withArgs("dismiss-notification-confirmation") - .returns({ - setProperties: (properties) => (capturedProperties = properties), - }); - - const currentUser = User.create({ unread_high_priority_notifications: 1 }); - const controller = this.owner.lookup("controller:user-notifications"); - controller.setProperties({ currentUser }); - const markReadFake = sinon.fake(); - sinon.stub(controller, "markRead").callsFake(markReadFake); - - controller.send("resetNew"); - - assert.strictEqual( - capturedProperties.confirmationMessage, - I18n.t("notifications.dismiss_confirmation.body.default", { count: 1 }) - ); - capturedProperties.dismissNotifications(); - assert.strictEqual(markReadFake.callCount, 1); - }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js b/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js new file mode 100644 index 00000000000..eb33b62f11b --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/autocomplete-test.js @@ -0,0 +1,259 @@ +import { module, test } from "qunit"; +import autocomplete from "discourse/lib/autocomplete"; +import { compile } from "handlebars"; + +module("Unit | Utility | autocomplete", function (hooks) { + let elements = []; + + function textArea(value) { + let element = document.createElement("TEXTAREA"); + element.value = value; + document.getElementById("ember-testing").appendChild(element); + elements.push(element); + return element; + } + + function cleanup() { + elements.forEach((e) => { + e.remove(); + autocomplete.call($(e), { cancel: true }); + autocomplete.call($(e), "destroy"); + }); + elements = []; + } + + hooks.afterEach(function () { + cleanup(); + }); + + function simulateKey(element, key) { + let keyCode = key.charCodeAt(0); + + let bubbled = false; + let trackBubble = function () { + bubbled = true; + }; + + element.addEventListener("keydown", trackBubble); + + let keyboardEvent = new KeyboardEvent("keydown", { + key, + keyCode, + which: keyCode, + }); + + element.dispatchEvent(keyboardEvent); + + element.removeEventListener("keydown", trackBubble); + + if (bubbled) { + let pos = element.selectionStart; + let value = element.value; + // backspace + if (key === "\b") { + element.value = value.slice(0, pos - 1) + value.slice(pos); + element.selectionStart = pos - 1; + element.selectionEnd = pos - 1; + } else { + element.value = value.slice(0, pos) + key + value.slice(pos); + element.selectionStart = pos + 1; + element.selectionEnd = pos + 1; + } + } + + element.dispatchEvent( + new KeyboardEvent("keyup", { key, keyCode, which: keyCode }) + ); + } + + test("Autocomplete can complete really short terms correctly", async function (assert) { + let element = textArea(""); + let $element = $(element); + + autocomplete.call($element, { + key: ":", + transformComplete: () => "sad:", + dataSource: () => [":sad:"], + template: compile(`
    + +
    `), + }); + + simulateKey(element, "a"); + simulateKey(element, " "); + + simulateKey(element, ":"); + simulateKey(element, ")"); + simulateKey(element, "\r"); + + let sleep = (millisecs) => + new Promise((promise) => setTimeout(promise, millisecs)); + // completeTerm awaits transformComplete + // we need to wait for it to be done + // Note: this is somewhat questionable given that when people + // press ENTER on an autocomplete they do not want to be beholden + // to an async function. + let inputEquals = async function (value) { + let count = 3000; + while (count > 0 && element.value !== value) { + count -= 1; + await sleep(1); + } + }; + + await inputEquals("a :sad: "); + assert.strictEqual(element.value, "a :sad: "); + assert.strictEqual(element.selectionStart, 8); + assert.strictEqual(element.selectionEnd, 8); + }); + + test("Autocomplete can account for cursor drift correctly", function (assert) { + let element = textArea(""); + let $element = $(element); + + autocomplete.call($element, { + key: "@", + dataSource: (term) => + ["test1", "test2"].filter((word) => word.includes(term)), + template: compile(`
    + +
    `), + }); + + simulateKey(element, "@"); + simulateKey(element, "\r"); + + assert.strictEqual(element.value, "@test1 "); + assert.strictEqual(element.selectionStart, 7); + assert.strictEqual(element.selectionEnd, 7); + + simulateKey(element, "@"); + simulateKey(element, "2"); + simulateKey(element, "\r"); + + assert.strictEqual(element.value, "@test1 @test2 "); + assert.strictEqual(element.selectionStart, 14); + assert.strictEqual(element.selectionEnd, 14); + + element.selectionStart = 6; + element.selectionEnd = 6; + + simulateKey(element, "\b"); + simulateKey(element, "\b"); + simulateKey(element, "\r"); + + assert.strictEqual(element.value, "@test1 @test2 "); + assert.strictEqual(element.selectionStart, 7); + assert.strictEqual(element.selectionEnd, 7); + + // lets see that deleting last space triggers autocomplete + element.selectionStart = element.value.length; + element.selectionEnd = element.value.length; + simulateKey(element, "\b"); + let list = document.querySelectorAll("#ac-testing ul li"); + assert.strictEqual(list.length, 1); + + simulateKey(element, "\b"); + list = document.querySelectorAll("#ac-testing ul li"); + assert.strictEqual(list.length, 2); + + // close autocomplete + simulateKey(element, "\r"); + + // does not trigger by mistake at the start + element.value = "test"; + element.selectionStart = element.value.length; + element.selectionEnd = element.value.length; + + simulateKey(element, "\b"); + list = document.querySelectorAll("#ac-testing ul li"); + assert.strictEqual(list.length, 0); + }); + + test("Autocomplete can handle spaces", function (assert) { + let element = textArea(""); + let $element = $(element); + + autocomplete.call($element, { + key: "@", + dataSource: (term) => + [ + { username: "jd", name: "jane dale" }, + { username: "jb", name: "jack black" }, + ] + .filter((user) => { + return user.username.includes(term) || user.name.includes(term); + }) + .map((user) => user.username), + template: compile(`
    + +
    `), + }); + + simulateKey(element, "@"); + simulateKey(element, "j"); + simulateKey(element, "a"); + simulateKey(element, "n"); + simulateKey(element, "e"); + simulateKey(element, " "); + simulateKey(element, "d"); + simulateKey(element, "\r"); + + assert.strictEqual(element.value, "@jd "); + }); + + test("Autocomplete can render on @", function (assert) { + let element = textArea("@"); + let $element = $(element); + + autocomplete.call($element, { + key: "@", + dataSource: () => ["test1", "test2"], + template: compile(`
    + +
    `), + }); + + element.dispatchEvent(new KeyboardEvent("keydown", { key: "@" })); + element.dispatchEvent(new KeyboardEvent("keyup", { key: "@" })); + + let list = document.querySelectorAll("#ac-testing ul li"); + assert.strictEqual(2, list.length); + + let selected = document.querySelectorAll("#ac-testing ul li a.selected"); + assert.strictEqual(1, selected.length); + assert.strictEqual("test1", selected[0].innerText); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/avatar-utils-test.js b/app/assets/javascripts/discourse/tests/unit/lib/avatar-utils-test.js new file mode 100644 index 00000000000..8020a9f7149 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/avatar-utils-test.js @@ -0,0 +1,93 @@ +import { + avatarImg, + avatarUrl, + getRawAvatarSize, +} from "discourse-common/lib/avatar-utils"; +import { module, test } from "qunit"; +import { setupURL } from "discourse-common/lib/get-url"; +import { setupTest } from "ember-qunit"; + +module("Unit | Utilities", function (hooks) { + setupTest(hooks); + + test("getRawAvatarSize avoids redirects", function (assert) { + assert.strictEqual( + getRawAvatarSize(1), + 24, + "returns the first size larger on the menu" + ); + + assert.strictEqual(getRawAvatarSize(2000), 288, "caps at highest"); + }); + + test("avatarUrl", function (assert) { + assert.blank(avatarUrl("", "tiny"), "no template returns blank"); + assert.strictEqual( + avatarUrl("/fake/template/{size}.png", "tiny"), + "/fake/template/" + getRawAvatarSize(24) + ".png", + "simple avatar url" + ); + assert.strictEqual( + avatarUrl("/fake/template/{size}.png", "large"), + "/fake/template/" + getRawAvatarSize(48) + ".png", + "different size" + ); + + setupURL("https://app-cdn.example.com", "https://example.com", ""); + + assert.strictEqual( + avatarUrl("/fake/template/{size}.png", "large"), + "https://app-cdn.example.com/fake/template/" + + getRawAvatarSize(48) + + ".png", + "uses CDN if present" + ); + }); + + let setDevicePixelRatio = function (value) { + if (Object.defineProperty && !window.hasOwnProperty("devicePixelRatio")) { + Object.defineProperty(window, "devicePixelRatio", { value: 2 }); + } else { + window.devicePixelRatio = value; + } + }; + + test("avatarImg", function (assert) { + let oldRatio = window.devicePixelRatio; + setDevicePixelRatio(2); + + let avatarTemplate = "/path/to/avatar/{size}.png"; + assert.strictEqual( + avatarImg({ avatarTemplate, size: "tiny" }), + "", + "it returns the avatar html" + ); + + assert.strictEqual( + avatarImg({ + avatarTemplate, + size: "tiny", + title: "evilest trout", + }), + "", + "it adds a title if supplied" + ); + + assert.strictEqual( + avatarImg({ + avatarTemplate, + size: "tiny", + extraClasses: "evil fish", + }), + "", + "it adds extra classes if supplied" + ); + + assert.blank( + avatarImg({ avatarTemplate: "", size: "tiny" }), + "it doesn't render avatars for invalid avatar template" + ); + + setDevicePixelRatio(oldRatio); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js b/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js new file mode 100644 index 00000000000..fc742da9cdd --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js @@ -0,0 +1,82 @@ +import { module, test } from "qunit"; +import { setupTest } from "ember-qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { buildQuote } from "discourse/lib/quote"; +import PrettyText from "pretty-text/pretty-text"; + +module("Unit | Utility | build-quote", function (hooks) { + setupTest(hooks); + + test("quotes", function (assert) { + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { + cooked: "

    lorem ipsum

    ", + username: "eviltrout", + post_number: 1, + topic_id: 2, + }); + + assert.strictEqual( + buildQuote(post, undefined), + "", + "empty string for undefined content" + ); + assert.strictEqual( + buildQuote(post, null), + "", + "empty string for null content" + ); + assert.strictEqual( + buildQuote(post, ""), + "", + "empty string for empty string content" + ); + + assert.strictEqual( + buildQuote(post, "lorem"), + '[quote="eviltrout, post:1, topic:2"]\nlorem\n[/quote]\n\n', + "correctly formats quotes" + ); + + assert.strictEqual( + buildQuote(post, " lorem \t "), + '[quote="eviltrout, post:1, topic:2"]\nlorem\n[/quote]\n\n', + "trims white spaces before & after the quoted contents" + ); + + assert.strictEqual( + buildQuote(post, "lorem ipsum", { full: true }), + '[quote="eviltrout, post:1, topic:2, full:true"]\nlorem ipsum\n[/quote]\n\n', + "marks quotes as full if the `full` option is passed" + ); + + assert.strictEqual( + buildQuote(post, "**lorem** ipsum"), + '[quote="eviltrout, post:1, topic:2"]\n**lorem** ipsum\n[/quote]\n\n', + "keeps BBCode formatting" + ); + }); + + test("quoting a quote", function (assert) { + const store = getOwner(this).lookup("service:store"); + const post = store.createRecord("post", { + cooked: new PrettyText().cook( + '[quote="sam, post:1, topic:1, full:true"]\nhello\n[/quote]\n*Test*' + ), + username: "eviltrout", + post_number: 1, + topic_id: 2, + }); + + const quote = buildQuote( + post, + '[quote="sam, post:1, topic:1, full:true"]\nhello\n[/quote]' + ); + + assert.strictEqual( + quote, + '[quote="eviltrout, post:1, topic:2"]\n[quote="sam, post:1, topic:1, full:true"]\nhello\n[/quote]\n[/quote]\n\n', + "allows quoting a quote" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/columns-test.js b/app/assets/javascripts/discourse/tests/unit/lib/columns-test.js new file mode 100644 index 00000000000..59fa6985675 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/columns-test.js @@ -0,0 +1,154 @@ +import { module, test } from "qunit"; +import Columns from "discourse/lib/columns"; + +module("Unit | Columns", function (hooks) { + hooks.afterEach(function () { + document.getElementById("qunit-fixture").innerHTML = ""; + }); + + test("works", function (assert) { + document.getElementById( + "qunit-fixture" + ).innerHTML = `
    +


    +
    +

    +
    `; + + const grid = document.querySelector(".d-image-grid"); + const cols = new Columns(grid); + assert.strictEqual(cols.items.length, 3); + + assert.strictEqual( + grid.dataset.columns, + "3", + "column count attribute is correct" + ); + + assert.strictEqual( + document.querySelectorAll(".d-image-grid > .d-image-grid-column").length, + 3, + "three column elements are rendered" + ); + }); + + test("disabled if items < minCount", function (assert) { + document.getElementById( + "qunit-fixture" + ).innerHTML = `
    +


    +

    +
    `; + + const grid = document.querySelector(".d-image-grid"); + const cols = new Columns(grid, { minCount: 3 }); + + assert.strictEqual(cols.items.length, 2); + + assert.strictEqual( + grid.dataset.disabled, + "true", + "disabled attribute is added" + ); + assert.strictEqual( + document.querySelectorAll(".d-image-grid > .d-image-grid-column").length, + 0, + "no column elements are rendered" + ); + }); + + test("4 items shown in 2x2 grid", function (assert) { + document.getElementById( + "qunit-fixture" + ).innerHTML = `
    + + + + +
    `; + + const grid = document.querySelector(".d-image-grid"); + const cols = new Columns(grid); + + assert.strictEqual(cols.items.length, 4); + assert.strictEqual( + grid.dataset.columns, + "2", + "column count attribute is correct" + ); + assert.strictEqual( + document.querySelectorAll(".d-image-grid > .d-image-grid-column").length, + 2, + "two columns are rendered" + ); + + assert.strictEqual( + document.querySelectorAll( + ".d-image-grid > .d-image-grid-column:first-child .image-wrapper" + ).length, + 2, + "two images in column 1" + ); + + assert.strictEqual( + document.querySelectorAll( + ".d-image-grid > .d-image-grid-column:nth-child(2) .image-wrapper" + ).length, + 2, + "two images in column 2" + ); + }); + + test("non-image elements", function (assert) { + document.getElementById( + "qunit-fixture" + ).innerHTML = `
    + + + +
    hey there
    +
    hey there
    +
    `; + + const grid = document.querySelector(".d-image-grid"); + const cols = new Columns(grid); + + assert.strictEqual(cols.items.length, 5); + assert.strictEqual(cols.container, grid); + + assert.strictEqual( + grid.dataset.columns, + "3", + "column count attribute is correct" + ); + assert.strictEqual( + document.querySelectorAll(".d-image-grid > .d-image-grid-column").length, + 3, + "three columns are rendered" + ); + + assert.strictEqual( + document.querySelectorAll( + ".d-image-grid > .d-image-grid-column:first-child > *" + ).length, + 2, + "two elements in column 1" + ); + + assert.strictEqual( + document.querySelectorAll( + ".d-image-grid > .d-image-grid-column:nth-child(2) > *" + ).length, + 2, + "two elements in column 2" + ); + + assert.strictEqual( + document.querySelectorAll( + ".d-image-grid > .d-image-grid-column:nth-child(3) > *" + ).length, + 1, + "one element in column 3" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/deprecated-test.js b/app/assets/javascripts/discourse/tests/unit/lib/deprecated-test.js index a6dd4aece1f..1b53cbb2ac4 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/deprecated-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/deprecated-test.js @@ -3,12 +3,17 @@ import { withSilencedDeprecations, withSilencedDeprecationsAsync, } from "discourse-common/lib/deprecated"; +import { + disableRaiseOnDeprecation, + enableRaiseOnDeprecation, +} from "discourse/tests/helpers/raise-on-deprecation"; import DeprecationCounter from "discourse/tests/helpers/deprecation-counter"; import { module, test } from "qunit"; import Sinon from "sinon"; module("Unit | Utility | deprecated", function (hooks) { hooks.beforeEach(function () { + disableRaiseOnDeprecation(); this.warnStub = Sinon.stub(console, "warn"); this.counterStub = Sinon.stub( DeprecationCounter.prototype, @@ -16,6 +21,10 @@ module("Unit | Utility | deprecated", function (hooks) { ); }); + hooks.afterEach(() => { + enableRaiseOnDeprecation(); + }); + test("works with just a message", function (assert) { deprecated("My message"); assert.strictEqual( 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 1b632c30f04..cda9778eb08 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js @@ -260,6 +260,9 @@ module("Unit | Utility | formatter", function (hooks) { assert.strictEqual(elem.dataset.time, d.getTime().toString()); assert.strictEqual(elem.title, ""); assert.strictEqual(elem.innerHTML, "1 day"); + + elem = domFromString(autoUpdatingRelativeAge(d, { prefix: "test" }))[0]; + assert.strictEqual(elem.innerHTML, "test 1d"); }); test("updateRelativeAge", function (assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/get-url-test.js b/app/assets/javascripts/discourse/tests/unit/lib/get-url-test.js index 25553f9fe17..1ec66aaf1a0 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/get-url-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/get-url-test.js @@ -172,4 +172,12 @@ module("Unit | Utility | get-url", function () { assert.strictEqual(getURLWithCDN(url), expected, "at correct path"); }); + + test("getURLWithCDN when URL includes protocol", function (assert) { + setupS3CDN("//awesome.cdn/site", "https://awesome.cdn/site"); + + let url = "https://awesome.cdn/site/awesome.png"; + + assert.strictEqual(getURLWithCDN(url), url, "at correct path"); + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js index 8deb904b152..4746fad0121 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js @@ -1,5 +1,6 @@ import { module, test } from "qunit"; import I18n from "I18n"; +import { withSilencedDeprecations } from "discourse-common/lib/deprecated"; module("Unit | Utility | i18n", function (hooks) { hooks.beforeEach(function () { @@ -288,4 +289,11 @@ module("Unit | Utility | i18n", function (hooks) { "customtest" ); }); + + test("legacy require support", function (assert) { + withSilencedDeprecations("discourse.i18n-t-import", () => { + const myI18n = require("I18n"); + assert.strictEqual(myI18n.t("topic.reply.title"), "Répondre"); + }); + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js new file mode 100644 index 00000000000..60bad9d3034 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/create-download-link-test.js @@ -0,0 +1,43 @@ +import { module, test } from "qunit"; + +import { createDownloadLink } from "discourse/lib/lightbox/helpers"; +import sinon from "sinon"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | createDownloadLink()", + function () { + test("creates a download link with the correct href and download attributes", async function (assert) { + const lightboxItem = { + downloadURL: "http://example.com/download.jpg", + title: "image.jpg", + }; + const createElementSpy = sinon.spy(document, "createElement"); + const clickStub = sinon.stub(HTMLAnchorElement.prototype, "click"); + + createDownloadLink(lightboxItem); + + assert.strictEqual( + createElementSpy.calledWith("a"), + true, + "creates an anchor element" + ); + + assert.strictEqual( + createElementSpy.returnValues[0].href, + "http://example.com/download.jpg", + "sets the correct href attribute" + ); + + assert.strictEqual( + createElementSpy.returnValues[0].download, + "image.jpg", + "sets the correct download attribute" + ); + + assert.strictEqual(clickStub.called, true, "clicks the link element"); + + createElementSpy.restore(); + clickStub.restore(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js new file mode 100644 index 00000000000..d3f1d3e5f8e --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/find-nearest-shared-parent-test.js @@ -0,0 +1,28 @@ +import { module, test } from "qunit"; + +import { findNearestSharedParent } from "discourse/lib/lightbox/helpers"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | findNearestSharedParent()", + function () { + test("it returns the nearest shared parent for the elements passed in", async function (assert) { + const element0 = document.createElement("div"); + const element1 = document.createElement("div"); + const element2 = document.createElement("div"); + const element3 = document.createElement("div"); + const element4 = document.createElement("div"); + + element1.appendChild(element2); + element3.appendChild(element4); + + element0.appendChild(element1); + element0.appendChild(element3); + + assert.strictEqual( + findNearestSharedParent([element2, element4]), + element0, + "returns the correct nearest shared parent" + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js new file mode 100644 index 00000000000..75f507ccea3 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/get-swipe-direction-test.js @@ -0,0 +1,66 @@ +import { module, test } from "qunit"; + +import { SWIPE_DIRECTIONS } from "discourse/lib/lightbox/constants"; +import { getSwipeDirection } from "discourse/lib/lightbox/helpers"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | getSwipeDirection()", + function () { + test("returns the correct direction based on the difference between touchstart and touchend", function (assert) { + assert.strictEqual( + getSwipeDirection({ + touchstartX: 200, + touchstartY: 0, + touchendX: 50, + touchendY: 0, + }), + SWIPE_DIRECTIONS.RIGHT, + "returns 'RIGHT' for swipes with a large negative x-axis difference" + ); + + assert.strictEqual( + getSwipeDirection({ + touchstartX: 50, + touchstartY: 0, + touchendX: 200, + touchendY: 0, + }), + SWIPE_DIRECTIONS.LEFT, + "returns 'LEFT' for swipes with a large positive x-axis difference" + ); + + assert.strictEqual( + getSwipeDirection({ + touchstartX: 0, + touchstartY: 200, + touchendX: 0, + touchendY: 50, + }), + SWIPE_DIRECTIONS.UP, + "returns 'UP' for swipes with a large negative y-axis difference" + ); + + assert.strictEqual( + getSwipeDirection({ + touchstartX: 0, + touchstartY: 50, + touchendX: 0, + touchendY: 200, + }), + SWIPE_DIRECTIONS.DOWN, + "returns 'DOWN' for swipes with a large positive y-axis difference" + ); + + assert.strictEqual( + getSwipeDirection({ + touchstartX: 50, + touchstartY: 50, + touchendX: 49, + touchendY: 49, + }), + false, + "returns 'false' for swipes with a small x-axis difference and a small y-axis difference" + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js new file mode 100644 index 00000000000..ebfdd45e97c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/open-image-in-new-tab-test.js @@ -0,0 +1,47 @@ +import { module, test } from "qunit"; + +import { openImageInNewTab } from "discourse/lib/lightbox/helpers"; +import sinon from "sinon"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | openImageinNewTab()", + function () { + test("opens the fullsize URL of the lightbox item in a new tab", async function (assert) { + const lightboxItem = { + fullsizeURL: "image.jpg", + }; + + const openStub = sinon.stub(window, "open"); + + await openImageInNewTab(lightboxItem); + + assert.strictEqual( + openStub.calledWith("image.jpg", "_blank"), + true, + "calls window.open with the correct arguments" + ); + + openStub.restore(); + }); + + test("handles errors when trying to open the new tab", async function (assert) { + const lightboxItem = { + fullsizeURL: "image.jpg", + }; + + const openStub = sinon.stub(window, "open").throws(); + const consoleErrorStub = sinon.stub(console, "error"); + + await openImageInNewTab(lightboxItem); + + assert.strictEqual( + consoleErrorStub.called, + true, + "logs an error to the console" + ); + + openStub.restore(); + consoleErrorStub.restore(); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js new file mode 100644 index 00000000000..78b40128486 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/preload-item-images-test.js @@ -0,0 +1,78 @@ +import { + LIGHTBOX_IMAGE_FIXTURES, + generateLightboxObject, +} from "discourse/tests/helpers/lightbox-helpers"; +import { module, test } from "qunit"; + +import { cloneJSON } from "discourse-common/lib/object"; +import { preloadItemImages } from "discourse/lib/lightbox/helpers"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | preloadItemImages()", + + function () { + const baseLightboxItem = generateLightboxObject().items[0]; + + test("returns the correct object", async function (assert) { + const lightboxItem = cloneJSON(baseLightboxItem); + + const result = await preloadItemImages(lightboxItem); + + assert.ok(result.isLoaded, "isLoaded should be true"); + + assert.ok(!result.hasLoadingError, "hasLoadingError should be false"); + + assert.strictEqual( + result.width, + LIGHTBOX_IMAGE_FIXTURES.first.width, + "width should be equal to fullsizeImage width" + ); + + assert.strictEqual( + result.height, + LIGHTBOX_IMAGE_FIXTURES.first.height, + "height should be equal to fullsizeImage height" + ); + + assert.strictEqual( + result.aspectRatio, + LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio, + "aspectRatio should be equal to image width/height" + ); + + assert.ok( + result.canZoom, + "canZoom should be true if fullsizeImage width or height is greater than window inner width or height" + ); + }); + + test("handles errors", async function (assert) { + const lightboxItem = cloneJSON(baseLightboxItem); + + lightboxItem.fullsizeURL = + LIGHTBOX_IMAGE_FIXTURES.invalidImage.fullsizeURL; + + const result = await preloadItemImages(lightboxItem); + + assert.strictEqual( + result.hasLoadingError, + true, + "sets hasLoadingError to true if there is an error" + ); + }); + + test("handles images smaller than the viewport", async function (assert) { + const lightboxItem = cloneJSON(baseLightboxItem); + + lightboxItem.fullsizeURL = + LIGHTBOX_IMAGE_FIXTURES.smallerThanViewPort.fullsizeURL; + + const result = await preloadItemImages(lightboxItem); + + assert.notOk( + result.canZoom, + "canZoom should be false if fullsizeImage width or height is smaller than window inner width or height" + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js new file mode 100644 index 00000000000..2078b3ecfeb --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/scroll-parent-to-element-to-center-test.js @@ -0,0 +1,51 @@ +import { module, test } from "qunit"; + +import { scrollParentToElementCenter } from "discourse/lib/lightbox/helpers"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | scrollParentToElementCenter()", + function () { + test("scrolls the parent element to the center of the element", async function (assert) { + const parent = document.createElement("div"); + parent.style.width = "200px"; + parent.style.height = "200px"; + parent.style.overflow = "scroll"; + + const element = document.createElement("div"); + element.style.width = "400px"; + element.style.height = "400px"; + parent.appendChild(element); + + document.body.appendChild(parent); + + const getExpectedX = (element.offsetWidth - parent.offsetWidth) / 2; + const expectedY = (element.offsetHeight - parent.offsetHeight) / 2; + + scrollParentToElementCenter({ element, isRTL: false }); + + assert.strictEqual( + parent.scrollLeft, + getExpectedX, + "scrolls parent to center of element (LTR - horizontal)" + ); + + assert.strictEqual( + parent.scrollTop, + expectedY, + "scrolls parent to center of viewport (LTR and RTL - vertical)" + ); + + parent.style.direction = "rtl"; + + scrollParentToElementCenter({ element, isRTL: true }); + + assert.strictEqual( + parent.scrollLeft, + getExpectedX * -1, + "scrolls parent to center of viewport (RTL - horizontal)" + ); + + document.body.removeChild(parent); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js new file mode 100644 index 00000000000..bdc05bcdcb5 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/set-carousel-scroll-position-test.js @@ -0,0 +1,76 @@ +import { module, test } from "qunit"; + +import { setCarouselScrollPosition } from "discourse/lib/lightbox/helpers"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | setCarouselScrollPosition()", + function () { + const carouselItemSize = 100; + const target = 9; + + const carousel = document.createElement("div"); + carousel.style.cssText = ` + display: grid; + height: 400px; + width: 400px; + overflow: auto; + position: relative; + `; + + const carouselItem = document.createElement("div"); + carouselItem.style.cssText = ` + width: ${carouselItemSize}px; + height: ${carouselItemSize}px; + `; + + Array(20) + .fill(null) + .map((_, index) => { + const item = carouselItem.cloneNode(true); + if (index === target) { + item.dataset.lightboxCarouselItem = "current"; + } + carousel.appendChild(item); + }); + + const expected = + target * carouselItemSize - carouselItemSize - carouselItemSize / 2; + + test("scrolls the carousel to the center of the active item ", async function (assert) { + const container = carousel.cloneNode(true); + + container.style.cssText += ` + grid-auto-flow: column; + grid-template-columns: repeat(auto, ${carouselItemSize}px); + `; + + const fixtureDiv = document.getElementById("qunit-fixture"); + fixtureDiv.appendChild(container); + + await setCarouselScrollPosition("instant"); + + assert.strictEqual( + container.scrollLeft, + expected, + "scrolls carousel to center of active item (horizontal)" + ); + + container.style.cssText += ` + grid-auto-flow: row; + grid-template-rows: repeat(auto, ${carouselItemSize}px); + `; + + await setCarouselScrollPosition("instant"); + + assert.strictEqual( + container.scrollTop, + expected, + "scrolls carousel to center of active item (vertical)" + ); + }); + + test("test scroll animation", async function (assert) { + assert.ok(true); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js new file mode 100644 index 00000000000..8a4008bfc2d --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/helpers/site-theme-color-test.js @@ -0,0 +1,69 @@ +import { + getSiteThemeColor, + setSiteThemeColor, +} from "discourse/lib/lightbox/helpers"; +import { module, test } from "qunit"; + +import sinon from "sinon"; + +module( + "Unit | lib | Experimental Lightbox | Helpers | getSiteThemeColor()", + function () { + test("gets the correct site theme color", async function (assert) { + const querySelectorSpy = sinon.spy(document, "querySelector"); + const MetaSiteColorStub = sinon.stub( + HTMLMetaElement.prototype, + "content" + ); + + MetaSiteColorStub.value("#ff0000"); + + const themeColor = await getSiteThemeColor(); + + assert.strictEqual( + querySelectorSpy.calledWith('meta[name="theme-color"]'), + true, + "Queries the correct element" + ); + + assert.strictEqual( + themeColor, + "#ff0000", + "returns the correct theme color" + ); + + querySelectorSpy.restore(); + MetaSiteColorStub.restore(); + }); + + test("sets the site theme color correctly", async function (assert) { + const querySelectorSpy = sinon.spy(document, "querySelector"); + + await setSiteThemeColor("0000ff"); + + assert.strictEqual( + querySelectorSpy.calledWith('meta[name="theme-color"]'), + true, + "queries the correct element" + ); + + assert.strictEqual( + querySelectorSpy.returnValues[0].content, + "#0000ff", + "sets the correct theme color" + ); + + querySelectorSpy.restore(); + }); + + test("invalid color given", async function (assert) { + await setSiteThemeColor("##0000ff"); + + assert.strictEqual( + document.querySelector('meta[name="theme-color"]').content, + "#0000ff", + "converts to a the correct theme color" + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js new file mode 100644 index 00000000000..d0eb3ddce2c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/lightbox/process-html-test.js @@ -0,0 +1,279 @@ +import { + LIGHTBOX_IMAGE_FIXTURES, + generateImageUploaderMarkup, + generateLightboxMarkup, +} from "discourse/tests/helpers/lightbox-helpers"; +import { module, test } from "qunit"; + +import { SELECTORS } from "discourse/lib/lightbox/constants"; +import domFromString from "discourse-common/lib/dom-from-string"; +import { processHTML } from "discourse/lib/lightbox/process-html"; + +module("Unit | lib | Experimental lightbox | processHTML()", function () { + const wrap = domFromString(generateLightboxMarkup())[0]; + const imageUploaderWrap = domFromString(generateImageUploaderMarkup())[0]; + const selector = SELECTORS.DEFAULT_ITEM_SELECTOR; + + test("returns the correct object from the proccessed element", async function (assert) { + const container = wrap.cloneNode(true); + + const { items, startingIndex } = await processHTML({ + container, + selector, + }); + + assert.strictEqual(items.length, 1); + + const item = items[0]; + + assert.strictEqual( + item.fullsizeURL, + LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL + ); + + assert.strictEqual(item.smallURL, LIGHTBOX_IMAGE_FIXTURES.first.smallURL); + + assert.strictEqual( + item.downloadURL, + LIGHTBOX_IMAGE_FIXTURES.first.downloadURL + ); + + assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.title); + + assert.strictEqual( + item.fileDetails, + LIGHTBOX_IMAGE_FIXTURES.first.fileDetails + ); + + assert.strictEqual( + item.dominantColor, + LIGHTBOX_IMAGE_FIXTURES.first.dominantColor + ); + + assert.strictEqual( + item.aspectRatio, + LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio + ); + + assert.strictEqual(item.index, LIGHTBOX_IMAGE_FIXTURES.first.index); + + assert.strictEqual( + item.cssVars.string, + LIGHTBOX_IMAGE_FIXTURES.first.cssVars.string + ); + + assert.strictEqual(startingIndex, 0); + }); + + test("returns the correct number of items", async function (assert) { + const htmlString = generateLightboxMarkup().repeat(3); + const container = domFromString(htmlString); + + const outer = document.createElement("div"); + outer.append(...container); + + const { items } = await processHTML({ + container: outer, + selector, + }); + + assert.strictEqual(items.length, 3); + }); + + test("fallsback to src when no href is defined for fullsizeURL", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector("a").removeAttribute("href"); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual( + items[0].fullsizeURL, + LIGHTBOX_IMAGE_FIXTURES.first.smallURL + ); + }); + + test("handles title fallbacks", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector("a").removeAttribute("title"); + + let { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.title); + + container.querySelector("img").removeAttribute("title"); + + ({ items } = await processHTML({ + container, + selector, + })); + + assert.strictEqual(items[0].title, LIGHTBOX_IMAGE_FIXTURES.first.alt); + + container.querySelector("img").removeAttribute("alt"); + + ({ items } = await processHTML({ + container, + selector, + })); + + assert.strictEqual(items[0].title, ""); + }); + + test("correctly escapes the title", async function (assert) { + const container = wrap.cloneNode(true); + + container + .querySelector("a") + .setAttribute("title", `"><\x00script>javascript:alert(1)`); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual( + items[0].title, + `"><\x00script>javascript:alert(1)</script>` + ); + }); + + test("handles missing aspect ratio", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector("img").style.removeProperty("aspect-ratio"); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual(items[0].aspectRatio, null); + + assert.strictEqual( + items[0].cssVars.string, + `--dominant-color: #${LIGHTBOX_IMAGE_FIXTURES.first.dominantColor};--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.smallURL});` + ); + }); + + test("handles missing file details", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector(SELECTORS.FILE_DETAILS_CONTAINER).remove(); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual(items[0].fileDetails, null); + }); + + test("handles missing dominant color", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector("img").removeAttribute("data-dominant-color"); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual(items[0].dominantColor, null); + + assert.strictEqual( + items[0].cssVars.string, + `--aspect-ratio: ${LIGHTBOX_IMAGE_FIXTURES.first.aspectRatio};--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.smallURL});` + ); + }); + + test("falls back to href when data-download is not defined", async function (assert) { + const container = wrap.cloneNode(true); + + container.querySelector("a").removeAttribute("data-download-href"); + + const { items } = await processHTML({ + container, + selector, + }); + + assert.strictEqual( + items[0].downloadURL, + LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL + ); + }); + + test("handles missing selector", async function (assert) { + const container = wrap.cloneNode(true); + + const { items } = await processHTML({ + container, + }); + + assert.strictEqual(items.length, 1); + }); + + test("handles custom selector", async function (assert) { + const container = wrap.cloneNode(true); + container.querySelector("a").classList.add("custom-selector"); + + const { items } = await processHTML({ + container, + selector: ".custom-selector", + }); + + assert.strictEqual(items.length, 1); + }); + + test("returns the correct object for image uploader components", async function (assert) { + const container = imageUploaderWrap.cloneNode(true); + + const { items } = await processHTML({ + container, + selector, + }); + + const item = items[0]; + + assert.strictEqual(items.length, 1); + + assert.strictEqual(item.title, ""); + + assert.strictEqual(item.aspectRatio, null); + + assert.strictEqual(item.dominantColor, null); + + assert.strictEqual(item.fileDetails, "x"); + + assert.strictEqual( + item.downloadURL, + LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL + ); + + assert.strictEqual( + item.smallURL, + LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL + ); + + assert.strictEqual( + item.fullsizeURL, + LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL + ); + + assert.strictEqual( + item.cssVars.string, + `--small-url: url(${LIGHTBOX_IMAGE_FIXTURES.first.fullsizeURL});` + ); + }); + + test("throws missing container error when no container / nodelist is passed", async function (assert) { + assert.rejects(processHTML({ selector })); + }); +}); 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 index 7465a51bb22..8b11b5552e7 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/plugin-api-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/plugin-api-test.js @@ -87,4 +87,33 @@ module("Unit | Utility | plugin-api", function (hooks) { assert.strictEqual(thingy.keep, "hey!"); assert.strictEqual(thingy.prop, "g'day"); }); + + test("modifyClass works with getters", function (assert) { + let Base = EmberObject.extend({ + get foo() { + throw new Error("base getter called"); + }, + }); + + getOwner(this).register("test-class:main", Base, { + instantiate: false, + }); + + // Performing this lookup triggers `factory._onLookup`. In DEBUG builds, that invokes injectedPropertyAssertion() + // https://github.com/emberjs/ember.js/blob/36505f1b42/packages/%40ember/-internals/runtime/lib/system/core_object.js#L1144-L1163 + // Which in turn invokes `factory.proto()`. + // This puts things in a state which will trigger https://github.com/emberjs/ember.js/issues/18860 when a native getter is overridden. + withPluginApi("1.1.0", (api) => { + api.modifyClass("test-class:main", { + get foo() { + return "modified getter"; + }, + }); + }); + + const obj = Base.create(); + assert.true(true, "no error thrown while merging mixin with getter"); + + assert.strictEqual(obj.foo, "modified getter", "returns correct result"); + }); }); 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 8e6c8887e97..c909f43b9ac 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,12 +4,10 @@ import { deleteCachedInlineOnebox, } from "pretty-text/inline-oneboxer"; import QUnit, { module, test } from "qunit"; -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: { @@ -18,7 +16,7 @@ const rawOpts = { enable_mentions: true, emoji_set: "twitter", external_emoji_url: "", - highlighted_languages: "json|ruby|javascript", + highlighted_languages: "json|ruby|javascript|xml", default_code_lang: "auto", enable_markdown_linkify: true, markdown_linkify_tlds: "com", @@ -440,6 +438,24 @@ eviltrout

    `, "quote has group class" ); + + assert.cooked( + "[quote]\ntest\n[/quote]", + '', + "it supports quotes without params" + ); + + assert.cooked( + "[quote]\n*test*\n[/quote]", + '', + "it doesn't insert a new line for italics" + ); + + assert.cooked( + "[quote=,script='a'> - - - {{content-for "body-footer"}} - - \ No newline at end of file diff --git a/app/assets/javascripts/pretty-text/tests/dummy/app/styles/.gitkeep b/app/assets/javascripts/pretty-text/tests/dummy/app/styles/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/pretty-text/tests/dummy/app/templates/.gitkeep b/app/assets/javascripts/pretty-text/tests/dummy/app/templates/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/pretty-text/tests/dummy/config/.gitkeep b/app/assets/javascripts/pretty-text/tests/dummy/config/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/pretty-text/tests/dummy/public/.gitkeep b/app/assets/javascripts/pretty-text/tests/dummy/public/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/pretty-text/tests/index.html b/app/assets/javascripts/pretty-text/tests/index.html deleted file mode 100644 index 5c291da716f..00000000000 --- a/app/assets/javascripts/pretty-text/tests/index.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Dummy Tests - - - - {{content-for "head"}} - {{content-for "test-head"}} - - - - - - {{content-for "head-footer"}} - {{content-for "test-head-footer"}} - - - {{content-for "body"}} - {{content-for "test-body"}} - -
    -
    -
    -
    -
    -
    - - - - - - - - {{content-for "body-footer"}} - {{content-for "test-body-footer"}} - - \ No newline at end of file diff --git a/app/assets/javascripts/pretty-text/vendor/.gitkeep b/app/assets/javascripts/pretty-text/vendor/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/select-kit/addon/.gitkeep b/app/assets/javascripts/select-kit/addon/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/assets/javascripts/select-kit/addon/templates/components/category-drop/category-drop-header.hbs b/app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/category-drop/category-drop-header.hbs rename to app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.js b/app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.js index b4cdf79e531..8abc2821210 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.js +++ b/app/assets/javascripts/select-kit/addon/components/category-drop/category-drop-header.js @@ -1,12 +1,10 @@ import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/category-drop/category-drop-header"; import { readOnly } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import { htmlSafe } from "@ember/template"; export default ComboBoxSelectBoxHeaderComponent.extend({ - layout, classNames: ["category-drop-header"], classNameBindings: ["categoryStyleClass"], categoryStyleClass: readOnly("site.category_style"), diff --git a/app/assets/javascripts/select-kit/addon/templates/components/category-row.hbs b/app/assets/javascripts/select-kit/addon/components/category-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/category-row.hbs rename to app/assets/javascripts/select-kit/addon/components/category-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/category-row.js b/app/assets/javascripts/select-kit/addon/components/category-row.js index 2368843c53b..0b0147d3969 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-row.js +++ b/app/assets/javascripts/select-kit/addon/components/category-row.js @@ -4,12 +4,10 @@ import Category from "discourse/models/category"; import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/category-row"; import { setting } from "discourse/lib/computed"; import { htmlSafe } from "@ember/template"; export default SelectKitRowComponent.extend({ - layout, classNames: ["category-row"], hideParentCategory: bool("selectKit.options.hideParentCategory"), allowUncategorized: bool("selectKit.options.allowUncategorized"), diff --git a/app/assets/javascripts/select-kit/addon/templates/components/color-palettes/color-palettes-row.hbs b/app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/color-palettes/color-palettes-row.hbs rename to app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.js b/app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.js index b72335111df..0e300877e26 100644 --- a/app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.js +++ b/app/assets/javascripts/select-kit/addon/components/color-palettes/color-palettes-row.js @@ -1,11 +1,9 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/color-palettes/color-palettes-row"; import { htmlSafe } from "@ember/template"; export default SelectKitRowComponent.extend({ classNames: ["color-palettes-row"], - layout, palettes: computed("item.colors.[]", function () { return htmlSafe( diff --git a/app/assets/javascripts/select-kit/addon/templates/components/combo-box/combo-box-header.hbs b/app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/combo-box/combo-box-header.hbs rename to app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.js b/app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.js index 6d6378d3337..fd35ef4eaa9 100644 --- a/app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.js +++ b/app/assets/javascripts/select-kit/addon/components/combo-box/combo-box-header.js @@ -1,10 +1,8 @@ import { and, reads } from "@ember/object/computed"; import SingleSelectHeaderComponent from "select-kit/components/select-kit/single-select-header"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/combo-box/combo-box-header"; export default SingleSelectHeaderComponent.extend({ - layout, classNames: ["combo-box-header"], clearable: reads("selectKit.options.clearable"), caretUpIcon: reads("selectKit.options.caretUpIcon"), diff --git a/app/assets/javascripts/select-kit/addon/templates/components/create-color-row.hbs b/app/assets/javascripts/select-kit/addon/components/create-color-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/create-color-row.hbs rename to app/assets/javascripts/select-kit/addon/components/create-color-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/create-color-row.js b/app/assets/javascripts/select-kit/addon/components/create-color-row.js index 4f978fe0cb1..2be176a353a 100644 --- a/app/assets/javascripts/select-kit/addon/components/create-color-row.js +++ b/app/assets/javascripts/select-kit/addon/components/create-color-row.js @@ -1,10 +1,8 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; import { escapeExpression } from "discourse/lib/utilities"; -import layout from "select-kit/templates/components/create-color-row"; import { schedule } from "@ember/runloop"; export default SelectKitRowComponent.extend({ - layout, classNames: ["create-color-row"], didReceiveAttrs() { diff --git a/app/assets/javascripts/select-kit/addon/templates/components/dropdown-select-box/dropdown-select-box-header.hbs b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/dropdown-select-box/dropdown-select-box-header.hbs rename to app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.js b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.js index 76f6d03d4b2..01ce7edd47e 100644 --- a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.js +++ b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-header.js @@ -1,15 +1,15 @@ import SingleSelectHeaderComponent from "select-kit/components/select-kit/single-select-header"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/dropdown-select-box/dropdown-select-box-header"; import { readOnly } from "@ember/object/computed"; export default SingleSelectHeaderComponent.extend({ - layout, classNames: ["dropdown-select-box-header"], - classNameBindings: ["btnClassName", "btnStyleClass"], + classNameBindings: ["btnClassName", "btnStyleClass", "btnCustomClasses"], showFullTitle: readOnly("selectKit.options.showFullTitle"), customStyle: readOnly("selectKit.options.customStyle"), + btnCustomClasses: readOnly("selectKit.options.btnCustomClasses"), + btnClassName: computed("showFullTitle", function () { return `btn ${this.showFullTitle ? "btn-icon-text" : "no-text btn-icon"}`; }), @@ -19,7 +19,6 @@ export default SingleSelectHeaderComponent.extend({ }), caretUpIcon: readOnly("selectKit.options.caretUpIcon"), - caretDownIcon: readOnly("selectKit.options.caretDownIcon"), caretIcon: computed( diff --git a/app/assets/javascripts/select-kit/addon/templates/components/dropdown-select-box/dropdown-select-box-row.hbs b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/dropdown-select-box/dropdown-select-box-row.hbs rename to app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.js b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.js index 6399da5b2dd..8e526072b07 100644 --- a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.js +++ b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box/dropdown-select-box-row.js @@ -1,10 +1,7 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import layout from "select-kit/templates/components/dropdown-select-box/dropdown-select-box-row"; import { readOnly } from "@ember/object/computed"; export default SelectKitRowComponent.extend({ - layout, classNames: ["dropdown-select-box-row"], - description: readOnly("item.description"), }); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs b/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.hbs similarity index 59% rename from app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs rename to app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.hbs index 3a293eaadc8..8ff700b0fd2 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs +++ b/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.hbs @@ -1,15 +1,19 @@ {{#if this.item.isUser}} {{avatar this.item imageSize="tiny"}} - {{format-username this.item.id}} - {{this.item.name}} +
    + {{format-username this.item.id}} + {{this.item.name}} +
    {{#if (and this.item.showUserStatus this.item.status)}} {{/if}} {{decorate-username-selector this.item.id}} {{else if this.item.isGroup}} {{d-icon "users"}} - {{this.item.id}} - {{this.item.full_name}} +
    + {{this.item.id}} + {{this.item.full_name}} +
    {{else}} {{d-icon "envelope"}} {{this.item.id}} diff --git a/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.js b/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.js index 5426c82fb7a..316dfe1a3df 100644 --- a/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.js +++ b/app/assets/javascripts/select-kit/addon/components/email-group-user-chooser-row.js @@ -1,7 +1,5 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import layout from "select-kit/templates/components/email-group-user-chooser-row"; export default SelectKitRowComponent.extend({ - layout, classNames: ["email-group-user-chooser-row"], }); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/flair-row.hbs b/app/assets/javascripts/select-kit/addon/components/flair-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/flair-row.hbs rename to app/assets/javascripts/select-kit/addon/components/flair-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/flair-row.js b/app/assets/javascripts/select-kit/addon/components/flair-row.js index 6edbf8cab2b..8210655ef6c 100644 --- a/app/assets/javascripts/select-kit/addon/components/flair-row.js +++ b/app/assets/javascripts/select-kit/addon/components/flair-row.js @@ -1,7 +1,5 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import layout from "select-kit/templates/components/flair-row"; export default SelectKitRowComponent.extend({ - layout, classNames: ["flair-row"], }); diff --git a/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js b/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js new file mode 100644 index 00000000000..9120819d008 --- /dev/null +++ b/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js @@ -0,0 +1,56 @@ +import MultiSelectComponent from "select-kit/components/multi-select"; +import FormTemplate from "discourse/models/form-template"; +import { computed } from "@ember/object"; + +export default MultiSelectComponent.extend({ + pluginApiIdentifiers: ["form-template-chooser"], + classNames: ["form-template-chooser"], + selectKitOptions: { + none: "form_template_chooser.select_template", + }, + + init() { + this._super(...arguments); + + if (!this.templates) { + this._fetchTemplates(); + } + }, + + didUpdateAttrs() { + this._super(...arguments); + this._fetchTemplates(); + }, + + @computed("templates") + get content() { + if (!this.templates) { + return this._fetchTemplates(); + } + + return this.templates; + }, + + _fetchTemplates() { + FormTemplate.findAll().then((result) => { + let sortedTemplates = this._sortTemplatesByName(result); + + if (this.filteredIds) { + sortedTemplates = sortedTemplates.filter((t) => + this.filteredIds.includes(t.id) + ); + } + + if (sortedTemplates.length > 0) { + return this.set("templates", sortedTemplates); + } else { + this.set("templates", sortedTemplates); + this.set("selectKit.options.disabled", true); + } + }); + }, + + _sortTemplatesByName(templates) { + return templates.sort((a, b) => a.name.localeCompare(b.name)); + }, +}); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/future-date-input-selector/future-date-input-selector-header.hbs b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/future-date-input-selector/future-date-input-selector-header.hbs rename to app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.js b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.js index 6581440616b..24dc65b2b71 100644 --- a/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.js +++ b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-header.js @@ -1,7 +1,5 @@ import ComboBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; -import layout from "select-kit/templates/components/future-date-input-selector/future-date-input-selector-header"; export default ComboBoxHeaderComponent.extend({ - layout, classNames: "future-date-input-selector-header", }); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/future-date-input-selector/future-date-input-selector-row.hbs b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/future-date-input-selector/future-date-input-selector-row.hbs rename to app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.js b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.js index 354746c5207..337d4f6a1bf 100644 --- a/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.js +++ b/app/assets/javascripts/select-kit/addon/components/future-date-input-selector/future-date-input-selector-row.js @@ -1,7 +1,5 @@ import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import layout from "select-kit/templates/components/future-date-input-selector/future-date-input-selector-row"; export default SelectKitRowComponent.extend({ - layout, classNames: ["future-date-input-selector-row"], }); diff --git a/app/assets/javascripts/select-kit/addon/components/icon-picker.js b/app/assets/javascripts/select-kit/addon/components/icon-picker.js index ab6553acbdf..dead5047cd8 100644 --- a/app/assets/javascripts/select-kit/addon/components/icon-picker.js +++ b/app/assets/javascripts/select-kit/addon/components/icon-picker.js @@ -36,7 +36,10 @@ export default MultiSelectComponent.extend({ return this._cachedIconsList; } else { return ajax("/svg-sprite/picker-search", { - data: { filter }, + data: { + filter, + only_available: this.onlyAvailable, + }, }).then((icons) => { icons = icons.map(this._processIcon); if (filter === "") { diff --git a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js index d5e1263a2e5..27d510ab741 100644 --- a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js +++ b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js @@ -26,6 +26,7 @@ export default MultiSelectComponent.extend(TagsMixin, { closeOnChange: false, maximum: "maxTagsPerTopic", autoInsertNoneItem: false, + useHeaderFilter: false, }, modifyComponentForRow(collection, item) { diff --git a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/mini-tag-chooser-header.js b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/mini-tag-chooser-header.js deleted file mode 100644 index fa42aac0976..00000000000 --- a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/mini-tag-chooser-header.js +++ /dev/null @@ -1,7 +0,0 @@ -import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; -import layout from "select-kit/templates/components/mini-tag-chooser/mini-tag-chooser-header"; - -export default ComboBoxSelectBoxHeaderComponent.extend({ - layout, - classNames: ["mini-tag-chooser-header"], -}); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/mini-tag-chooser/selected-collection.hbs b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/mini-tag-chooser/selected-collection.hbs rename to app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.js b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.js index fc563c4698d..4619490286f 100644 --- a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.js +++ b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser/selected-collection.js @@ -1,13 +1,9 @@ import { reads } from "@ember/object/computed"; import Component from "@ember/component"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/mini-tag-chooser/selected-collection"; export default Component.extend({ tagName: "", - - layout, - selectedTags: reads("collection.content.selectedTags.[]"), tags: computed("selectedTags.[]", "selectKit.filter", function () { diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select.hbs b/app/assets/javascripts/select-kit/addon/components/multi-select.hbs new file mode 100644 index 00000000000..2e1aa9af9cf --- /dev/null +++ b/app/assets/javascripts/select-kit/addon/components/multi-select.hbs @@ -0,0 +1,56 @@ +{{#unless this.selectKit.isHidden}} + {{component + this.selectKit.options.headerComponent + tabindex=this.tabindex + value=this.value + selectedContent=this.selectedContent + selectKit=this.selectKit + id=(concat this.selectKit.uniqueID "-header") + }} + + + {{#unless this.selectKit.options.useHeaderFilter}} + {{component + this.selectKit.options.filterComponent + selectKit=this.selectKit + id=(concat this.selectKit.uniqueID "-filter") + }} + + {{#if this.selectedContent.length}} +
    + {{#each this.selectedContent as |item|}} + {{component + this.selectKit.options.selectedChoiceComponent + item=item + selectKit=this.selectKit + }} + {{/each}} +
    + {{/if}} + {{/unless}} + + {{#each this.collections as |collection|}} + {{component + (component-for-collection collection.identifier this.selectKit) + collection=collection + selectKit=this.selectKit + value=this.value + }} + {{/each}} + + {{#if this.selectKit.filter}} + {{#if this.selectKit.hasNoContent}} + + {{i18n "select_kit.no_content"}} + + {{else}} + + {{i18n "select_kit.results_count" count=this.mainCollection.length}} + + {{/if}} + {{/if}} +
    +{{/unless}} \ No newline at end of file diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select.js b/app/assets/javascripts/select-kit/addon/components/multi-select.js index 247bc49f55d..7ab70bb3c12 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select.js @@ -2,12 +2,10 @@ import SelectKitComponent from "select-kit/components/select-kit"; import { computed } from "@ember/object"; import { isPresent } from "@ember/utils"; import { next } from "@ember/runloop"; -import layout from "select-kit/templates/components/multi-select"; import { makeArray } from "discourse-common/lib/helpers"; export default SelectKitComponent.extend({ pluginApiIdentifiers: ["multi-select"], - layout, classNames: ["multi-select"], multiSelect: true, @@ -23,6 +21,7 @@ export default SelectKitComponent.extend({ autoFilterable: true, caretDownIcon: "caretIcon", caretUpIcon: "caretIcon", + useHeaderFilter: false, }, caretIcon: computed("value.[]", function () { diff --git a/app/assets/javascripts/select-kit/addon/templates/components/multi-select/format-selected-content.hbs b/app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/multi-select/format-selected-content.hbs rename to app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.js b/app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.js index 710122a76b6..f369b7f23d6 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/format-selected-content.js @@ -1,12 +1,10 @@ import Component from "@ember/component"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/multi-select/format-selected-content"; import { makeArray } from "discourse-common/lib/helpers"; import UtilsMixin from "select-kit/mixins/utils"; export default Component.extend(UtilsMixin, { tagName: "", - layout, content: null, selectKit: null, diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.hbs b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.hbs new file mode 100644 index 00000000000..f9099884900 --- /dev/null +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.hbs @@ -0,0 +1,25 @@ +{{#unless this.isHidden}} + {{! filter-input-search prevents 1password from attempting autocomplete }} + {{! template-lint-disable no-pointer-down-event-binding }} + + + + {{#if this.selectKit.options.filterIcon}} + {{d-icon this.selectKit.options.filterIcon class="filter-icon"}} + {{/if}} +{{/unless}} \ No newline at end of file diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.js b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.js index 0987b2df217..7284d8f8e74 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-filter.js @@ -1,20 +1,18 @@ -import I18n from "I18n"; import SelectKitFilterComponent from "select-kit/components/select-kit/select-kit-filter"; import { isEmpty } from "@ember/utils"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/select-kit/select-kit-filter"; import { action } from "@ember/object"; export default SelectKitFilterComponent.extend({ - layout, classNames: ["multi-select-filter"], @discourseComputed("placeholder", "selectKit.hasSelection") computedPlaceholder(placeholder, hasSelection) { - if (hasSelection) { + if (this.hidePlaceholderWithSelection && hasSelection) { return ""; } - return isEmpty(placeholder) ? "" : I18n.t(placeholder); + + return isEmpty(placeholder) ? "" : placeholder; }, @action diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.hbs b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.hbs new file mode 100644 index 00000000000..3bff6e9a7e3 --- /dev/null +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.hbs @@ -0,0 +1,33 @@ +
    + {{#each this.icons as |icon|}} + {{d-icon icon}} + {{/each}} + + {{#if this.selectKit.options.useHeaderFilter}} +
    + {{#if this.selectedContent.length}} + {{#each this.selectedContent as |item|}} + {{component + this.selectKit.options.selectedChoiceComponent + item=item + selectKit=this.selectKit + }} + {{/each}} + {{/if}} + + {{component + this.selectKit.options.filterComponent + selectKit=this.selectKit + id=(concat this.selectKit.uniqueID "-filter") + hidePlaceholderWithSelection=true + }} +
    + {{else}} + + + {{d-icon this.caretIcon class="caret-icon"}} + {{/if}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.js b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.js index 14ee8979d1c..6be5eb72292 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/multi-select-header.js @@ -1,5 +1,4 @@ import SelectKitHeaderComponent from "select-kit/components/select-kit/select-kit-header"; -import layout from "select-kit/templates/components/multi-select/multi-select-header"; import { computed } from "@ember/object"; import { reads } from "@ember/object/computed"; @@ -7,11 +6,10 @@ export default SelectKitHeaderComponent.extend({ tagName: "summary", classNames: ["multi-select-header"], attributeBindings: ["ariaLabel:aria-label"], - layout, - caretUpIcon: reads("selectKit.options.caretUpIcon"), caretDownIcon: reads("selectKit.options.caretDownIcon"), ariaLabel: reads("selectKit.options.headerAriaLabel"), + caretIcon: computed( "selectKit.isExpanded", "caretUpIcon", diff --git a/app/assets/javascripts/select-kit/addon/templates/components/multi-select/selected-category.hbs b/app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/multi-select/selected-category.hbs rename to app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.js b/app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.js index 0792ef0fa2b..5c336b2255f 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select/selected-category.js @@ -1,12 +1,10 @@ import SelectedNameComponent from "select-kit/components/selected-name"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/multi-select/selected-category"; import { htmlSafe } from "@ember/template"; export default SelectedNameComponent.extend({ classNames: ["selected-category"], - layout, badge: computed("item", function () { return htmlSafe( diff --git a/app/assets/javascripts/select-kit/addon/components/none-category-row.hbs b/app/assets/javascripts/select-kit/addon/components/none-category-row.hbs new file mode 100644 index 00000000000..35670d94ae2 --- /dev/null +++ b/app/assets/javascripts/select-kit/addon/components/none-category-row.hbs @@ -0,0 +1,19 @@ +{{#if this.category}} + + + {{#if this.shouldDisplayDescription}} + + {{/if}} +{{else}} + {{html-safe this.label}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/select-kit/addon/components/none-category-row.js b/app/assets/javascripts/select-kit/addon/components/none-category-row.js index 3d06e23457b..f8edef316f8 100644 --- a/app/assets/javascripts/select-kit/addon/components/none-category-row.js +++ b/app/assets/javascripts/select-kit/addon/components/none-category-row.js @@ -1,11 +1,9 @@ import CategoryRowComponent from "select-kit/components/category-row"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/category-row"; import { htmlSafe } from "@ember/template"; export default CategoryRowComponent.extend({ - layout, classNames: "none category-row", @discourseComputed("category") diff --git a/app/assets/javascripts/select-kit/addon/templates/components/notifications-filter/notifications-filter-header.hbs b/app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/notifications-filter/notifications-filter-header.hbs rename to app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.js b/app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.js index d67f2138be1..06ad0447a87 100644 --- a/app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.js +++ b/app/assets/javascripts/select-kit/addon/components/notifications-filter/notifications-filter-header.js @@ -1,13 +1,9 @@ import DropdownSelectBoxHeaderComponent from "select-kit/components/dropdown-select-box/dropdown-select-box-header"; import discourseComputed from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; -import layout from "select-kit/templates/components/notifications-filter/notifications-filter-header"; export default DropdownSelectBoxHeaderComponent.extend({ - layout, - classNames: ["notifications-filter-header"], - label: fmt("value", "user.user_notifications.filters.%@"), @discourseComputed("selectKit.isExpanded") diff --git a/app/assets/javascripts/select-kit/addon/templates/components/period-chooser/period-chooser-header.hbs b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.hbs similarity index 50% rename from app/assets/javascripts/select-kit/addon/templates/components/period-chooser/period-chooser-header.hbs rename to app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.hbs index d8111ba8a8a..13fae3bb97f 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/period-chooser/period-chooser-header.hbs +++ b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.hbs @@ -1,4 +1,4 @@ -

    +

    {{period-title this.value showDateRange=true diff --git a/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.js b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.js index 31a9c66db84..56e8e93e2ad 100644 --- a/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.js +++ b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-header.js @@ -1,9 +1,7 @@ import DropdownSelectBoxHeaderComponent from "select-kit/components/dropdown-select-box/dropdown-select-box-header"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/period-chooser/period-chooser-header"; export default DropdownSelectBoxHeaderComponent.extend({ - layout, classNames: ["period-chooser-header"], @discourseComputed("selectKit.isExpanded") diff --git a/app/assets/javascripts/select-kit/addon/templates/components/period-chooser/period-chooser-row.hbs b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/period-chooser/period-chooser-row.hbs rename to app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.js b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.js index 0787fa62ef4..f71accbeda4 100644 --- a/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.js +++ b/app/assets/javascripts/select-kit/addon/components/period-chooser/period-chooser-row.js @@ -1,10 +1,8 @@ import DropdownSelectBoxRowComponent from "select-kit/components/dropdown-select-box/dropdown-select-box-row"; import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/period-chooser/period-chooser-row"; export default DropdownSelectBoxRowComponent.extend({ - layout, classNames: ["period-chooser-row"], @discourseComputed("rowName") diff --git a/app/assets/javascripts/select-kit/addon/templates/components/pinned-button.hbs b/app/assets/javascripts/select-kit/addon/components/pinned-button.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/pinned-button.hbs rename to app/assets/javascripts/select-kit/addon/components/pinned-button.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/pinned-button.js b/app/assets/javascripts/select-kit/addon/components/pinned-button.js index bc1a834becf..2f90964566f 100644 --- a/app/assets/javascripts/select-kit/addon/components/pinned-button.js +++ b/app/assets/javascripts/select-kit/addon/components/pinned-button.js @@ -1,14 +1,12 @@ import Component from "@ember/component"; import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; -import layout from "select-kit/templates/components/pinned-button"; export default Component.extend({ pluginApiIdentifiers: ["pinned-button"], descriptionKey: "help", classNames: "pinned-button", classNameBindings: ["isHidden"], - layout, @discourseComputed("topic.pinned_globally", "pinned") reasonText(pinnedGlobally, pinned) { 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 304b6c3f5be..a2c617320bb 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit.js @@ -114,6 +114,7 @@ export default Component.extend( highlightPrevious: bind(this, this._highlightPrevious), highlightLast: bind(this, this._highlightLast), highlightFirst: bind(this, this._highlightFirst), + deselectLast: bind(this, this._deselectLast), change: bind(this, this._onChangeWrapper), select: bind(this, this.select), deselect: bind(this, this.deselect), @@ -188,6 +189,14 @@ export default Component.extend( this.handleDeprecations(); }, + didInsertElement() { + this._super(...arguments); + + if (this.selectKit.options.expandedOnInsert) { + this._open(); + } + }, + click(event) { event.preventDefault(); event.stopPropagation(); @@ -207,6 +216,7 @@ export default Component.extend( didReceiveAttrs() { this._super(...arguments); + const deprecatedOptions = this._resolveDeprecatedOptions(); const mergedOptions = Object.assign({}, ...this.selectKitOptions); Object.keys(mergedOptions).forEach((key) => { if (isPresent(this.options[key])) { @@ -214,6 +224,11 @@ export default Component.extend( return; } + if (isPresent(deprecatedOptions[`options.${key}`])) { + this.selectKit.options.set(key, deprecatedOptions[`options.${key}`]); + return; + } + const value = mergedOptions[key]; if ( @@ -281,6 +296,7 @@ export default Component.extend( minimum: null, autoInsertNoneItem: true, closeOnChange: true, + useHeaderFilter: false, limitMatches: null, placement: isDocumentRTL() ? "bottom-end" : "bottom-start", verticalOffset: 3, @@ -296,6 +312,8 @@ export default Component.extend( desktopPlacementStrategy: null, hiddenValues: null, disabled: false, + expandedOnInsert: false, + formName: null, }, autoFilterable: computed("content.[]", "selectKit.filter", function () { @@ -785,6 +803,12 @@ export default Component.extend( } }, + _deselectLast() { + if (this.selectKit.hasSelection) { + this.deselectByValue(this.value[this.value.length - 1]); + } + }, + select(value, item) { if (!isPresent(value)) { this._onClearSelection(); @@ -837,7 +861,7 @@ export default Component.extend( this.clearErrors(); const inModal = this.element.closest("#discourse-modal"); - if (inModal && this?.site?.mobileView) { + if (inModal && this.site.mobileView) { const modalBody = inModal.querySelector(".modal-body"); modalBody.style = ""; } @@ -860,7 +884,7 @@ export default Component.extend( this.selectKit.onOpen(event); if (!this.popper) { - const inModal = this.element.closest("#discourse-modal"); + const inModal = this.element.closest("#discourse-modal .modal-body"); const anchor = document.querySelector( `#${this.selectKit.uniqueID}-header` ); @@ -1046,7 +1070,7 @@ export default Component.extend( handleDeprecations() { this._deprecateValueAttribute(); this._deprecateMutations(); - this._deprecateOptions(); + this._handleDeprecatedArgs(); }, _computePlacementStrategy() { @@ -1056,7 +1080,7 @@ export default Component.extend( return placementStrategy; } - if (this.capabilities?.isIpadOS || this.site?.mobileView) { + if (this.capabilities.isIpadOS || this.site.mobileView) { placementStrategy = this.selectKit.options.mobilePlacementStrategy || "absolute"; } else { @@ -1068,17 +1092,11 @@ export default Component.extend( }, _deprecated(text) { - const discourseSetup = document.getElementById("data-discourse-setup"); - if ( - discourseSetup && - discourseSetup.getAttribute("data-environment") === "development" - ) { - deprecated(text, { - since: "v2.4.0", - dropFrom: "2.9.0.beta1", - id: "discourse.select-kit", - }); - } + deprecated(text, { + since: "v2.4.0", + dropFrom: "2.9.0.beta1", + id: "discourse.select-kit", + }); }, _deprecateValueAttribute() { @@ -1107,11 +1125,8 @@ export default Component.extend( } }, - _deprecateOptions() { + _resolveDeprecatedOptions() { const migrations = { - headerIcon: "icon", - onExpand: "onOpen", - onCollapse: "onClose", allowAny: "options.allowAny", allowCreate: "options.allowAny", filterable: "options.filterable", @@ -1129,9 +1144,33 @@ export default Component.extend( minimum: "options.minimum", i18nPostfix: "options.i18nPostfix", i18nPrefix: "options.i18nPrefix", + btnCustomClasses: "options.btnCustomClasses", castInteger: "options.castInteger", }; + const resolvedDeprecations = {}; + + Object.keys(migrations).forEach((from) => { + const to = migrations[from]; + if (this.get(from) && !this.get(to)) { + this._deprecated( + `The \`${from}\` attribute is deprecated. Use \`${to}\` instead` + ); + + resolvedDeprecations[(to, this.get(from))]; + } + }); + + return resolvedDeprecations; + }, + + _handleDeprecatedArgs() { + const migrations = { + headerIcon: "icon", + onExpand: "onOpen", + onCollapse: "onClose", + }; + Object.keys(migrations).forEach((from) => { const to = migrations[from]; if (this.get(from) && !this.get(to)) { diff --git a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/errors-collection.hbs b/app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/select-kit/errors-collection.hbs rename to app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.js b/app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.js index 099bd7aeba1..44494409ab5 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/errors-collection.js @@ -1,7 +1,5 @@ import Component from "@ember/component"; -import layout from "select-kit/templates/components/select-kit/errors-collection"; export default Component.extend({ - layout, tagName: "", }); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-body.hbs b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.hbs similarity index 100% rename from app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-body.hbs rename to app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.hbs diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js index a6d75f83487..37bffd0578e 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js @@ -1,10 +1,9 @@ import Component from "@ember/component"; -import { bind } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; +import { next } from "@ember/runloop"; import { computed } from "@ember/object"; -import layout from "select-kit/templates/components/select-kit/select-kit-body"; export default Component.extend({ - layout, classNames: ["select-kit-body"], classNameBindings: ["emptyBody:empty-body"], @@ -12,55 +11,53 @@ export default Component.extend({ return false; }), - rootEventType: "click", - - init() { - this._super(...arguments); - - this.handleRootMouseDownHandler = bind(this, this.handleRootMouseDown); - }, - didInsertElement() { this._super(...arguments); this.element.style.position = "relative"; - - document.addEventListener( - this.rootEventType, - this.handleRootMouseDownHandler, - true - ); + document.addEventListener("click", this.handleClick, true); + this.selectKit + .mainElement() + .addEventListener("keydown", this._handleKeydown, true); }, willDestroyElement() { this._super(...arguments); - - document.removeEventListener( - this.rootEventType, - this.handleRootMouseDownHandler, - true - ); + document.removeEventListener("click", this.handleClick, true); + this.selectKit + .mainElement() + ?.removeEventListener("keydown", this._handleKeydown, true); }, - handleRootMouseDown(event) { - if (!this.selectKit.isExpanded) { + @bind + handleClick(event) { + if (!this.selectKit.isExpanded || !this.selectKit.mainElement()) { return; } - const headerElement = document.querySelector( - `#${this.selectKit.uniqueID}-header` - ); - - if (headerElement && headerElement.contains(event.target)) { + if (this.selectKit.mainElement().contains(event.target)) { return; } - if (this.element.contains(event.target)) { + this.selectKit.close(event); + }, + + @bind + _handleKeydown(event) { + if (!this.selectKit.isExpanded || event.key !== "Tab") { return; } - if (this.selectKit.mainElement()) { + next(() => { + if ( + this.isDestroying || + this.isDestroyed || + this.selectKit.mainElement()?.contains(document.activeElement) + ) { + return; + } + this.selectKit.close(event); - } + }); }, }); diff --git a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.hbs similarity index 79% rename from app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs rename to app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.hbs index f17c087bc34..d52ec3e195d 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.hbs @@ -1,8 +1,9 @@ {{#if this.collection.content.length}}

    <%= c.description&.html_safe %>
    + <% if c.subcategory_list.present? %> +
    + <% c.subcategory_list.each_with_index do |sc, index| %> + <%= sc.name %>  + <% end %> +
    + <% end %>
    diff --git a/app/views/common/_discourse_preload_stylesheet.html.erb b/app/views/common/_discourse_preload_stylesheet.html.erb index 06402715769..efe1ac15d44 100644 --- a/app/views/common/_discourse_preload_stylesheet.html.erb +++ b/app/views/common/_discourse_preload_stylesheet.html.erb @@ -7,14 +7,22 @@ <%- end %> <%- if staff? %> - <%= discourse_stylesheet_preload_tag(:admin) %> + <%- if rtl? %> + <%= discourse_stylesheet_preload_tag(:admin_rtl) %> + <%- else %> + <%= discourse_stylesheet_preload_tag(:admin) %> + <%- end %> <%- end %> <%- if admin? %> - <%= discourse_stylesheet_preload_tag(:wizard) %> + <%- if rtl? %> + <%= discourse_stylesheet_preload_tag(:wizard_rtl) %> + <%- else %> + <%= discourse_stylesheet_preload_tag(:wizard) %> + <%- end %> <%- 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.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?, request: request, rtl: rtl?).each do |file| %> <%= discourse_stylesheet_preload_tag(file) %> <%- end %> diff --git a/app/views/common/_discourse_publish_stylesheet.html.erb b/app/views/common/_discourse_publish_stylesheet.html.erb index 9429b0cd8dd..5a7b6a9bb72 100644 --- a/app/views/common/_discourse_publish_stylesheet.html.erb +++ b/app/views/common/_discourse_publish_stylesheet.html.erb @@ -1,6 +1,6 @@ <%= discourse_stylesheet_link_tag 'publish', theme_id: nil %> -<%- 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.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?, request: request, rtl: rtl?).each do |file| %> <%= discourse_stylesheet_link_tag(file) %> <%- end %> diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index ca92af30768..68dd71ab7bb 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -7,14 +7,22 @@ <%- end %> <%- if staff? %> - <%= discourse_stylesheet_link_tag(:admin) %> + <%- if rtl? %> + <%= discourse_stylesheet_link_tag(:admin_rtl) %> + <%- else %> + <%= discourse_stylesheet_link_tag(:admin) %> + <%- end %> <%- end %> <%- if admin? %> - <%= discourse_stylesheet_link_tag(:wizard) %> + <%- if rtl? %> + <%= discourse_stylesheet_link_tag(:wizard_rtl) %> + <%- else %> + <%= discourse_stylesheet_link_tag(:wizard) %> + <%- end %> <%- 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.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?, request: request, rtl: rtl?).each do |file| %> <%= discourse_stylesheet_link_tag(file) %> <%- end %> diff --git a/app/views/layouts/_noscript_footer.html.erb b/app/views/layouts/_noscript_footer.html.erb index 76770aaa049..1d1f706b641 100644 --- a/app/views/layouts/_noscript_footer.html.erb +++ b/app/views/layouts/_noscript_footer.html.erb @@ -16,16 +16,20 @@ -
  • - - - -
  • -
  • - - - -
  • + <% if tos_url.present? %> +
  • + + + +
  • + <% end %> + <% if privacy_policy_url.present? %> +
  • + + + +
  • + <% end %> diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index cc8c28779da..699a0e61abf 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -9,12 +9,9 @@ <%= theme_lookup("head_tag") %> <%= render_google_universal_analytics_code %> <%= yield :head %> - <% if show_browser_update? %> - - <% end %> <%= build_plugin_html 'server:before-head-close-crawler' %> - + <%= theme_lookup("header") %> <%= render partial: "layouts/noscript_header" %>
    diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb index 90711df5d2c..8c095e69d99 100644 --- a/app/views/layouts/embed.html.erb +++ b/app/views/layouts/embed.html.erb @@ -19,6 +19,7 @@ <%= yield :head %> + <%= theme_lookup("embedded_header") %> <%= yield %> diff --git a/app/views/list/list.erb b/app/views/list/list.erb index 8d35d0f0c87..e322560d233 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -38,7 +38,7 @@ <% end %> <% end %> -
    +
    @@ -109,7 +109,7 @@ <% if @list.topics.length > 0 && @list.more_topics_url %> -
    +
    - -
    + <%- if I18n.t('user_notifications.digest.custom.html.header').present? %> - @@ -20,7 +19,7 @@
    + <%= raw(t 'user_notifications.digest.custom.html.header') %>
    -
    + <%- if logo_url.blank? %> <%= SiteSetting.title %> @@ -35,7 +34,7 @@ <%- end %> - - -
    +
    @@ -46,7 +45,7 @@
    + @@ -57,14 +56,14 @@ <%- @counts.each do |count| -%> - <%- end -%> <%- @counts.each do |count| -%> - <%- end -%> @@ -186,7 +185,7 @@
    + <%= count[:value] -%>
    + <%=t count[:label_key] -%>
    +
    @@ -208,8 +207,8 @@
    - - + - +
      +   <% @popular_posts.each do |post| %> @@ -270,7 +269,7 @@ <% end %>   
    <% end %> @@ -283,8 +282,8 @@ - - + - +
      +   @@ -323,7 +322,7 @@
      
    @@ -374,7 +373,7 @@ - ",""],legend:[1,"
    ","
    "],thead:[1,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],col:[2,"","
    "],area:[1,"",""],_default:[0,"",""]},bh=U(c);bg.optgroup=bg.option,bg.tbody=bg.tfoot=bg.colgroup=bg.caption=bg.thead,bg.th=bg.td,f.support.htmlSerialize||(bg._default=[1,"div
    ","
    "]),f.fn.extend({text:function(a){if(f.isFunction(a))return this.each(function(b){var c=f(this);c.text(a.call(this,b,c.text()))});if(typeof a!="object"&&a!==b)return this.empty().append((this[0]&&this[0].ownerDocument||c).createTextNode(a));return f.text(this)},wrapAll:function(a){if(f.isFunction(a))return this.each(function(b){f(this).wrapAll(a.call(this,b))});if(this[0]){var b=f(a,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstChild&&a.firstChild.nodeType===1)a=a.firstChild;return a}).append(this)}return this},wrapInner:function(a){if(f.isFunction(a))return this.each(function(b){f(this).wrapInner(a.call(this,b))});return this.each(function(){var b=f(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=f.isFunction(a);return this.each(function(c){f(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(){return this.parent().each(function(){f.nodeName(this,"body")||f(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(a){this.nodeType===1&&this.appendChild(a)})},prepend:function(){return this.domManip(arguments,!0,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this)});if(arguments.length){var a=f.clean(arguments);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this.nextSibling)});if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,f.clean(arguments));return a}},remove:function(a,b){for(var c=0,d;(d=this[c])!=null;c++)if(!a||f.filter(a,[d]).length)!b&&d.nodeType===1&&(f.cleanData(d.getElementsByTagName("*")),f.cleanData([d])),d.parentNode&&d.parentNode.removeChild(d);return this},empty:function() +{for(var a=0,b;(b=this[a])!=null;a++){b.nodeType===1&&f.cleanData(b.getElementsByTagName("*"));while(b.firstChild)b.removeChild(b.firstChild)}return this},clone:function(a,b){a=a==null?!1:a,b=b==null?a:b;return this.map(function(){return f.clone(this,a,b)})},html:function(a){if(a===b)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(W,""):null;if(typeof a=="string"&&!ba.test(a)&&(f.support.leadingWhitespace||!X.test(a))&&!bg[(Z.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Y,"<$1>");try{for(var c=0,d=this.length;c1&&l0?this.clone(!0):this).get();f(e[h])[b](j),d=d.concat(j)}return this.pushStack(d,a,e.selector)}}),f.extend({clone:function(a,b,c){var d,e,g,h=f.support.html5Clone||!bc.test("<"+a.nodeName)?a.cloneNode(!0):bo(a);if((!f.support.noCloneEvent||!f.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!f.isXMLDoc(a)){bk(a,h),d=bl(a),e=bl(h);for(g=0;d[g];++g)e[g]&&bk(d[g],e[g])}if(b){bj(a,h);if(c){d=bl(a),e=bl(h);for(g=0;d[g];++g)bj(d[g],e[g])}}d=e=null;return h},clean:function(a,b,d,e){var g;b=b||c,typeof b.createElement=="undefined"&&(b=b.ownerDocument||b[0]&&b[0].ownerDocument||c);var h=[],i;for(var j=0,k;(k=a[j])!=null;j++){typeof k=="number"&&(k+="");if(!k)continue;if(typeof k=="string")if(!_.test(k))k=b.createTextNode(k);else{k=k.replace(Y,"<$1>");var l=(Z.exec(k)||["",""])[1].toLowerCase(),m=bg[l]||bg._default,n=m[0],o=b.createElement("div");b===c?bh.appendChild(o):U(b).appendChild(o),o.innerHTML=m[1]+k+m[2];while(n--)o=o.lastChild;if(!f.support.tbody){var p=$.test(k),q=l==="table"&&!p?o.firstChild&&o.firstChild.childNodes:m[1]===""&&!p?o.childNodes:[];for(i=q.length-1;i>=0;--i)f.nodeName(q[i],"tbody")&&!q[i].childNodes.length&&q[i].parentNode.removeChild(q[i])}!f.support.leadingWhitespace&&X.test(k)&&o.insertBefore(b.createTextNode(X.exec(k)[0]),o.firstChild),k=o.childNodes}var r;if(!f.support.appendChecked)if(k[0]&&typeof (r=k.length)=="number")for(i=0;i=0)return b+"px"}}}),f.support.opacity||(f.cssHooks.opacity={get:function(a,b){return br.test((b&&a.currentStyle?a.currentStyle.filter:a.style.filter)||"")?parseFloat(RegExp.$1)/100+"":b?"1":""},set:function(a,b){var c=a.style,d=a.currentStyle,e=f.isNumeric(b)?"alpha(opacity="+b*100+")":"",g=d&&d.filter||c.filter||"";c.zoom=1;if(b>=1&&f.trim(g.replace(bq,""))===""){c.removeAttribute("filter");if(d&&!d.filter)return}c.filter=bq.test(g)?g.replace(bq,e):g+" "+e}}),f(function(){f.support.reliableMarginRight||(f.cssHooks.marginRight={get:function(a,b){var c;f.swap(a,{display:"inline-block"},function(){b?c=bz(a,"margin-right","marginRight"):c=a.style.marginRight});return c}})}),c.defaultView&&c.defaultView.getComputedStyle&&(bA=function(a,b){var c,d,e;b=b.replace(bs,"-$1").toLowerCase(),(d=a.ownerDocument.defaultView)&&(e=d.getComputedStyle(a,null))&&(c=e.getPropertyValue(b),c===""&&!f.contains(a.ownerDocument.documentElement,a)&&(c=f.style(a,b)));return c}),c.documentElement.currentStyle&&(bB=function(a,b){var c,d,e,f=a.currentStyle&&a.currentStyle[b],g=a.style;f===null&&g&&(e=g[b])&&(f=e),!bt.test(f)&&bu.test(f)&&(c=g.left,d=a.runtimeStyle&&a.runtimeStyle.left,d&&(a.runtimeStyle.left=a.currentStyle.left),g.left=b==="fontSize"?"1em":f||0,f=g.pixelLeft+"px",g.left=c,d&&(a.runtimeStyle.left=d));return f===""?"auto":f}),bz=bA||bB,f.expr&&f.expr.filters&&(f.expr.filters.hidden=function(a){var b=a.offsetWidth,c=a.offsetHeight;return b===0&&c===0||!f.support.reliableHiddenOffsets&&(a.style&&a.style.display||f.css(a,"display"))==="none"},f.expr.filters.visible=function(a){return!f.expr.filters.hidden(a)});var bD=/%20/g,bE=/\[\]$/,bF=/\r?\n/g,bG=/#.*$/,bH=/^(.*?):[ \t]*([^\r\n]*)\r?$/mg,bI=/^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i,bJ=/^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/,bK=/^(?:GET|HEAD)$/,bL=/^\/\//,bM=/\?/,bN=/)<[^<]*)*<\/script>/gi,bO=/^(?:select|textarea)/i,bP=/\s+/,bQ=/([?&])_=[^&]*/,bR=/^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+))?)?/,bS=f.fn.load,bT={},bU={},bV,bW,bX=["*/"]+["*"];try{bV=e.href}catch(bY){bV=c.createElement("a"),bV.href="",bV=bV.href}bW=bR.exec(bV.toLowerCase())||[],f.fn.extend({load:function(a,c,d){if(typeof a!="string"&&bS)return bS.apply(this,arguments);if(!this.length)return this;var e=a.indexOf(" ");if(e>=0){var g=a.slice(e,a.length);a=a.slice(0,e)}var h="GET";c&&(f.isFunction(c)?(d=c,c=b):typeof c=="object"&&(c=f.param(c,f.ajaxSettings.traditional),h="POST"));var i=this;f.ajax({url:a,type:h,dataType:"html",data:c,complete:function(a,b,c){c=a.responseText,a.isResolved()&&(a.done(function(a){c=a}),i.html(g?f("
    ").append(c.replace(bN,"")).find(g):c)),d&&i.each(d,[c,b,a])}});return this},serialize:function(){return f.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?f.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||bO.test(this.nodeName)||bI.test(this.type))}).map(function(a,b){var c=f(this).val();return c==null?null:f.isArray(c)?f.map(c,function(a,c){return{name:b.name,value:a.replace(bF,"\r\n")}}):{name:b.name,value:c.replace(bF,"\r\n")}}).get()}}),f.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "),function(a,b){f.fn[b]=function(a){return this.on(b,a)}}),f.each(["get","post"],function(a,c){f[c]=function(a,d,e,g){f.isFunction(d)&&(g=g||e,e=d,d=b);return f.ajax({type:c,url:a,data:d,success:e,dataType:g})}}),f.extend({getScript:function(a,c){return f.get(a,b,c,"script")},getJSON:function(a,b,c){return f.get(a,b,c,"json")},ajaxSetup:function(a,b){b?b_(a,f.ajaxSettings):(b=a,a=f.ajaxSettings),b_(a,b);return a},ajaxSettings:{url:bV,isLocal:bJ.test(bW[1]),global:!0,type:"GET",contentType:"application/x-www-form-urlencoded",processData:!0,async:!0,accepts:{xml:"application/xml, text/xml",html:"text/html",text:"text/plain",json:"application/json, text/javascript","*":bX},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":a.String,"text html":!0,"text json":f.parseJSON,"text xml":f.parseXML},flatOptions:{context:!0,url:!0}},ajaxPrefilter:bZ(bT),ajaxTransport:bZ(bU),ajax:function(a,c){function w(a,c,l,m){if(s!==2){s=2,q&&clearTimeout(q),p=b,n=m||"",v.readyState=a>0?4:0;var o,r,u,w=c,x=l?cb(d,v,l):b,y,z;if(a>=200&&a<300||a===304){if(d.ifModified){if(y=v.getResponseHeader("Last-Modified"))f.lastModified[k]=y;if(z=v.getResponseHeader("Etag"))f.etag[k]=z}if(a===304)w="notmodified",o=!0;else try{r=cc(d,x),w="success",o=!0}catch(A){w="parsererror",u=A}}else{u=w;if(!w||a)w="error",a<0&&(a=0)}v.status=a,v.statusText=""+(c||w),o?h.resolveWith(e,[r,w,v]):h.rejectWith(e,[v,w,u]),v.statusCode(j),j=b,t&&g.trigger("ajax"+(o?"Success":"Error"),[v,d,o?r:u]),i.fireWith(e,[v,w]),t&&(g.trigger("ajaxComplete",[v,d]),--f.active||f.event.trigger("ajaxStop"))}}typeof a=="object"&&(c=a,a=b),c=c||{};var d=f.ajaxSetup({},c),e=d.context||d,g=e!==d&&(e.nodeType||e instanceof f)?f(e):f.event,h=f.Deferred(),i=f.Callbacks("once memory"),j=d.statusCode||{},k,l={},m={},n,o,p,q,r,s=0,t,u,v={readyState:0,setRequestHeader:function(a,b){if(!s){var c=a.toLowerCase();a=m[c]=m[c]||a,l[a]=b}return this},getAllResponseHeaders:function(){return s===2?n:null},getResponseHeader:function(a){var c;if(s===2){if(!o){o={};while(c=bH.exec(n))o[c[1].toLowerCase()]=c[2]}c=o[a.toLowerCase()]}return c===b?null:c},overrideMimeType:function(a){s||(d.mimeType=a);return this},abort:function(a){a=a||"abort",p&&p.abort(a),w(0,a);return this}};h.promise(v),v.success=v.done,v.error=v.fail,v.complete=i.add,v.statusCode=function(a){if(a){var b;if(s<2)for(b in a)j[b]=[j[b],a[b]];else b=a[v.status],v.then(b,b)}return this},d.url=((a||d.url)+"").replace(bG,"").replace(bL,bW[1]+"//"),d.dataTypes=f.trim(d.dataType||"*").toLowerCase().split(bP),d.crossDomain==null&&(r=bR.exec(d.url.toLowerCase()),d.crossDomain=!(!r||r[1]==bW[1]&&r[2]==bW[2]&&(r[3]||(r[1]==="http:"?80:443))==(bW[3]||(bW[1]==="http:"?80:443)))),d.data&&d.processData&&typeof d.data!="string"&&(d.data=f.param(d.data,d.traditional)),b$(bT,d,c,v);if(s===2)return!1;t=d.global,d.type=d.type.toUpperCase(),d.hasContent=!bK.test(d.type),t&&f.active++===0&&f.event.trigger("ajaxStart");if(!d.hasContent){d.data&&(d.url+=(bM.test(d.url)?"&":"?")+d.data,delete d.data),k=d.url;if(d.cache===!1){var x=f.now(),y=d.url.replace(bQ,"$1_="+x);d.url=y+(y===d.url?(bM.test(d.url)?"&":"?")+"_="+x:"")}}(d.data&&d.hasContent&&d.contentType!==!1||c.contentType)&&v.setRequestHeader("Content-Type",d.contentType),d.ifModified&&(k=k||d.url,f.lastModified[k]&&v.setRequestHeader("If-Modified-Since",f.lastModified[k]),f.etag[k]&&v.setRequestHeader("If-None-Match",f.etag[k])),v.setRequestHeader("Accept",d.dataTypes[0]&&d.accepts[d.dataTypes[0]]?d.accepts[d.dataTypes[0]]+(d.dataTypes[0]!=="*"?", "+bX+"; q=0.01":""):d.accepts["*"]);for(u in d.headers)v.setRequestHeader(u,d.headers[u]);if(d.beforeSend&&(d.beforeSend.call(e,v,d)===!1||s===2)){v.abort();return!1}for(u in{success:1,error:1,complete:1})v[u](d[u]);p=b$(bU,d,c,v);if(!p)w(-1,"No Transport");else{v.readyState=1,t&&g.trigger("ajaxSend",[v,d]),d.async&&d.timeout>0&&(q=setTimeout(function(){v.abort("timeout")},d.timeout));try{s=1,p.send(l,w)}catch(z){if(s<2)w(-1,z);else throw z}}return v},param:function(a,c){var d=[],e=function(a,b){b=f.isFunction(b)?b():b,d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(b)};c===b&&(c=f.ajaxSettings.traditional);if(f.isArray(a)||a.jquery&&!f.isPlainObject(a))f.each(a,function(){e(this.name,this.value)});else for(var g in a)ca(g,a[g],c,e);return d.join("&").replace(bD,"+")}}),f.extend({active:0,lastModified:{},etag:{}});var cd=f.now(),ce=/(\=)\?(&|$)|\?\?/i;f.ajaxSetup({jsonp:"callback",jsonpCallback:function(){return f.expando+"_"+cd++}}),f.ajaxPrefilter("json jsonp",function(b,c,d){var e=b.contentType==="application/x-www-form-urlencoded"&&typeof b.data=="string";if(b.dataTypes[0]==="jsonp"||b.jsonp!==!1&&(ce.test(b.url)||e&&ce.test(b.data))){var g,h=b.jsonpCallback=f.isFunction(b.jsonpCallback)?b.jsonpCallback():b.jsonpCallback,i=a[h],j=b.url,k=b.data,l="$1"+h+"$2";b.jsonp!==!1&&(j=j.replace(ce,l),b.url===j&&(e&&(k=k.replace(ce,l)),b.data===k&&(j+=(/\?/.test(j)?"&":"?")+b.jsonp+"="+h))),b.url=j,b.data=k,a[h]=function(a){g=[a]},d.always(function(){a[h]=i,g&&f.isFunction(i)&&a[h](g[0])}),b.converters["script json"]=function(){g||f.error(h+" was not called");return g[0]},b.dataTypes[0]="json";return"script"}}),f.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/javascript|ecmascript/},converters:{"text script":function(a){f.globalEval(a);return a}}}),f.ajaxPrefilter("script",function(a){a.cache===b&&(a.cache=!1),a.crossDomain&&(a.type="GET",a.global=!1)}),f.ajaxTransport("script",function(a){if(a.crossDomain){var d,e=c.head||c.getElementsByTagName("head")[0]||c.documentElement;return{send:function(f,g){d=c.createElement("script"),d.async="async",a.scriptCharset&&(d.charset=a.scriptCharset),d.src=a.url,d.onload=d.onreadystatechange=function(a,c){if(c||!d.readyState||/loaded|complete/.test(d.readyState))d.onload=d.onreadystatechange=null,e&&d.parentNode&&e.removeChild(d),d=b,c||g(200,"success")},e.insertBefore(d,e.firstChild)},abort:function(){d&&d.onload(0,1)}}}});var cf=a.ActiveXObject?function(){for(var a in ch)ch[a](0,1)}:!1,cg=0,ch;f.ajaxSettings.xhr=a.ActiveXObject?function(){return!this.isLocal&&ci()||cj()}:ci,function(a){f.extend(f.support,{ajax:!!a,cors:!!a&&"withCredentials"in a})}(f.ajaxSettings.xhr()),f.support.ajax&&f.ajaxTransport(function(c){if(!c.crossDomain||f.support.cors){var d;return{send:function(e,g){var h=c.xhr(),i,j;c.username?h.open(c.type,c.url,c.async,c.username,c.password):h.open(c.type,c.url,c.async);if(c.xhrFields)for(j in c.xhrFields)h[j]=c.xhrFields[j];c.mimeType&&h.overrideMimeType&&h.overrideMimeType(c.mimeType),!c.crossDomain&&!e["X-Requested-With"]&&(e["X-Requested-With"]="XMLHttpRequest");try{for(j in e)h.setRequestHeader(j,e[j])}catch(k){}h.send(c.hasContent&&c.data||null),d=function(a,e){var j,k,l,m,n;try{if(d&&(e||h.readyState===4)){d=b,i&&(h.onreadystatechange=f.noop,cf&&delete ch[i]);if(e)h.readyState!==4&&h.abort();else{j=h.status,l=h.getAllResponseHeaders(),m={},n=h.responseXML,n&&n.documentElement&&(m.xml=n),m.text=h.responseText;try{k=h.statusText}catch(o){k=""}!j&&c.isLocal&&!c.crossDomain?j=m.text?200:404:j===1223&&(j=204)}}}catch(p){e||g(-1,p)}m&&g(j,k,m,l)},!c.async||h.readyState===4?d():(i=++cg,cf&&(ch||(ch={},f(a).unload(cf)),ch[i]=d),h.onreadystatechange=d)},abort:function(){d&&d(0,1)}}}});var ck={},cl,cm,cn=/^(?:toggle|show|hide)$/,co=/^([+\-]=)?([\d+.\-]+)([a-z%]*)$/i,cp,cq=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]],cr;f.fn.extend({show:function(a,b,c){var d,e;if(a||a===0)return this.animate(cu("show",3),a,b,c);for(var g=0,h=this.length;g=i.duration+this.startTime){this.now=this.end,this.pos=this.state=1,this.update(),i.animatedProperties[this.prop]=!0;for(b in i.animatedProperties)i.animatedProperties[b]!==!0&&(g=!1);if(g){i.overflow!=null&&!f.support.shrinkWrapBlocks&&f.each(["","X","Y"],function(a,b){h.style["overflow"+b]=i.overflow[a]}),i.hide&&f(h).hide();if(i.hide||i.show)for(b in i.animatedProperties)f.style(h,b,i.orig[b]),f.removeData(h,"fxshow"+b,!0),f.removeData(h,"toggle"+b,!0);d=i.complete,d&&(i.complete=!1,d.call(h))}return!1}i.duration==Infinity?this.now=e:(c=e-this.startTime,this.state=c/i.duration,this.pos=f.easing[i.animatedProperties[this.prop]](this.state,c,0,1,i.duration),this.now=this.start+(this.end-this.start)*this.pos),this.update();return!0}},f.extend(f.fx,{tick:function(){var a,b=f.timers,c=0;for(;c-1,k={},l={},m,n;j?(l=e.position(),m=l.top,n=l.left):(m=parseFloat(h)||0,n=parseFloat(i)||0),f.isFunction(b)&&(b=b.call(a,c,g)),b.top!=null&&(k.top=b.top-g.top+m),b.left!=null&&(k.left=b.left-g.left+n),"using"in b?b.using.call(a,k):e.css(k)}},f.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),c=this.offset(),d=cx.test(b[0].nodeName)?{top:0,left:0}:b.offset();c.top-=parseFloat(f.css(a,"marginTop"))||0,c.left-=parseFloat(f.css(a,"marginLeft"))||0,d.top+=parseFloat(f.css(b[0],"borderTopWidth"))||0,d.left+=parseFloat(f.css(b[0],"borderLeftWidth"))||0;return{top:c.top-d.top,left:c.left-d.left}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||c.body;while(a&&!cx.test(a.nodeName)&&f.css(a,"position")==="static")a=a.offsetParent;return a})}}),f.each(["Left","Top"],function(a,c){var d="scroll"+c;f.fn[d]=function(c){var e,g;if(c===b){e=this[0];if(!e)return null;g=cy(e);return g?"pageXOffset"in g?g[a?"pageYOffset":"pageXOffset"]:f.support.boxModel&&g.document.documentElement[d]||g.document.body[d]:e[d]}return this.each(function(){g=cy(this),g?g.scrollTo(a?f(g).scrollLeft():c,a?c:f(g).scrollTop()):this[d]=c})}}),f.each(["Height","Width"],function(a,c){var d=c.toLowerCase();f.fn["inner"+c]=function(){var a=this[0];return a?a.style?parseFloat(f.css(a,d,"padding")):this[d]():null},f.fn["outer"+c]=function(a){var b=this[0];return b?b.style?parseFloat(f.css(b,d,a?"margin":"border")):this[d]():null},f.fn[d]=function(a){var e=this[0];if(!e)return a==null?null:this;if(f.isFunction(a))return this.each(function(b){var c=f(this);c[d](a.call(this,b,c[d]()))});if(f.isWindow(e)){var g=e.document.documentElement["client"+c],h=e.document.body;return e.document.compatMode==="CSS1Compat"&&g||h&&h["client"+c]||g}if(e.nodeType===9)return Math.max(e.documentElement["client"+c],e.body["scroll"+c],e.documentElement["scroll"+c],e.body["offset"+c],e.documentElement["offset"+c]);if(a===b){var i=f.css(e,d),j=parseFloat(i);return f.isNumeric(j)?j:i}return this.css(d,typeof a=="string"?a:a+"px")}}),a.jQuery=a.$=f,typeof define=="function"&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return f})})(window); \ No newline at end of file diff --git a/documentation/chat/backend/method_list.html b/documentation/chat/backend/method_list.html new file mode 100644 index 00000000000..16b5d99b647 --- /dev/null +++ b/documentation/chat/backend/method_list.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + Method List + + + +
    +
    +

    Method List

    + + + +
    + +
      + + +
    • +
      + contract + Chat::Service::Base +
      +
    • + + +
    • +
      + model + Chat::Service::Base +
      +
    • + + +
    • +
      + policy + Chat::Service::Base +
      +
    • + + +
    • +
      + step + Chat::Service::Base +
      +
    • + + +
    • +
      + transaction + Chat::Service::Base +
      +
    • + + +
    • +
      + #fail + Chat::Service::Base::Context +
      +
    • + + +
    • +
      + #fail! + Chat::Service::Base::Context +
      +
    • + + +
    • +
      + #failure? + Chat::Service::Base::Context +
      +
    • + + +
    • +
      + #success? + Chat::Service::Base::Context +
      +
    • + + +
    • +
      + #context + Chat::Service::Base::Failure +
      +
    • + + +
    • +
      + #call + Chat::Service::TrashChannel +
      +
    • + + +
    • +
      + #call + Chat::Service::UpdateChannel +
      +
    • + + +
    • +
      + #call + Chat::Service::UpdateChannelStatus +
      +
    • + + +
    • +
      + #call + Chat::Service::UpdateUserLastRead +
      +
    • + + + +
    +
    + + diff --git a/documentation/chat/backend/top-level-namespace.html b/documentation/chat/backend/top-level-namespace.html new file mode 100644 index 00000000000..5ac499bd4e8 --- /dev/null +++ b/documentation/chat/backend/top-level-namespace.html @@ -0,0 +1,111 @@ + + + + + + + Top Level Namespace + + — Documentation by YARD 0.9.28 + + + + + + + + + + + + + + + + + + + +
    + + +

    Top Level Namespace + + + +

    +
    + + + + + + + + + + + +
    + +

    Defined Under Namespace

    +

    + + + Modules: Chat + + + + +

    + + + + + + + + + +
    + + + + +
    + + \ No newline at end of file diff --git a/documentation/chat/frontend/PluginApi.html b/documentation/chat/frontend/PluginApi.html new file mode 100644 index 00000000000..90810a73205 --- /dev/null +++ b/documentation/chat/frontend/PluginApi.html @@ -0,0 +1,1217 @@ + + + + + Discourse: PluginApi + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    Class

    +

    PluginApi

    + + + + + +
    + + +
    + + + + + + + + + + + + +
    + +
    +
    + + + + + + + + + + + + + + + +
    + +
    + +

    new PluginApi() +

    + + + + + +
    + + Class exposing the javascript API available to plugins and themes. +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + +

    Source

    + + + + + +
    + + + + + + + + + + + + + + + +

    Methods

    + + + + + +
    + + + + + + + + + +
    + +
    + +

    decorateChatMessage(decorator) +

    + + + + + +
    + + Decorate a chat message +
    + + + + + + + + + +

    Parameters

    + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + +

    Example

    + +
    api.decorateChatMessage((chatMessage, messageContainer) => {
    +  messageContainer.dataset.foo = chatMessage.id;
    +});
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    registerChatComposerButton(options) +

    + + + + + +
    + + Register a button in the chat composer +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + options + + + + + + + + Object + + + + + + + + + +
      + +

      Properties

      + + +
        + + +
      • + + id + + + + + + + + number + + + + + + + + + + + + + + + + + +
        The id of the button
        + +
      • + + + +
      • + + action + + + + + + + + function + + + + + + + + + + + + + + + + + +
        An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }`
        + +
      • + + + +
      • + + icon + + + + + + + + string + + + + + + + + + + + + + + + + + +
        A valid font awesome icon name, eg: "far fa-image"
        + +
      • + + + +
      • + + label + + + + + + + + string + + + + + + + + + + + + + + + + + +
        Text displayed on the button, a translatable key, eg: "foo.bar"
        + +
      • + + + +
      • + + translatedLabel + + + + + + + + string + + + + + + + + + + + + + + + + + +
        Text displayed on the button, a string, eg: "Add gifs"
        + +
      • + + + +
      • + + position + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Can be "inline" or "dropdown", defaults to "inline"
        + +
      • + + + +
      • + + title + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Title attribute of the button, a translatable key, eg: "foo.bar"
        + +
      • + + + +
      • + + translatedTitle + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Title attribute of the button, a string, eg: "Add gifs"
        + +
      • + + + +
      • + + ariaLabel + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        aria-label attribute of the button, a translatable key, eg: "foo.bar"
        + +
      • + + + +
      • + + translatedAriaLabel + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        aria-label attribute of the button, a string, eg: "Add gifs"
        + +
      • + + + +
      • + + classNames + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Additional names to add to the button’s class attribute, eg: ["foo", "bar"]
        + +
      • + + + +
      • + + displayed + + + + + + + + boolean + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Hide or show the button
        + +
      • + + + +
      • + + disabled + + + + + + + + boolean + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Sets the disabled attribute on the button
        + +
      • + + + +
      • + + priority + + + + + + + + number + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        An integer defining the order of the buttons, higher comes first, eg: `700`
        + +
      • + + + +
      • + + dependentKeys + + + + + + + + Array.<string> + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + +

    Example

    + +
    api.registerChatComposerButton({
    +  id: "foo",
    +  displayed() {
    +    return this.site.mobileView && this.canAttachUploads;
    +  }
    +});
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +

    Type Definitions

    + + + + + +
    + + + + + + + + + +
    + +
    + +

    decorateChatMessageCallback(chatMessage, messageContainer, chatChannel) +

    + + + + + +
    + + Callback used to decorate a chat message +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + chatMessage + + + + + + + + ChatMessage + + + + + + + + + +
      model
      + +
    • + + + +
    • + + messageContainer + + + + + + + + HTMLElement + + + + + + + + + +
      DOM node
      + +
    • + + + +
    • + + chatChannel + + + + + + + + ChatChannel + + + + + + + + + +
      model
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + +
    + + + + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/documentation/chat/frontend/global.html b/documentation/chat/frontend/global.html new file mode 100644 index 00000000000..3ac821fd57f --- /dev/null +++ b/documentation/chat/frontend/global.html @@ -0,0 +1,372 @@ + + + + + Discourse: Global + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    +

    Global

    + + + + + +
    + + +
    + + + + + + + + + + + + +
    + +
    +
    + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + + + + + + + + + + + + + + + +

    Methods

    + + + + + +
    + + + + + + + + + +
    + +
    + +

    load() → {Promise} +

    + + + + + +
    + + Loads first batch of results +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    loadMore() → {Promise} +

    + + + + + +
    + + Attempts to load more results +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + + + +
    + +
    + + + + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/documentation/chat/frontend/index.html b/documentation/chat/frontend/index.html new file mode 100644 index 00000000000..1aa37512e0f --- /dev/null +++ b/documentation/chat/frontend/index.html @@ -0,0 +1,80 @@ + + + + + Discourse: + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    +

    + + + + + + + +

    + + + + + + + + + + + + + + + +
    +

    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 user documentation, see Discourse Chat.

    +

    For developer documentation, see Discourse Documentation.

    +
    + + + + + + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/documentation/chat/frontend/lib_collection.js.html b/documentation/chat/frontend/lib_collection.js.html new file mode 100644 index 00000000000..53d771795cd --- /dev/null +++ b/documentation/chat/frontend/lib_collection.js.html @@ -0,0 +1,178 @@ + + + + + Discourse: lib/collection.js + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    source

    +

    lib/collection.js

    + + + + + + +
    +
    +
    import { ajax } from "discourse/lib/ajax";
    +import { tracked } from "@glimmer/tracking";
    +import { bind } from "discourse-common/utils/decorators";
    +import { Promise } from "rsvp";
    +
    +/**
    + * Handles a paginated API response.
    + */
    +export default class Collection {
    +  @tracked items = [];
    +  @tracked meta = {};
    +  @tracked loading = false;
    +
    +  constructor(resourceURL, handler) {
    +    this._resourceURL = resourceURL;
    +    this._handler = handler;
    +    this._fetchedAll = false;
    +  }
    +
    +  get loadMoreURL() {
    +    return this.meta.load_more_url;
    +  }
    +
    +  get totalRows() {
    +    return this.meta.total_rows;
    +  }
    +
    +  get length() {
    +    return this.items.length;
    +  }
    +
    +  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
    +  [Symbol.iterator]() {
    +    let index = 0;
    +
    +    return {
    +      next: () => {
    +        if (index < this.items.length) {
    +          return { value: this.items[index++], done: false };
    +        } else {
    +          return { done: true };
    +        }
    +      },
    +    };
    +  }
    +
    +  /**
    +   * Loads first batch of results
    +   * @returns {Promise}
    +   */
    +  @bind
    +  load(params = {}) {
    +    this._fetchedAll = false;
    +
    +    if (this.loading) {
    +      return Promise.resolve();
    +    }
    +
    +    this.loading = true;
    +
    +    const filteredQueryParams = Object.entries(params).filter(
    +      ([, v]) => v !== undefined
    +    );
    +    const queryString = new URLSearchParams(filteredQueryParams).toString();
    +
    +    const endpoint = this._resourceURL + (queryString ? `?${queryString}` : "");
    +    return this.#fetch(endpoint)
    +      .then((result) => {
    +        this.items = this._handler(result);
    +        this.meta = result.meta;
    +      })
    +      .finally(() => {
    +        this.loading = false;
    +      });
    +  }
    +
    +  /**
    +   * Attempts to load more results
    +   * @returns {Promise}
    +   */
    +  @bind
    +  loadMore() {
    +    let promise = Promise.resolve();
    +
    +    if (this.loading) {
    +      return promise;
    +    }
    +
    +    if (
    +      this._fetchedAll ||
    +      (this.totalRows && this.items.length >= this.totalRows)
    +    ) {
    +      return promise;
    +    }
    +
    +    this.loading = true;
    +
    +    if (this.loadMoreURL) {
    +      promise = this.#fetch(this.loadMoreURL).then((result) => {
    +        const newItems = this._handler(result);
    +
    +        if (newItems.length) {
    +          this.items = this.items.concat(newItems);
    +        } else {
    +          this._fetchedAll = true;
    +        }
    +        this.meta = result.meta;
    +      });
    +    }
    +
    +    return promise.finally(() => {
    +      this.loading = false;
    +    });
    +  }
    +
    +  #fetch(url) {
    +    return ajax(url, { type: "GET" });
    +  }
    +}
    +
    +
    +
    + + + + +
    +
    + + + + + + + + diff --git a/documentation/chat/frontend/module-ChatApi.html b/documentation/chat/frontend/module-ChatApi.html new file mode 100644 index 00000000000..fc73b931c23 --- /dev/null +++ b/documentation/chat/frontend/module-ChatApi.html @@ -0,0 +1,3236 @@ + + + + + Discourse: ChatApi + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    Module

    +

    ChatApi

    + + + + + +
    + + +
    + + + +
    + +
    +
    + + + + + +
    Chat API service. Provides methods to interact with the chat API.
    + + + + + +
    + + + + + + + + + + + +

    Implements

    +
      + +
    • {@ember/service}
    • + +
    + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + + + + + + + + + + + + + + + +

    Methods

    + + + + + +
    + + + + + + + + + +
    + +
    + +

    categoryPermissions(categoryId) → {Promise} +

    + + + + + +
    + + Lists chat permissions for a category. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + categoryId + + + + + + + + number + + + + + + + + + +
      ID of the category.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    channel(channelId) → {Promise} +

    + + + + + +
    + + Get a channel by its ID. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + +

    Example

    + +
    this.chatApi.channel(1).then(channel => { ... })
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    channels() → {Collection} +

    + + + + + +
    + + List all accessible category channels of the current user. +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Collection + + +
    • + +
    + + + + +

    Example

    + +
    this.chatApi.channels.then(channels => { ... })
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    createChannel(data) → {Promise} +

    + + + + + +
    + + Creates a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      Params of the channel.
      + +

      Properties

      + + +
        + + +
      • + + name + + + + + + + + string + + + + + + + + + + + + + + + + + +
        The name of the channel.
        + +
      • + + + +
      • + + chatable_id + + + + + + + + string + + + + + + + + + + + + + + + + + +
        The category of the channel.
        + +
      • + + + +
      • + + description + + + + + + + + string + + + + + + + + + + + + + + + + + +
        The description of the channel.
        + +
      • + + + +
      • + + auto_join_users + + + + + + + + boolean + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Should users join this channel automatically.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + +

    Example

    + +
    this.chatApi
    +     .createChannel({ name: "foo", chatable_id: 1, description "bar" })
    +     .then((channel) => { ... })
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    createChannelArchive(channelId, data) → {Promise} +

    + + + + + +
    + + Creates a channel archive. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      Params of the archive.
      + +

      Properties

      + + +
        + + +
      • + + selection + + + + + + + + string + + + + + + + + + + + + + + + + + +
        "new_topic" or "existing_topic".
        + +
      • + + + +
      • + + title + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Title of the topic when creating a new topic.
        + +
      • + + + +
      • + + category_id + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        ID of the category used when creating a new topic.
        + +
      • + + + +
      • + + tags + + + + + + + + Array.<string> + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        tags used when creating a new topic.
        + +
      • + + + +
      • + + topic_id + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        ID of the topic when using an existing topic.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    destroyChannel(channelId) → {Promise} +

    + + + + + +
    + + Destroys a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + +

    Example

    + +
    this.chatApi.destroyChannel(1).then(() => { ... })
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    followChannel(channelId) → {Promise} +

    + + + + + +
    + + Makes current user follow a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    listChannelMemberships(channelId) → {Collection} +

    + + + + + +
    + + Lists members of a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Collection + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    listCurrentUserChannels() → {Promise} +

    + + + + + +
    + + Lists public and direct message channels of the current user. +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    moveChannelMessages(channelId, data) → {Promise} +

    + + + + + +
    + + Moves messages from one channel to another. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the original channel.
      + +
    • + + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      Params of the move.
      + +

      Properties

      + + +
        + + +
      • + + message_ids + + + + + + + + Array.<number> + + + + + + + + + +
        IDs of the moved messages.
        + +
      • + + + +
      • + + destination_channel_id + + + + + + + + number + + + + + + + + + +
        ID of the channel where the messages are moved to.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + +

    Example

    + +
    this.chatApi
    +    .moveChannelMessages(1, {
    +      message_ids: [2, 3],
    +      destination_channel_id: 4,
    +    }).then(() => { ... })
    + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    sendMessage(channelId, data) → {Promise} +

    + + + + + +
    + + Sends a message. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      ID of the channel.
      + +
    • + + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      Params of the message.
      + +

      Properties

      + + +
        + + +
      • + + message + + + + + + + + string + + + + + + + + + + + + + + + + + +
        The raw content of the message in markdown.
        + +
      • + + + +
      • + + cooked + + + + + + + + string + + + + + + + + + + + + + + + + + +
        The cooked content of the message.
        + +
      • + + + +
      • + + in_reply_to_id + + + + + + + + number + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        The ID of the replied-to message.
        + +
      • + + + +
      • + + staged_id + + + + + + + + number + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        The staged ID of the message before it was persisted.
        + +
      • + + + +
      • + + upload_ids + + + + + + + + Array.<number> + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Array of upload ids linked to the message.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    unfollowChannel(channelId) → {Promise} +

    + + + + + +
    + + Makes current user unfollow a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    updateChannel(channelId, data) → {Promise} +

    + + + + + +
    + + Updates a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      Params of the archive.
      + +

      Properties

      + + +
        + + +
      • + + description + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Description of the channel.
        + +
      • + + + +
      • + + name + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Name of the channel.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    updateChannelStatus(channelId, status) → {Promise} +

    + + + + + +
    + + Updates the status of a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + + +
    • + + status + + + + + + + + string + + + + + + + + + +
      The new status, can be "open" or "closed".
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + +
    + + + + + + + + + +
    + +
    + +

    updateCurrentUserChannelNotificationsSettings(channelId, data) → {Promise} +

    + + + + + +
    + + Update notifications settings of current user for a channel. +
    + + + + + + + + + +

    Parameters

    + + +
      + + +
    • + + channelId + + + + + + + + number + + + + + + + + + +
      The ID of the channel.
      + +
    • + + + +
    • + + data + + + + + + + + object + + + + + + + + + +
      The settings to modify.
      + +

      Properties

      + + +
        + + +
      • + + muted + + + + + + + + boolean + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Mutes the channel.
        + +
      • + + + +
      • + + desktop_notification_level + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Notifications level on desktop: never, mention or always.
        + +
      • + + + +
      • + + mobile_notification_level + + + + + + + + string + + + + + + + + + <optional>
        + + + + + +
        + + + + +
        Notifications level on mobile: never, mention or always.
        + +
      • + + +
      + +
    • + + +
    + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    Returns

    +
      + +
    • + + Promise + + +
    • + +
    + + + + + + + + + + + + + + + + +

    Source

    + + + + +
    + + + + + + + +
    + +
    + + + + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/documentation/chat/frontend/module.exports.html b/documentation/chat/frontend/module.exports.html new file mode 100644 index 00000000000..fb56f529cb8 --- /dev/null +++ b/documentation/chat/frontend/module.exports.html @@ -0,0 +1,198 @@ + + + + + Discourse: exports + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    Class

    +

    exports

    + + + + + +
    + + +
    + + + + + + + + + + + + +
    Handles a paginated API response.
    + + +
    + +
    +
    + + + + + + + + + + + + + + +

    Constructor

    + + +
    + +
    + +

    new exports() +

    + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + +

    Source

    + + + + + +
    + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/documentation/chat/frontend/pre-initializers_chat-plugin-api.js.html b/documentation/chat/frontend/pre-initializers_chat-plugin-api.js.html new file mode 100644 index 00000000000..d78c363d0a4 --- /dev/null +++ b/documentation/chat/frontend/pre-initializers_chat-plugin-api.js.html @@ -0,0 +1,156 @@ + + + + + Discourse: pre-initializers/chat-plugin-api.js + + + + + + + + +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    source

    +

    pre-initializers/chat-plugin-api.js

    + + + + + + +
    +
    +
    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";
    +
    +/**
    + * Class exposing the javascript API available to plugins and themes.
    + * @class PluginApi
    + */
    +
    +/**
    + * Callback used to decorate a chat message
    + *
    + * @callback PluginApi~decorateChatMessageCallback
    + * @param {ChatMessage} chatMessage - model
    + * @param {HTMLElement} messageContainer - DOM node
    + * @param {ChatChannel} chatChannel - model
    + */
    +
    +/**
    + * Decorate a chat message
    + *
    + * @memberof PluginApi
    + * @instance
    + * @function decorateChatMessage
    + * @param {PluginApi~decorateChatMessageCallback} decorator
    + * @example
    + *
    + * api.decorateChatMessage((chatMessage, messageContainer) => {
    + *   messageContainer.dataset.foo = chatMessage.id;
    + * });
    + */
    +
    +/**
    + * Register a button in the chat composer
    + *
    + * @memberof PluginApi
    + * @instance
    + * @function registerChatComposerButton
    + * @param {Object} options
    + * @param {number} options.id - The id of the button
    + * @param {function} options.action - An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }`
    + * @param {string} options.icon - A valid font awesome icon name, eg: "far fa-image"
    + * @param {string} options.label - Text displayed on the button, a translatable key, eg: "foo.bar"
    + * @param {string} options.translatedLabel - Text displayed on the button, a string, eg: "Add gifs"
    + * @param {string} [options.position] - Can be "inline" or "dropdown", defaults to "inline"
    + * @param {string} [options.title] - Title attribute of the button, a translatable key, eg: "foo.bar"
    + * @param {string} [options.translatedTitle] - Title attribute of the button, a string, eg: "Add gifs"
    + * @param {string} [options.ariaLabel] - aria-label attribute of the button, a translatable key, eg: "foo.bar"
    + * @param {string} [options.translatedAriaLabel] - aria-label attribute of the button, a string, eg: "Add gifs"
    + * @param {string} [options.classNames] - Additional names to add to the button’s class attribute, eg: ["foo", "bar"]
    + * @param {boolean} [options.displayed] - Hide or show the button
    + * @param {boolean} [options.disabled] - Sets the disabled attribute on the button
    + * @param {number} [options.priority] - An integer defining the order of the buttons, higher comes first, eg: `700`
    + * @param {Array.<string>} [options.dependentKeys] - List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
    + * @example
    + *
    + * api.registerChatComposerButton({
    + *   id: "foo",
    + *   displayed() {
    + *     return this.site.mobileView && this.canAttachUploads;
    + *   }
    + * });
    + */
    +
    +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/documentation/chat/frontend/scripts/prism-linenumbers.js b/documentation/chat/frontend/scripts/prism-linenumbers.js new file mode 100644 index 00000000000..67ede74adfc --- /dev/null +++ b/documentation/chat/frontend/scripts/prism-linenumbers.js @@ -0,0 +1,57 @@ +(function() { + +if (typeof self === 'undefined' || !self.Prism || !self.document) { + return; +} + +Prism.hooks.add('complete', function (env) { + if (!env.code) { + return; + } + + // works only for wrapped inside
     (not inline)
    +  var pre = env.element.parentNode;
    +  var clsReg = /\s*\bline-numbers\b\s*/;
    +  if (
    +    !pre || !/pre/i.test(pre.nodeName) ||
    +      // Abort only if nor the 
     nor the  have the class
    +    (!clsReg.test(pre.className) && !clsReg.test(env.element.className))
    +  ) {
    +    return;
    +  }
    +
    +  if (env.element.querySelector(".line-numbers-rows")) {
    +    // Abort if line numbers already exists
    +    return;
    +  }
    +
    +  if (clsReg.test(env.element.className)) {
    +    // Remove the class "line-numbers" from the 
    +    env.element.className = env.element.className.replace(clsReg, '');
    +  }
    +  if (!clsReg.test(pre.className)) {
    +    // Add the class "line-numbers" to the 
    +    pre.className += ' line-numbers';
    +  }
    +
    +  var match = env.code.match(/\n(?!$)/g);
    +  var linesNum = match ? match.length + 1 : 1;
    +  var lineNumbersWrapper;
    +
    +  var lines = new Array(linesNum + 1);
    +  lines = lines.join('');
    +
    +  lineNumbersWrapper = document.createElement('span');
    +  lineNumbersWrapper.setAttribute('aria-hidden', 'true');
    +  lineNumbersWrapper.className = 'line-numbers-rows';
    +  lineNumbersWrapper.innerHTML = lines;
    +
    +  if (pre.hasAttribute('data-start')) {
    +    pre.style.counterReset = 'linenumber ' + (parseInt(pre.getAttribute('data-start'), 10) - 1);
    +  }
    +
    +  env.element.appendChild(lineNumbersWrapper);
    +
    +});
    +
    +}());
    \ No newline at end of file
    diff --git a/documentation/chat/frontend/scripts/prism.dev.js b/documentation/chat/frontend/scripts/prism.dev.js
    new file mode 100644
    index 00000000000..beb5e5827f7
    --- /dev/null
    +++ b/documentation/chat/frontend/scripts/prism.dev.js
    @@ -0,0 +1,1115 @@
    +/* PrismJS 1.13.0
    +http://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+json&plugins=line-highlight+line-numbers */
    +var _self = (typeof window !== 'undefined')
    +	? window   // if in browser
    +	: (
    +		(typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope)
    +		? self // if in worker
    +		: {}   // if in node js
    +	);
    +
    +/**
    + * Prism: Lightweight, robust, elegant syntax highlighting
    + * MIT license http://www.opensource.org/licenses/mit-license.php/
    + * @author Lea Verou http://lea.verou.me
    + */
    +
    +var Prism = (function(){
    +
    +// Private helper vars
    +var lang = /\blang(?:uage)?-(\w+)\b/i;
    +var uniqueId = 0;
    +
    +var _ = _self.Prism = {
    +	manual: _self.Prism && _self.Prism.manual,
    +	disableWorkerMessageHandler: _self.Prism && _self.Prism.disableWorkerMessageHandler,
    +	util: {
    +		encode: function (tokens) {
    +			if (tokens instanceof Token) {
    +				return new Token(tokens.type, _.util.encode(tokens.content), tokens.alias);
    +			} else if (_.util.type(tokens) === 'Array') {
    +				return tokens.map(_.util.encode);
    +			} else {
    +				return tokens.replace(/&/g, '&').replace(/ text.length) {
    +						// Something went terribly wrong, ABORT, ABORT!
    +						return;
    +					}
    +
    +					if (str instanceof Token) {
    +						continue;
    +					}
    +
    +					if (greedy && i != strarr.length - 1) {
    +						pattern.lastIndex = pos;
    +						var match = pattern.exec(text);
    +						if (!match) {
    +							break;
    +						}
    +
    +						var from = match.index + (lookbehind ? match[1].length : 0),
    +						    to = match.index + match[0].length,
    +						    k = i,
    +						    p = pos;
    +
    +						for (var len = strarr.length; k < len && (p < to || (!strarr[k].type && !strarr[k - 1].greedy)); ++k) {
    +							p += strarr[k].length;
    +							// Move the index i to the element in strarr that is closest to from
    +							if (from >= p) {
    +								++i;
    +								pos = p;
    +							}
    +						}
    +
    +						// If strarr[i] is a Token, then the match starts inside another Token, which is invalid
    +						if (strarr[i] instanceof Token) {
    +							continue;
    +						}
    +
    +						// Number of tokens to delete and replace with the new match
    +						delNum = k - i;
    +						str = text.slice(pos, p);
    +						match.index -= pos;
    +					} else {
    +						pattern.lastIndex = 0;
    +
    +						var match = pattern.exec(str),
    +							delNum = 1;
    +					}
    +
    +					if (!match) {
    +						if (oneshot) {
    +							break;
    +						}
    +
    +						continue;
    +					}
    +
    +					if(lookbehind) {
    +						lookbehindLength = match[1] ? match[1].length : 0;
    +					}
    +
    +					var from = match.index + lookbehindLength,
    +					    match = match[0].slice(lookbehindLength),
    +					    to = from + match.length,
    +					    before = str.slice(0, from),
    +					    after = str.slice(to);
    +
    +					var args = [i, delNum];
    +
    +					if (before) {
    +						++i;
    +						pos += before.length;
    +						args.push(before);
    +					}
    +
    +					var wrapped = new Token(token, inside? _.tokenize(match, inside) : match, alias, match, greedy);
    +
    +					args.push(wrapped);
    +
    +					if (after) {
    +						args.push(after);
    +					}
    +
    +					Array.prototype.splice.apply(strarr, args);
    +
    +					if (delNum != 1)
    +						_.matchGrammar(text, strarr, grammar, i, pos, true, token);
    +
    +					if (oneshot)
    +						break;
    +				}
    +			}
    +		}
    +	},
    +
    +	tokenize: function(text, grammar, language) {
    +		var strarr = [text];
    +
    +		var rest = grammar.rest;
    +
    +		if (rest) {
    +			for (var token in rest) {
    +				grammar[token] = rest[token];
    +			}
    +
    +			delete grammar.rest;
    +		}
    +
    +		_.matchGrammar(text, strarr, grammar, 0, 0, false);
    +
    +		return strarr;
    +	},
    +
    +	hooks: {
    +		all: {},
    +
    +		add: function (name, callback) {
    +			var hooks = _.hooks.all;
    +
    +			hooks[name] = hooks[name] || [];
    +
    +			hooks[name].push(callback);
    +		},
    +
    +		run: function (name, env) {
    +			var callbacks = _.hooks.all[name];
    +
    +			if (!callbacks || !callbacks.length) {
    +				return;
    +			}
    +
    +			for (var i=0, callback; callback = callbacks[i++];) {
    +				callback(env);
    +			}
    +		}
    +	}
    +};
    +
    +var Token = _.Token = function(type, content, alias, matchedStr, greedy) {
    +	this.type = type;
    +	this.content = content;
    +	this.alias = alias;
    +	// Copy of the full string this token was created from
    +	this.length = (matchedStr || "").length|0;
    +	this.greedy = !!greedy;
    +};
    +
    +Token.stringify = function(o, language, parent) {
    +	if (typeof o == 'string') {
    +		return o;
    +	}
    +
    +	if (_.util.type(o) === 'Array') {
    +		return o.map(function(element) {
    +			return Token.stringify(element, language, o);
    +		}).join('');
    +	}
    +
    +	var env = {
    +		type: o.type,
    +		content: Token.stringify(o.content, language, parent),
    +		tag: 'span',
    +		classes: ['token', o.type],
    +		attributes: {},
    +		language: language,
    +		parent: parent
    +	};
    +
    +	if (o.alias) {
    +		var aliases = _.util.type(o.alias) === 'Array' ? o.alias : [o.alias];
    +		Array.prototype.push.apply(env.classes, aliases);
    +	}
    +
    +	_.hooks.run('wrap', env);
    +
    +	var attributes = Object.keys(env.attributes).map(function(name) {
    +		return name + '="' + (env.attributes[name] || '').replace(/"/g, '"') + '"';
    +	}).join(' ');
    +
    +	return '<' + env.tag + ' class="' + env.classes.join(' ') + '"' + (attributes ? ' ' + attributes : '') + '>' + env.content + '';
    +
    +};
    +
    +if (!_self.document) {
    +	if (!_self.addEventListener) {
    +		// in Node.js
    +		return _self.Prism;
    +	}
    +
    +	if (!_.disableWorkerMessageHandler) {
    +		// In worker
    +		_self.addEventListener('message', function (evt) {
    +			var message = JSON.parse(evt.data),
    +				lang = message.language,
    +				code = message.code,
    +				immediateClose = message.immediateClose;
    +
    +			_self.postMessage(_.highlight(code, _.languages[lang], lang));
    +			if (immediateClose) {
    +				_self.close();
    +			}
    +		}, false);
    +	}
    +
    +	return _self.Prism;
    +}
    +
    +//Get current script and highlight
    +var script = document.currentScript || [].slice.call(document.getElementsByTagName("script")).pop();
    +
    +if (script) {
    +	_.filename = script.src;
    +
    +	if (!_.manual && !script.hasAttribute('data-manual')) {
    +		if(document.readyState !== "loading") {
    +			if (window.requestAnimationFrame) {
    +				window.requestAnimationFrame(_.highlightAll);
    +			} else {
    +				window.setTimeout(_.highlightAll, 16);
    +			}
    +		}
    +		else {
    +			document.addEventListener('DOMContentLoaded', _.highlightAll);
    +		}
    +	}
    +}
    +
    +return _self.Prism;
    +
    +})();
    +
    +if (typeof module !== 'undefined' && module.exports) {
    +	module.exports = Prism;
    +}
    +
    +// hack for components to work correctly in node.js
    +if (typeof global !== 'undefined') {
    +	global.Prism = Prism;
    +}
    +;
    +Prism.languages.markup = {
    +	'comment': //,
    +	'prolog': /<\?[\s\S]+?\?>/,
    +	'doctype': //i,
    +	'cdata': //i,
    +	'tag': {
    +		pattern: /<\/?(?!\d)[^\s>\/=$<%]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">=]+))?)*\s*\/?>/i,
    +		greedy: true,
    +		inside: {
    +			'tag': {
    +				pattern: /^<\/?[^\s>\/]+/i,
    +				inside: {
    +					'punctuation': /^<\/?/,
    +					'namespace': /^[^\s>\/:]+:/
    +				}
    +			},
    +			'attr-value': {
    +				pattern: /=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">=]+)/i,
    +				inside: {
    +					'punctuation': [
    +						/^=/,
    +						{
    +							pattern: /(^|[^\\])["']/,
    +							lookbehind: true
    +						}
    +					]
    +				}
    +			},
    +			'punctuation': /\/?>/,
    +			'attr-name': {
    +				pattern: /[^\s>\/]+/,
    +				inside: {
    +					'namespace': /^[^\s>\/:]+:/
    +				}
    +			}
    +
    +		}
    +	},
    +	'entity': /&#?[\da-z]{1,8};/i
    +};
    +
    +Prism.languages.markup['tag'].inside['attr-value'].inside['entity'] =
    +	Prism.languages.markup['entity'];
    +
    +// Plugin to make entity title show the real entity, idea by Roman Komarov
    +Prism.hooks.add('wrap', function(env) {
    +
    +	if (env.type === 'entity') {
    +		env.attributes['title'] = env.content.replace(/&/, '&');
    +	}
    +});
    +
    +Prism.languages.xml = Prism.languages.markup;
    +Prism.languages.html = Prism.languages.markup;
    +Prism.languages.mathml = Prism.languages.markup;
    +Prism.languages.svg = Prism.languages.markup;
    +
    +Prism.languages.css = {
    +	'comment': /\/\*[\s\S]*?\*\//,
    +	'atrule': {
    +		pattern: /@[\w-]+?.*?(?:;|(?=\s*\{))/i,
    +		inside: {
    +			'rule': /@[\w-]+/
    +			// See rest below
    +		}
    +	},
    +	'url': /url\((?:(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,
    +	'selector': /[^{}\s][^{};]*?(?=\s*\{)/,
    +	'string': {
    +		pattern: /("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,
    +		greedy: true
    +	},
    +	'property': /[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i,
    +	'important': /\B!important\b/i,
    +	'function': /[-a-z0-9]+(?=\()/i,
    +	'punctuation': /[(){};:]/
    +};
    +
    +Prism.languages.css['atrule'].inside.rest = Prism.languages.css;
    +
    +if (Prism.languages.markup) {
    +	Prism.languages.insertBefore('markup', 'tag', {
    +		'style': {
    +			pattern: /()[\s\S]*?(?=<\/style>)/i,
    +			lookbehind: true,
    +			inside: Prism.languages.css,
    +			alias: 'language-css',
    +			greedy: true
    +		}
    +	});
    +
    +	Prism.languages.insertBefore('inside', 'attr-value', {
    +		'style-attr': {
    +			pattern: /\s*style=("|')(?:\\[\s\S]|(?!\1)[^\\])*\1/i,
    +			inside: {
    +				'attr-name': {
    +					pattern: /^\s*style/i,
    +					inside: Prism.languages.markup.tag.inside
    +				},
    +				'punctuation': /^\s*=\s*['"]|['"]\s*$/,
    +				'attr-value': {
    +					pattern: /.+/i,
    +					inside: Prism.languages.css
    +				}
    +			},
    +			alias: 'language-css'
    +		}
    +	}, Prism.languages.markup.tag);
    +};
    +Prism.languages.clike = {
    +	'comment': [
    +		{
    +			pattern: /(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,
    +			lookbehind: true
    +		},
    +		{
    +			pattern: /(^|[^\\:])\/\/.*/,
    +			lookbehind: true,
    +			greedy: true
    +		}
    +	],
    +	'string': {
    +		pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,
    +		greedy: true
    +	},
    +	'class-name': {
    +		pattern: /((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[\w.\\]+/i,
    +		lookbehind: true,
    +		inside: {
    +			punctuation: /[.\\]/
    +		}
    +	},
    +	'keyword': /\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,
    +	'boolean': /\b(?:true|false)\b/,
    +	'function': /[a-z0-9_]+(?=\()/i,
    +	'number': /\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,
    +	'operator': /--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,
    +	'punctuation': /[{}[\];(),.:]/
    +};
    +
    +Prism.languages.javascript = Prism.languages.extend('clike', {
    +	'keyword': /\b(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,
    +	'number': /\b(?:0[xX][\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+|NaN|Infinity)\b|(?:\b\d+\.?\d*|\B\.\d+)(?:[Ee][+-]?\d+)?/,
    +	// Allow for all non-ASCII characters (See http://stackoverflow.com/a/2008444)
    +	'function': /[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*\()/i,
    +	'operator': /-[-=]?|\+[+=]?|!=?=?|<>?>?=?|=(?:==?|>)?|&[&=]?|\|[|=]?|\*\*?=?|\/=?|~|\^=?|%=?|\?|\.{3}/
    +});
    +
    +Prism.languages.insertBefore('javascript', 'keyword', {
    +	'regex': {
    +		pattern: /((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(\[[^\]\r\n]+]|\\.|[^/\\\[\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})]))/,
    +		lookbehind: true,
    +		greedy: true
    +	},
    +	// This must be declared before keyword because we use "function" inside the look-forward
    +	'function-variable': {
    +		pattern: /[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=\s*(?:function\b|(?:\([^()]*\)|[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/i,
    +		alias: 'function'
    +	},
    +	'constant': /\b[A-Z][A-Z\d_]*\b/
    +});
    +
    +Prism.languages.insertBefore('javascript', 'string', {
    +	'template-string': {
    +		pattern: /`(?:\\[\s\S]|[^\\`])*`/,
    +		greedy: true,
    +		inside: {
    +			'interpolation': {
    +				pattern: /\$\{[^}]+\}/,
    +				inside: {
    +					'interpolation-punctuation': {
    +						pattern: /^\$\{|\}$/,
    +						alias: 'punctuation'
    +					},
    +					rest: Prism.languages.javascript
    +				}
    +			},
    +			'string': /[\s\S]+/
    +		}
    +	}
    +});
    +
    +if (Prism.languages.markup) {
    +	Prism.languages.insertBefore('markup', 'tag', {
    +		'script': {
    +			pattern: /()[\s\S]*?(?=<\/script>)/i,
    +			lookbehind: true,
    +			inside: Prism.languages.javascript,
    +			alias: 'language-javascript',
    +			greedy: true
    +		}
    +	});
    +}
    +
    +Prism.languages.js = Prism.languages.javascript;
    +
    +Prism.languages.json = {
    +	'property': /"(?:\\.|[^\\"\r\n])*"(?=\s*:)/i,
    +	'string': {
    +		pattern: /"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,
    +		greedy: true
    +	},
    +	'number': /\b0x[\dA-Fa-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:[Ee][+-]?\d+)?/,
    +	'punctuation': /[{}[\]);,]/,
    +	'operator': /:/g,
    +	'boolean': /\b(?:true|false)\b/i,
    +	'null': /\bnull\b/i
    +};
    +
    +Prism.languages.jsonp = Prism.languages.json;
    +
    +(function(){
    +
    +if (typeof self === 'undefined' || !self.Prism || !self.document || !document.querySelector) {
    +	return;
    +}
    +
    +function $$(expr, con) {
    +	return Array.prototype.slice.call((con || document).querySelectorAll(expr));
    +}
    +
    +function hasClass(element, className) {
    +  className = " " + className + " ";
    +  return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(className) > -1
    +}
    +
    +// Some browsers round the line-height, others don't.
    +// We need to test for it to position the elements properly.
    +var isLineHeightRounded = (function() {
    +	var res;
    +	return function() {
    +		if(typeof res === 'undefined') {
    +			var d = document.createElement('div');
    +			d.style.fontSize = '13px';
    +			d.style.lineHeight = '1.5';
    +			d.style.padding = 0;
    +			d.style.border = 0;
    +			d.innerHTML = ' 
     '; + document.body.appendChild(d); + // Browsers that round the line-height should have offsetHeight === 38 + // The others should have 39. + res = d.offsetHeight === 38; + document.body.removeChild(d); + } + return res; + } +}()); + +function highlightLines(pre, lines, classes) { + lines = typeof lines === 'string' ? lines : pre.getAttribute('data-line'); + + var ranges = lines.replace(/\s+/g, '').split(','), + offset = +pre.getAttribute('data-line-offset') || 0; + + var parseMethod = isLineHeightRounded() ? parseInt : parseFloat; + var lineHeight = parseMethod(getComputedStyle(pre).lineHeight); + var hasLineNumbers = hasClass(pre, 'line-numbers'); + + for (var i=0, currentRange; currentRange = ranges[i++];) { + var range = currentRange.split('-'); + + var start = +range[0], + end = +range[1] || start; + + var line = pre.querySelector('.line-highlight[data-range="' + currentRange + '"]') || document.createElement('div'); + + line.setAttribute('aria-hidden', 'true'); + line.setAttribute('data-range', currentRange); + line.className = (classes || '') + ' line-highlight'; + + //if the line-numbers plugin is enabled, then there is no reason for this plugin to display the line numbers + if(hasLineNumbers && Prism.plugins.lineNumbers) { + var startNode = Prism.plugins.lineNumbers.getLine(pre, start); + var endNode = Prism.plugins.lineNumbers.getLine(pre, end); + + if (startNode) { + line.style.top = startNode.offsetTop + 'px'; + } + + if (endNode) { + line.style.height = (endNode.offsetTop - startNode.offsetTop) + endNode.offsetHeight + 'px'; + } + } else { + line.setAttribute('data-start', start); + + if(end > start) { + line.setAttribute('data-end', end); + } + + line.style.top = (start - offset - 1) * lineHeight + 'px'; + + line.textContent = new Array(end - start + 2).join(' \n'); + } + + //allow this to play nicely with the line-numbers plugin + if(hasLineNumbers) { + //need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioning + pre.appendChild(line); + } else { + (pre.querySelector('code') || pre).appendChild(line); + } + } +} + +function applyHash() { + var hash = location.hash.slice(1); + + // Remove pre-existing temporary lines + $$('.temporary.line-highlight').forEach(function (line) { + line.parentNode.removeChild(line); + }); + + var range = (hash.match(/\.([\d,-]+)$/) || [,''])[1]; + + if (!range || document.getElementById(hash)) { + return; + } + + var id = hash.slice(0, hash.lastIndexOf('.')), + pre = document.getElementById(id); + + if (!pre) { + return; + } + + if (!pre.hasAttribute('data-line')) { + pre.setAttribute('data-line', ''); + } + + highlightLines(pre, range, 'temporary '); + + document.querySelector('.temporary.line-highlight').scrollIntoView(); + + // offset fixed header with buffer + window.scrollBy(0, -100); +} + +var fakeTimer = 0; // Hack to limit the number of times applyHash() runs + +Prism.hooks.add('before-sanity-check', function(env) { + var pre = env.element.parentNode; + var lines = pre && pre.getAttribute('data-line'); + + if (!pre || !lines || !/pre/i.test(pre.nodeName)) { + return; + } + + /* + * Cleanup for other plugins (e.g. autoloader). + * + * Sometimes blocks are highlighted multiple times. It is necessary + * to cleanup any left-over tags, because the whitespace inside of the
    + * tags change the content of the tag. + */ + var num = 0; + $$('.line-highlight', pre).forEach(function (line) { + num += line.textContent.length; + line.parentNode.removeChild(line); + }); + // Remove extra whitespace + if (num && /^( \n)+$/.test(env.code.slice(-num))) { + env.code = env.code.slice(0, -num); + } +}); + +Prism.hooks.add('complete', function completeHook(env) { + var pre = env.element.parentNode; + var lines = pre && pre.getAttribute('data-line'); + + if (!pre || !lines || !/pre/i.test(pre.nodeName)) { + return; + } + + clearTimeout(fakeTimer); + + var hasLineNumbers = Prism.plugins.lineNumbers; + var isLineNumbersLoaded = env.plugins && env.plugins.lineNumbers; + + if (hasClass(pre, 'line-numbers') && hasLineNumbers && !isLineNumbersLoaded) { + Prism.hooks.add('line-numbers', completeHook); + } else { + highlightLines(pre, lines); + fakeTimer = setTimeout(applyHash, 1); + } +}); + + window.addEventListener('load', applyHash); + window.addEventListener('hashchange', applyHash); + window.addEventListener('resize', function () { + var preElements = document.querySelectorAll('pre[data-line]'); + Array.prototype.forEach.call(preElements, function (pre) { + highlightLines(pre); + }); + }); + +})(); +(function () { + + if (typeof self === 'undefined' || !self.Prism || !self.document) { + return; + } + + /** + * Plugin name which is used as a class name for
     which is activating the plugin
    +	 * @type {String}
    +	 */
    +	var PLUGIN_NAME = 'line-numbers';
    +
    +	/**
    +	 * Regular expression used for determining line breaks
    +	 * @type {RegExp}
    +	 */
    +	var NEW_LINE_EXP = /\n(?!$)/g;
    +
    +	/**
    +	 * Resizes line numbers spans according to height of line of code
    +	 * @param {Element} element 
     element
    +	 */
    +	var _resizeElement = function (element) {
    +		var codeStyles = getStyles(element);
    +		var whiteSpace = codeStyles['white-space'];
    +
    +		if (whiteSpace === 'pre-wrap' || whiteSpace === 'pre-line') {
    +			var codeElement = element.querySelector('code');
    +			var lineNumbersWrapper = element.querySelector('.line-numbers-rows');
    +			var lineNumberSizer = element.querySelector('.line-numbers-sizer');
    +			var codeLines = codeElement.textContent.split(NEW_LINE_EXP);
    +
    +			if (!lineNumberSizer) {
    +				lineNumberSizer = document.createElement('span');
    +				lineNumberSizer.className = 'line-numbers-sizer';
    +
    +				codeElement.appendChild(lineNumberSizer);
    +			}
    +
    +			lineNumberSizer.style.display = 'block';
    +
    +			codeLines.forEach(function (line, lineNumber) {
    +				lineNumberSizer.textContent = line || '\n';
    +				var lineSize = lineNumberSizer.getBoundingClientRect().height;
    +				lineNumbersWrapper.children[lineNumber].style.height = lineSize + 'px';
    +			});
    +
    +			lineNumberSizer.textContent = '';
    +			lineNumberSizer.style.display = 'none';
    +		}
    +	};
    +
    +	/**
    +	 * Returns style declarations for the element
    +	 * @param {Element} element
    +	 */
    +	var getStyles = function (element) {
    +		if (!element) {
    +			return null;
    +		}
    +
    +		return window.getComputedStyle ? getComputedStyle(element) : (element.currentStyle || null);
    +	};
    +
    +	window.addEventListener('resize', function () {
    +		Array.prototype.forEach.call(document.querySelectorAll('pre.' + PLUGIN_NAME), _resizeElement);
    +	});
    +
    +	Prism.hooks.add('complete', function (env) {
    +		if (!env.code) {
    +			return;
    +		}
    +
    +		// works only for  wrapped inside 
     (not inline)
    +		var pre = env.element.parentNode;
    +		var clsReg = /\s*\bline-numbers\b\s*/;
    +		if (
    +			!pre || !/pre/i.test(pre.nodeName) ||
    +			// Abort only if nor the 
     nor the  have the class
    +			(!clsReg.test(pre.className) && !clsReg.test(env.element.className))
    +		) {
    +			return;
    +		}
    +
    +		if (env.element.querySelector('.line-numbers-rows')) {
    +			// Abort if line numbers already exists
    +			return;
    +		}
    +
    +		if (clsReg.test(env.element.className)) {
    +			// Remove the class 'line-numbers' from the 
    +			env.element.className = env.element.className.replace(clsReg, ' ');
    +		}
    +		if (!clsReg.test(pre.className)) {
    +			// Add the class 'line-numbers' to the 
    +			pre.className += ' line-numbers';
    +		}
    +
    +		var match = env.code.match(NEW_LINE_EXP);
    +		var linesNum = match ? match.length + 1 : 1;
    +		var lineNumbersWrapper;
    +
    +		var lines = new Array(linesNum + 1);
    +		lines = lines.join('');
    +
    +		lineNumbersWrapper = document.createElement('span');
    +		lineNumbersWrapper.setAttribute('aria-hidden', 'true');
    +		lineNumbersWrapper.className = 'line-numbers-rows';
    +		lineNumbersWrapper.innerHTML = lines;
    +
    +		if (pre.hasAttribute('data-start')) {
    +			pre.style.counterReset = 'linenumber ' + (parseInt(pre.getAttribute('data-start'), 10) - 1);
    +		}
    +
    +		env.element.appendChild(lineNumbersWrapper);
    +
    +		_resizeElement(pre);
    +
    +		Prism.hooks.run('line-numbers', env);
    +	});
    +
    +	Prism.hooks.add('line-numbers', function (env) {
    +		env.plugins = env.plugins || {};
    +		env.plugins.lineNumbers = true;
    +	});
    +
    +	/**
    +	 * Global exports
    +	 */
    +	Prism.plugins.lineNumbers = {
    +		/**
    +		 * Get node for provided line number
    +		 * @param {Element} element pre element
    +		 * @param {Number} number line number
    +		 * @return {Element|undefined}
    +		 */
    +		getLine: function (element, number) {
    +			if (element.tagName !== 'PRE' || !element.classList.contains(PLUGIN_NAME)) {
    +				return;
    +			}
    +
    +			var lineNumberRows = element.querySelector('.line-numbers-rows');
    +			var lineNumberStart = parseInt(element.getAttribute('data-start'), 10) || 1;
    +			var lineNumberEnd = lineNumberStart + (lineNumberRows.children.length - 1);
    +
    +			if (number < lineNumberStart) {
    +				number = lineNumberStart;
    +			}
    +			if (number > lineNumberEnd) {
    +				number = lineNumberEnd;
    +			}
    +
    +			var lineIndex = number - lineNumberStart;
    +
    +			return lineNumberRows.children[lineIndex];
    +		}
    +	};
    +
    +}());
    diff --git a/documentation/chat/frontend/services_chat-api.js.html b/documentation/chat/frontend/services_chat-api.js.html
    new file mode 100644
    index 00000000000..67456b35620
    --- /dev/null
    +++ b/documentation/chat/frontend/services_chat-api.js.html
    @@ -0,0 +1,325 @@
    +
    +
    +
    +    
    +    Discourse: services/chat-api.js
    +    
    +      
    +    
    +    
    +    
    +
    +
    +
    +
    + +

    + + Discourse + +

    + +
    + + +
    +
    +

    source

    +

    services/chat-api.js

    + + + + + + +
    +
    +
    import Service, { inject as service } from "@ember/service";
    +import { ajax } from "discourse/lib/ajax";
    +import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
    +import Collection from "../lib/collection";
    +
    +/**
    + * Chat API service. Provides methods to interact with the chat API.
    + *
    + * @module ChatApi
    + * @implements {@ember/service}
    + */
    +export default class ChatApi extends Service {
    +  @service chatChannelsManager;
    +
    +  /**
    +   * Get a channel by its ID.
    +   * @param {number} channelId - The ID of the channel.
    +   * @returns {Promise}
    +   *
    +   * @example
    +   *
    +   *    this.chatApi.channel(1).then(channel => { ... })
    +   */
    +  channel(channelId) {
    +    return this.#getRequest(`/channels/${channelId}`).then((result) =>
    +      this.chatChannelsManager.store(result.channel)
    +    );
    +  }
    +
    +  /**
    +   * List all accessible category channels of the current user.
    +   * @returns {Collection}
    +   *
    +   * @example
    +   *
    +   *    this.chatApi.channels.then(channels => { ... })
    +   */
    +  channels() {
    +    return new Collection(`${this.#basePath}/channels`, (response) => {
    +      return response.channels.map((channel) =>
    +        this.chatChannelsManager.store(channel)
    +      );
    +    });
    +  }
    +
    +  /**
    +   * Moves messages from one channel to another.
    +   * @param {number} channelId - The ID of the original channel.
    +   * @param {object} data - Params of the move.
    +   * @param {Array.<number>} data.message_ids - IDs of the moved messages.
    +   * @param {number} data.destination_channel_id - ID of the channel where the messages are moved to.
    +   * @returns {Promise}
    +   *
    +   * @example
    +   *
    +   *   this.chatApi
    +   *     .moveChannelMessages(1, {
    +   *       message_ids: [2, 3],
    +   *       destination_channel_id: 4,
    +   *     }).then(() => { ... })
    +   */
    +  moveChannelMessages(channelId, data = {}) {
    +    return this.#postRequest(`/channels/${channelId}/messages/moves`, {
    +      move: data,
    +    });
    +  }
    +
    +  /**
    +   * Destroys a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @returns {Promise}
    +   *
    +   * @example
    +   *
    +   *    this.chatApi.destroyChannel(1).then(() => { ... })
    +   */
    +  destroyChannel(channelId) {
    +    return this.#deleteRequest(`/channels/${channelId}`);
    +  }
    +
    +  /**
    +   * Creates a channel.
    +   * @param {object} data - Params of the channel.
    +   * @param {string} data.name - The name of the channel.
    +   * @param {string} data.chatable_id - The category of the channel.
    +   * @param {string} data.description - The description of the channel.
    +   * @param {boolean} [data.auto_join_users] - Should users join this channel automatically.
    +   * @returns {Promise}
    +   *
    +   * @example
    +   *
    +   *    this.chatApi
    +   *      .createChannel({ name: "foo", chatable_id: 1, description "bar" })
    +   *      .then((channel) => { ... })
    +   */
    +  createChannel(data = {}) {
    +    return this.#postRequest("/channels", { channel: data }).then((response) =>
    +      this.chatChannelsManager.store(response.channel)
    +    );
    +  }
    +
    +  /**
    +   * Lists chat permissions for a category.
    +   * @param {number} categoryId - ID of the category.
    +   * @returns {Promise}
    +   */
    +  categoryPermissions(categoryId) {
    +    return this.#getRequest(`/category-chatables/${categoryId}/permissions`);
    +  }
    +
    +  /**
    +   * Sends a message.
    +   * @param {number} channelId - ID of the channel.
    +   * @param {object} data - Params of the message.
    +   * @param {string} data.message - The raw content of the message in markdown.
    +   * @param {string} data.cooked - The cooked content of the message.
    +   * @param {number} [data.in_reply_to_id] - The ID of the replied-to message.
    +   * @param {number} [data.staged_id] - The staged ID of the message before it was persisted.
    +   * @param {Array.<number>} [data.upload_ids] - Array of upload ids linked to the message.
    +   * @returns {Promise}
    +   */
    +  sendMessage(channelId, data = {}) {
    +    return ajax(`/chat/${channelId}`, {
    +      ignoreUnsent: false,
    +      type: "POST",
    +      data,
    +    });
    +  }
    +
    +  /**
    +   * Creates a channel archive.
    +   * @param {number} channelId - The ID of the channel.
    +   * @param {object} data - Params of the archive.
    +   * @param {string} data.selection - "new_topic" or "existing_topic".
    +   * @param {string} [data.title] - Title of the topic when creating a new topic.
    +   * @param {string} [data.category_id] - ID of the category used when creating a new topic.
    +   * @param {Array.<string>} [data.tags] - tags used when creating a new topic.
    +   * @param {string} [data.topic_id] - ID of the topic when using an existing topic.
    +   * @returns {Promise}
    +   */
    +  createChannelArchive(channelId, data = {}) {
    +    return this.#postRequest(`/channels/${channelId}/archives`, {
    +      archive: data,
    +    });
    +  }
    +
    +  /**
    +   * Updates a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @param {object} data - Params of the archive.
    +   * @param {string} [data.description] - Description of the channel.
    +   * @param {string} [data.name] - Name of the channel.
    +   * @returns {Promise}
    +   */
    +  updateChannel(channelId, data = {}) {
    +    return this.#putRequest(`/channels/${channelId}`, { channel: data });
    +  }
    +
    +  /**
    +   * Updates the status of a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @param {string} status - The new status, can be "open" or "closed".
    +   * @returns {Promise}
    +   */
    +  updateChannelStatus(channelId, status) {
    +    return this.#putRequest(`/channels/${channelId}/status`, { status });
    +  }
    +
    +  /**
    +   * Lists members of a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @returns {Collection}
    +   */
    +  listChannelMemberships(channelId) {
    +    return new Collection(
    +      `${this.#basePath}/channels/${channelId}/memberships`,
    +      (response) => {
    +        return response.memberships.map((membership) =>
    +          UserChatChannelMembership.create(membership)
    +        );
    +      }
    +    );
    +  }
    +
    +  /**
    +   * Lists public and direct message channels of the current user.
    +   * @returns {Promise}
    +   */
    +  listCurrentUserChannels() {
    +    return this.#getRequest("/channels/me").then((result) => {
    +      return (result?.channels || []).map((channel) =>
    +        this.chatChannelsManager.store(channel)
    +      );
    +    });
    +  }
    +
    +  /**
    +   * Makes current user follow a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @returns {Promise}
    +   */
    +  followChannel(channelId) {
    +    return this.#postRequest(`/channels/${channelId}/memberships/me`).then(
    +      (result) => UserChatChannelMembership.create(result.membership)
    +    );
    +  }
    +
    +  /**
    +   * Makes current user unfollow a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @returns {Promise}
    +   */
    +  unfollowChannel(channelId) {
    +    return this.#deleteRequest(`/channels/${channelId}/memberships/me`).then(
    +      (result) => UserChatChannelMembership.create(result.membership)
    +    );
    +  }
    +
    +  /**
    +   * Update notifications settings of current user for a channel.
    +   * @param {number} channelId - The ID of the channel.
    +   * @param {object} data - The settings to modify.
    +   * @param {boolean} [data.muted] - Mutes the channel.
    +   * @param {string} [data.desktop_notification_level] - Notifications level on desktop: never, mention or always.
    +   * @param {string} [data.mobile_notification_level] - Notifications level on mobile: never, mention or always.
    +   * @returns {Promise}
    +   */
    +  updateCurrentUserChannelNotificationsSettings(channelId, data = {}) {
    +    return this.#putRequest(
    +      `/channels/${channelId}/notifications-settings/me`,
    +      { notifications_settings: data }
    +    );
    +  }
    +
    +  get #basePath() {
    +    return "/chat/api";
    +  }
    +
    +  #getRequest(endpoint, data = {}) {
    +    return ajax(`${this.#basePath}${endpoint}`, {
    +      type: "GET",
    +      data,
    +    });
    +  }
    +
    +  #putRequest(endpoint, data = {}) {
    +    return ajax(`${this.#basePath}${endpoint}`, {
    +      type: "PUT",
    +      data,
    +    });
    +  }
    +
    +  #postRequest(endpoint, data = {}) {
    +    return ajax(`${this.#basePath}${endpoint}`, {
    +      type: "POST",
    +      data,
    +    });
    +  }
    +
    +  #deleteRequest(endpoint, data = {}) {
    +    return ajax(`${this.#basePath}${endpoint}`, {
    +      type: "DELETE",
    +      data,
    +    });
    +  }
    +}
    +
    +
    +
    + + + + +
    +
    + + + + + + + + diff --git a/documentation/chat/frontend/styles/styles.css b/documentation/chat/frontend/styles/styles.css new file mode 100644 index 00000000000..159439a937d --- /dev/null +++ b/documentation/chat/frontend/styles/styles.css @@ -0,0 +1,498 @@ +:root { + --primary-color: #0664a8; + --secondary-color: #107e7d; + --link-color: var(--primary-color); + --link-hover-color: var(--primary-color); + --border-color: #eee; + --code-color: #666; + --code-attention-color: #ca2d00; + --text-color: #4a4a4a; + --light-font-color: #999; + --supporting-color: #7097b5; + --heading-color: var(--text-color); + --subheading-color: var(--secondary-color); + --heading-background: #f7f7f7; + --code-bg-color: #f8f8f8; + --nav-title-color: var(--primary-color); + --nav-title-align: center; + --nav-title-size: 1rem; + --nav-title-margin-bottom: 1.5em; + --nav-title-font-weight: 600; + --nav-list-margin-left: 2em; + --nav-bg-color: #fff; + --nav-heading-display: block; + --nav-heading-color: #aaa; + --nav-link-color: #666; + --nav-text-color: #aaa; + --nav-type-class-color: #fff; + --nav-type-class-bg: #FF8C00; + --nav-type-member-color: #39b739; + --nav-type-member-bg: #d5efd5; + --nav-type-function-color: #549ab9; + --nav-type-function-bg: #e1f6ff; + --nav-type-namespace-color: #eb6420; + --nav-type-namespace-bg: #fad8c7; + --nav-type-typedef-color: #964cb1; + --nav-type-typedef-bg: #f2e4f7; + --nav-type-module-color: #964cb1; + --nav-type-module-bg: #f2e4f7; + --nav-type-event-color: #948b34; + --nav-type-event-bg: #fff6a6; + --max-content-width: 900px; + --nav-width: 320px; + --padding-unit: 30px; + --layout-footer-color: #aaa; + --member-name-signature-display: none; + --base-font-size: 16px; + --base-line-height: 1.7; + --body-font: -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --code-font: Consolas, Monaco, "Andale Mono", monospace; +} + +body { + font-family: var(--body-font); + font-size: var(--base-font-size); + line-height: var(--base-line-height); + color: var(--text-color); + -webkit-font-smoothing: antialiased; + text-size-adjust: 100%; +} + +* { + box-sizing: border-box; +} + +a { + text-decoration: none; + color: var(--link-color); +} +a:hover, a:active { + text-decoration: underline; + color: var(--link-hover-color); +} + +img { + max-width: 100%; +} +img + p { + margin-top: 1em; +} + +ul { + margin: 1em 0; +} + +tt, code, kbd, samp { + font-family: var(--code-font); +} + +code { + display: inline-block; + background-color: var(--code-bg-color); + padding: 2px 6px 0px; + border-radius: 3px; + color: var(--code-attention-color); +} + +.prettyprint.source code:not([class*=language-]) { + display: block; + padding: 20px; + overflow: scroll; + color: var(--code-color); +} + +.layout-main, +.layout-footer { + margin-left: var(--nav-width); +} + +.container { + max-width: var(--max-content-width); + margin-left: auto; + margin-right: auto; +} + +.layout-main { + margin-top: var(--padding-unit); + margin-bottom: var(--padding-unit); + padding: 0 var(--padding-unit); +} + +.layout-header { + background: var(--nav-bg-color); + border-right: 1px solid var(--border-color); + position: fixed; + padding: 0 var(--padding-unit); + top: 0; + left: 0; + right: 0; + width: var(--nav-width); + height: 100%; + overflow: scroll; +} +.layout-header h1 { + display: block; + margin-bottom: var(--nav-title-margin-bottom); + font-size: var(--nav-title-size); + font-weight: var(--nav-title-font-weight); + text-align: var(--nav-title-align); +} +.layout-header h1 a:link, .layout-header h1 a:visited { + color: var(--nav-title-color); +} +.layout-header img { + max-width: 120px; + display: block; + margin: 1em auto; +} + +.layout-nav { + margin-bottom: 2rem; +} +.layout-nav ul { + margin: 0 0 var(--nav-list-margin-left); + padding: 0; +} +.layout-nav li { + list-style-type: none; + font-size: 0.95em; +} +.layout-nav li.nav-heading:first-child { + display: var(--nav-heading-display); + margin-left: 0; + margin-bottom: 1em; + text-transform: uppercase; + color: var(--nav-heading-color); + font-size: 0.85em; +} +.layout-nav a { + color: var(--nav-link-color); +} +.layout-nav a:link, .layout-nav a a:visited { + color: var(--nav-link-color); +} + +.layout-content--source { + max-width: none; +} + +.nav-heading { + margin-top: 1em; + font-weight: 500; +} +.nav-heading a { + color: var(--nav-link-color); +} +.nav-heading a:link, .nav-heading a:visited { + color: var(--nav-link-color); +} +.nav-heading .nav-item-type { + font-size: 0.9em; +} + +.nav-item-type { + display: inline-block; + font-size: 0.9em; + width: 1.2em; + height: 1.2em; + line-height: 1.2em; + display: inline-block; + text-align: center; + border-radius: 0.2em; + margin-right: 0.5em; +} +.nav-item-type.type-class { + color: var(--nav-type-class-color); + background: var(--nav-type-class-bg); +} +.nav-item-type.type-typedef { + color: var(--nav-type-typedef-color); + background: var(--nav-type-typedef-bg); +} +.nav-item-type.type-function { + color: var(--nav-type-function-color); + background: var(--nav-type-function-bg); +} +.nav-item-type.type-namespace { + color: var(--nav-type-namespace-color); + background: var(--nav-type-namespace-bg); +} +.nav-item-type.type-member { + color: var(--nav-type-member-color); + background: var(--nav-type-member-bg); +} +.nav-item-type.type-module { + color: var(--nav-type-module-color); + background: var(--nav-type-module-bg); +} +.nav-item-type.type-event { + color: var(--nav-type-event-color); + background: var(--nav-type-event-bg); +} + +.nav-item-name.is-function:after { + display: inline; + content: "()"; + color: var(--nav-link-color); + opacity: 0.75; +} +.nav-item-name.is-class { + font-size: 1.1em; +} + +.layout-footer { + padding-top: 2rem; + padding-bottom: 2rem; + font-size: 0.8em; + text-align: center; + color: var(--layout-footer-color); +} +.layout-footer a { + color: var(--light-font-color); + text-decoration: underline; +} + +h1 { + font-size: 2rem; + color: var(--heading-color); +} + +h5 { + margin: 0; + font-weight: 500; + font-size: 1em; +} +h5 + .code-caption { + margin-top: 1em; +} + +.page-kind { + margin: 0 0 -0.5em; + font-weight: 400; + color: var(--light-font-color); + text-transform: uppercase; +} + +.page-title { + margin-top: 0; +} + +.subtitle { + font-weight: 600; + font-size: 1.5em; + color: var(--subheading-color); + margin: 1em 0; + padding: 0.4em 0; + border-bottom: 1px solid var(--border-color); +} +.subtitle + .event, .subtitle + .member, .subtitle + .method { + border-top: none; + padding-top: 0; +} + +.method-type + .method-name { + margin-top: 0.5em; +} + +.event-name, +.member-name, +.method-name, +.type-definition-name { + margin: 1em 0; + font-size: 1.4rem; + font-family: var(--code-font); + font-weight: 600; + color: var(--primary-color); +} +.event-name .signature-attributes, +.member-name .signature-attributes, +.method-name .signature-attributes, +.type-definition-name .signature-attributes { + display: inline-block; + margin-left: 0.25em; + font-size: 60%; + color: #999; + font-style: italic; + font-weight: lighter; +} + +.type-signature { + display: inline-block; + margin-left: 0.5em; +} + +.member-name .type-signature { + display: var(--member-name-signature-display); +} + +.type-signature, +.return-type-signature { + color: #aaa; + font-weight: 400; +} +.type-signature a:link, .type-signature a:visited, +.return-type-signature a:link, +.return-type-signature a:visited { + color: #aaa; +} + +table { + margin-top: 1rem; + width: auto; + min-width: 400px; + max-width: 100%; + border-top: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); +} +table th, table h4 { + font-weight: 500; +} +table th, +table td { + padding: 0.5rem 0.75rem; +} +table th, +table td { + border-left: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); +} +table p:last-child { + margin-bottom: 0; +} + +.readme h2 { + border-bottom: 1px solid var(--border-color); + margin: 1em 0; + padding-bottom: 0.5rem; + color: var(--subheading-color); +} +.readme h2 + h3 { + margin-top: 0; +} +.readme h3 { + margin: 2rem 0 1rem 0; +} + +article.event, article.member, article.method { + padding: 1em 0 1em; + margin: 1em 0; + border-top: 1px solid var(--border-color); +} + +.method-type-signature:not(:empty) { + display: inline-block; + background: #ecf0f1; + color: #627475; + padding: 0.25em 0.5em 0.35em; + font-weight: 300; + font-size: 0.8rem; + margin: 0 0.75em 0 0; +} + +.method-heading { + margin: 1em 0; +} + +li.method-returns, +.method-params li { + margin-bottom: 1em; +} + +.method-source a:link, .method-source a:visited { + color: var(--light-font-color); +} + +.method-returns p { + margin: 0; +} + +.event-description, +.method-description { + margin: 0 0 2em; +} + +.param-type code, +.method-returns code { + color: #111; +} + +.param-name { + font-weight: 600; + display: inline-block; + margin-right: 0.5em; +} + +.param-type, +.param-default, +.param-attributes { + font-family: var(--code-font); +} + +.param-default::before { + display: inline-block; + content: "Default:"; + font-family: var(--body-font); +} + +.param-attributes { + color: var(--light-font-color); +} + +.param-description p:first-child { + margin-top: 0; +} + +.param-properties { + font-weight: 500; + margin: 1em 0 0; +} + +.param-types, +.property-types { + display: inline-block; + margin: 0 0.5em 0 0.25em; + color: #999; +} + +.param-attr, +.property-attr { + display: inline-block; + padding: 0.2em 0.5em; + border: 1px solid #eee; + color: #aaa; + font-weight: 300; + font-size: 0.8em; + vertical-align: baseline; +} + +.properties-table p:last-child { + margin-bottom: 0; +} + +pre[class*=language-] { + border-radius: 0; +} + +code[class*=language-], +pre[class*=language-] { + text-shadow: none; + border: none; +} +code[class*=language-].source-page, +pre[class*=language-].source-page { + font-size: 0.9em; +} + +.line-numbers .line-numbers-rows { + border-right: none; +} + +.source-page { + font-size: 14px; +} +.source-page code { + z-index: 1; +} +.source-page .line-height.temporary { + z-index: 0; +} \ No newline at end of file diff --git a/documentation/chat/frontend/styles/vendor/prism-custom.css b/documentation/chat/frontend/styles/vendor/prism-custom.css new file mode 100644 index 00000000000..09d20634024 --- /dev/null +++ b/documentation/chat/frontend/styles/vendor/prism-custom.css @@ -0,0 +1,142 @@ +/* PrismJS 1.17.1 +https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+http */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ + +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} + +pre[class*="language-"]::selection, pre[class*="language-"] ::selection, +code[class*="language-"]::selection, code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} + +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f6f8fa; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} + +.token.punctuation { + color: #999; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #9a6e3a; + background: hsla(0, 0%, 100%, .5); +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} + +.token.function, +.token.class-name { + color: #DD4A68; +} + +.token.regex, +.token.important, +.token.variable { + color: #e90; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + diff --git a/documentation/index.html b/documentation/index.html new file mode 100644 index 00000000000..83efddc8df2 --- /dev/null +++ b/documentation/index.html @@ -0,0 +1,125 @@ + + + + + + + + + + + Discourse documentation | Discourse - Civilized Discussion + + + + + + +
    +
    +

    Discourse projects documentation

    +
    +
    + +
    +
    + +
    +
    + + diff --git a/documentation/yard-custom-template/default/fulldoc/html/setup.rb b/documentation/yard-custom-template/default/fulldoc/html/setup.rb new file mode 100644 index 00000000000..e536537a490 --- /dev/null +++ b/documentation/yard-custom-template/default/fulldoc/html/setup.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Order was not deterministic for identic method names defined with @!method +# so we sort the list on path instead +def generate_method_list + @items = + prune_method_listing(Registry.all(:method), false) + .reject { |m| m.name.to_s =~ /=$/ && m.is_attribute? } + .sort_by { |m| m.path } + @list_title = "Method List" + @list_type = "method" + generate_list_contents +end diff --git a/documentation/yard-custom-template/default/layout/html/footer.erb b/documentation/yard-custom-template/default/layout/html/footer.erb new file mode 100644 index 00000000000..a38e33bcfdb --- /dev/null +++ b/documentation/yard-custom-template/default/layout/html/footer.erb @@ -0,0 +1,6 @@ +<%# Removes date and ruby version to avoid differences in CI check %> + diff --git a/documentation/yard-custom-template/default/method_details/setup.rb b/documentation/yard-custom-template/default/method_details/setup.rb new file mode 100644 index 00000000000..551e7fcbf33 --- /dev/null +++ b/documentation/yard-custom-template/default/method_details/setup.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +def source +end diff --git a/jsconfig.base.json b/jsconfig.base.json index d85d3dfec34..7b50cf30ba6 100644 --- a/jsconfig.base.json +++ b/jsconfig.base.json @@ -9,4 +9,10 @@ "**/node_modules", "**/dist", ], + "glint": { + "environment": [ + "ember-loose", + "ember-template-imports" + ] + } } diff --git a/lefthook.yml b/lefthook.yml index be90ea676c1..d7ba1648551 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -11,6 +11,9 @@ pre-commit: rubocop: glob: "*.rb" run: bundle exec rubocop --parallel --force-exclusion {staged_files} + syntax_tree: + glob: "*.{rb,rake}" + run: bundle exec stree check Gemfile {staged_files} prettier: glob: "*.js" include: "app/assets/javascripts|plugins/.+?/assets/javascripts" diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index 49564110bfc..d8569ed0342 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -33,24 +33,13 @@ class AdminUserIndexQuery find_users_query.count end - def custom_direction - if params[:ascending] - Discourse.deprecate( - ":ascending is deprecated please use :asc instead", - output_in_test: true, - drop_from: "2.9.0", - ) - end - asc = params[:asc] || params[:ascending] - asc.present? && asc ? "ASC" : "DESC" - end - def initialize_query_with_order(klass) order = [] custom_order = params[:order] + custom_direction = params[:asc].present? ? "ASC" : "DESC" if custom_order.present? && - without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)$/, "")] + without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)\z/, "")] order << "#{without_dir} #{custom_direction}" end diff --git a/lib/age_words.rb b/lib/age_words.rb index c9bfbfec281..0f6504bcc12 100644 --- a/lib/age_words.rb +++ b/lib/age_words.rb @@ -6,7 +6,91 @@ module AgeWords "—" else now = Time.now - FreedomPatches::Rails4.distance_of_time_in_words(now, now + secs) + distance_of_time_in_words(now, now + secs) end end + + # Sam: This has now forked of rails. Trouble is we would never like to use "about 1 month" ever, we only want months for 2 or more months. + # Backporting a fix to rails itself may get too complex + def self.distance_of_time_in_words(from_time, to_time = 0, include_seconds = false, options = {}) + options = { scope: :"datetime.distance_in_words" }.merge!(options) + + from_time = from_time.to_time if from_time.respond_to?(:to_time) + to_time = to_time.to_time if to_time.respond_to?(:to_time) + distance = (to_time.to_f - from_time.to_f).abs + distance_in_minutes = (distance / 60.0).round + distance_in_seconds = distance.round + + I18n.with_options locale: options[:locale], scope: options[:scope] do |locale| + case distance_in_minutes + when 0..1 + unless include_seconds + return( + ( + if distance_in_minutes == 0 + locale.t(:less_than_x_minutes, count: 1) + else + locale.t(:x_minutes, count: distance_in_minutes) + end + ) + ) + end + + case distance_in_seconds + when 0..4 + locale.t :less_than_x_seconds, count: 5 + when 5..9 + locale.t :less_than_x_seconds, count: 10 + when 10..19 + locale.t :less_than_x_seconds, count: 20 + when 20..39 + locale.t :half_a_minute + when 40..59 + locale.t :less_than_x_minutes, count: 1 + else + locale.t :x_minutes, count: 1 + end + when 2..44 + locale.t :x_minutes, count: distance_in_minutes + when 45..89 + locale.t :about_x_hours, count: 1 + when 90..1439 + locale.t :about_x_hours, count: (distance_in_minutes.to_f / 60.0).round + when 1440..2519 + locale.t :x_days, count: 1 + + # this is were we diverge from Rails + when 2520..129_599 + locale.t :x_days, count: (distance_in_minutes.to_f / 1440.0).round + when 129_600..525_599 + locale.t :x_months, count: (distance_in_minutes.to_f / 43200.0).round + else + fyear = from_time.year + fyear += 1 if from_time.month >= 3 + tyear = to_time.year + tyear -= 1 if to_time.month < 3 + leap_years = (fyear > tyear) ? 0 : (fyear..tyear).count { |x| Date.leap?(x) } + minute_offset_for_leap_year = leap_years * 1440 + # Discount the leap year days when calculating year distance. + # e.g. if there are 20 leap year days between 2 dates having the same day + # and month then the based on 365 days calculation + # the distance in years will come out to over 80 years when in written + # english it would read better as about 80 years. + minutes_with_offset = distance_in_minutes - minute_offset_for_leap_year + remainder = (minutes_with_offset % 525_600) + distance_in_years = (minutes_with_offset / 525_600) + if remainder < 131_400 + locale.t(:about_x_years, count: distance_in_years) + elsif remainder < 394_200 + locale.t(:over_x_years, count: distance_in_years) + else + locale.t(:almost_x_years, count: distance_in_years + 1) + end + end + end + end + + def self.time_ago_in_words(from_time, include_seconds = false, options = {}) + distance_of_time_in_words(from_time, Time.now, include_seconds, options) + end end diff --git a/lib/autospec/manager.rb b/lib/autospec/manager.rb index 87dded1963c..fba758be265 100644 --- a/lib/autospec/manager.rb +++ b/lib/autospec/manager.rb @@ -153,7 +153,7 @@ class Autospec::Manager filename, _ = failed_specs[0].split(":") if filename && File.exist?(filename) && !File.directory?(filename) spec = File.read(filename) - start, _ = spec.split(/\S*#focus\S*$/) + start, _ = spec.split(/\S*#focus\S*\z/) if start.length < spec.length line = start.scan(/\n/).length + 1 puts "Found #focus tag on line #{line}!" @@ -194,7 +194,7 @@ class Autospec::Manager def listen_for_changes puts "@@@@@@@@@@@@ listen_for_changes" if @debug - options = { ignore: %r{^lib/autospec} } + options = { ignore: %r{\Alib/autospec} } if @opts[:force_polling] options[:force_polling] = true @@ -216,7 +216,7 @@ class Autospec::Manager # process_change can acquire a mutex and block # the acceptor Thread.new do - if file =~ /(es6|js)$/ + if file =~ /(es6|js)\z/ process_change([[file]]) else process_change([[file, line]]) diff --git a/lib/autospec/reload_css.rb b/lib/autospec/reload_css.rb index 78258bed17f..f8fb530aa8e 100644 --- a/lib/autospec/reload_css.rb +++ b/lib/autospec/reload_css.rb @@ -10,11 +10,11 @@ class Autospec::ReloadCss end # css, scss, sass or handlebars - watch(/\.css$/) - watch(/\.ca?ss\.erb$/) - watch(/\.s[ac]ss$/) - watch(/\.hbs$/) - watch(/\.hbr$/) + watch(/\.css\z/) + watch(/\.ca?ss\.erb\z/) + watch(/\.s[ac]ss\z/) + watch(/\.hbs\z/) + watch(/\.hbr\z/) def self.message_bus MessageBus::Instance.new.tap do |bus| @@ -44,7 +44,7 @@ class Autospec::ReloadCss p = p.sub(/\.sass\.erb/, "") p = p.sub(/\.sass/, "") p = p.sub(/\.scss/, "") - p = p.sub(%r{^app/assets/stylesheets}, "assets") + p = p.sub(%r{\Aapp/assets/stylesheets}, "assets") { name: p, hash: hash || SecureRandom.hex } end message_bus.publish "/file-change", paths diff --git a/lib/autospec/rspec_runner.rb b/lib/autospec/rspec_runner.rb index 408b6fe79e0..e5efa38e2ea 100644 --- a/lib/autospec/rspec_runner.rb +++ b/lib/autospec/rspec_runner.rb @@ -11,29 +11,31 @@ module Autospec end # Discourse specific - watch(%r{^lib/(.+)\.rb$}) { |m| "spec/components/#{m[1]}_spec.rb" } + watch(%r{\Alib/(.+)\.rb\z}) { |m| "spec/components/#{m[1]}_spec.rb" } - watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } - watch(%r{^app/(.+)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } - watch(%r{^spec/.+_spec\.rb$}) - watch(%r{^spec/support/.+\.rb$}) { "spec" } + watch(%r{\Aapp/(.+)\.rb\z}) { |m| "spec/#{m[1]}_spec.rb" } + watch(%r{\Aapp/(.+)(\.erb|\.haml)\z}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } + watch(%r{\Aspec/.+_spec\.rb\z}) + watch(%r{\Aspec/support/.+\.rb\z}) { "spec" } watch("app/controllers/application_controller.rb") { "spec/requests" } watch(%r{app/controllers/(.+).rb}) { |m| "spec/requests/#{m[1]}_spec.rb" } - watch(%r{^app/views/(.+)/.+\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" } + watch(%r{\Aapp/views/(.+)/.+\.(erb|haml)\z}) { |m| "spec/requests/#{m[1]}_spec.rb" } - watch(%r{^spec/fabricators/.+_fabricator\.rb$}) { "spec" } + watch(%r{\Aspec/fabricators/.+_fabricator\.rb\z}) { "spec" } - watch(%r{^app/assets/javascripts/pretty-text/.*\.js\.es6$}) do + watch(%r{\Aapp/assets/javascripts/pretty-text/.*\.js\.es6\z}) do + "spec/components/pretty_text_spec.rb" + end + watch(%r{\Aplugins/.*/discourse-markdown/.*\.js\.es6\z}) do "spec/components/pretty_text_spec.rb" end - watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" } - watch(%r{^plugins/.*/spec/.*\.rb}) - watch(%r{^(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" } - watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" } - watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" } + watch(%r{\Aplugins/.*/spec/.*\.rb}) + watch(%r{\A(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" } + watch(%r{\A(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" } + watch(%r{\A(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" } RELOADERS = Set.new def self.reload(pattern) diff --git a/lib/autospec/simple_runner.rb b/lib/autospec/simple_runner.rb index dcf88e44434..09f813f877c 100644 --- a/lib/autospec/simple_runner.rb +++ b/lib/autospec/simple_runner.rb @@ -29,7 +29,7 @@ module Autospec # launch rspec Dir.chdir(Rails.root) do # rubocop:disable Discourse/NoChdir because this is not part of the app env = { "RAILS_ENV" => "test" } - if specs.split(" ").any? { |s| s =~ %r{^(./)?plugins} } + if specs.split(" ").any? { |s| s =~ %r{\A(./)?plugins} } env["LOAD_PLUGINS"] = "1" puts "Loading plugins while running specs" end diff --git a/lib/backup_restore/backup_file_handler.rb b/lib/backup_restore/backup_file_handler.rb index 645fad9f64d..19d79f20e99 100644 --- a/lib/backup_restore/backup_file_handler.rb +++ b/lib/backup_restore/backup_file_handler.rb @@ -11,7 +11,7 @@ module BackupRestore @filename = filename @current_db = current_db @root_tmp_directory = root_tmp_directory - @is_archive = !(@filename =~ /\.sql\.gz$/) + @is_archive = !(@filename =~ /\.sql\.gz\z/) @store_location = location end diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index 45780ce9bac..a0b6c923afa 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -11,7 +11,7 @@ module BackupRestore @user_id = user_id @client_id = opts[:client_id] @publish_to_message_bus = opts[:publish_to_message_bus] || false - @with_uploads = opts[:with_uploads].nil? ? include_uploads? : opts[:with_uploads] + @with_uploads = opts[:with_uploads] == false ? false : include_uploads? @filename_override = opts[:filename] @ticket = opts[:ticket] diff --git a/lib/backup_restore/database_restorer.rb b/lib/backup_restore/database_restorer.rb index 2a3bc6985c7..e913ba99324 100644 --- a/lib/backup_restore/database_restorer.rb +++ b/lib/backup_restore/database_restorer.rb @@ -164,7 +164,7 @@ module BackupRestore DatabaseRestorer.core_migration_files.each do |path| require path - class_name = File.basename(path, ".rb").sub(/^\d+_/, "").camelize + class_name = File.basename(path, ".rb").sub(/\A\d+_/, "").camelize migration_class = class_name.constantize if migration_class.const_defined?(:DROPPED_TABLES) diff --git a/lib/backup_restore/s3_backup_store.rb b/lib/backup_restore/s3_backup_store.rb index 10e2798642a..4949ebf7179 100644 --- a/lib/backup_restore/s3_backup_store.rb +++ b/lib/backup_restore/s3_backup_store.rb @@ -80,7 +80,7 @@ module BackupRestore expires_in: expires_in, opts: { metadata: metadata, - acl: "private", + acl: SiteSetting.s3_use_acls ? "private" : nil, }, ) end @@ -115,7 +115,7 @@ module BackupRestore existing_external_upload_key, File.join(s3_helper.s3_bucket_folder_path, original_filename), options: { - acl: "private", + acl: SiteSetting.s3_use_acls ? "private" : nil, apply_metadata_to_destination: true, }, ) @@ -173,7 +173,7 @@ module BackupRestore path = Regexp.quote(path) end - %r{^#{path}[^/]*\.t?gz$}i + %r{\A#{path}[^/]*\.t?gz\z}i end end diff --git a/lib/badge_queries.rb b/lib/badge_queries.rb index 4eda8098739..1be3b1a8085 100644 --- a/lib/badge_queries.rb +++ b/lib/badge_queries.rb @@ -160,11 +160,15 @@ module BadgeQueries FROM invites i JOIN invited_users iu ON iu.invite_id = i.id JOIN users u2 ON u2.id = iu.user_id - WHERE i.deleted_at IS NULL AND u2.active AND u2.trust_level >= #{trust_level.to_i} AND u2.silenced_till IS NULL + WHERE i.deleted_at IS NULL + AND i.invited_by_id <> u2.id + AND u2.active + AND u2.trust_level >= #{trust_level.to_i} + AND u2.silenced_till IS NULL GROUP BY invited_by_id HAVING COUNT(*) >= #{count.to_i} ) AND u.active AND u.silenced_till IS NULL AND u.id > 0 AND - (:backfill OR u.id IN (:user_ids) ) + (:backfill OR u.id IN (:user_ids) ) SQL end @@ -271,4 +275,32 @@ module BadgeQueries WHERE "rank" = 1 SQL end + + def self.anniversaries(start_date, end_date) + start_date = start_date.iso8601(6) + end_date = end_date.iso8601(6) + + <<~SQL + SELECT u.id + FROM users AS u + JOIN posts AS p ON p.user_id = u.id + JOIN topics AS t ON p.topic_id = t.id + WHERE u.id > 0 + AND u.active + AND NOT u.staged + AND (u.silenced_till IS NULL OR u.silenced_till < '#{start_date}') + AND (u.suspended_till IS NULL OR u.suspended_till < '#{start_date}') + AND u.created_at <= '#{start_date}' + AND NOT p.hidden + AND p.deleted_at IS NULL + AND p.created_at BETWEEN '#{start_date}' AND '#{end_date}' + AND t.visible + AND t.archetype <> 'private_message' + AND t.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM user_badges AS ub WHERE ub.user_id = u.id AND ub.badge_id = #{Badge::Anniversary} AND ub.granted_at BETWEEN '#{start_date}' AND '#{end_date}') + AND NOT EXISTS (SELECT 1 FROM anonymous_users AS au WHERE au.user_id = u.id) + GROUP BY u.id + HAVING COUNT(p.id) > 0 + SQL + end end diff --git a/lib/color_math.rb b/lib/color_math.rb new file mode 100644 index 00000000000..074b9cc4da9 --- /dev/null +++ b/lib/color_math.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module ColorMath + # Equivalent to dc-color-brightness() in variables.scss + def self.brightness(color) + rgb = Converters.hex_to_rgb(color) + (rgb[0].to_i * 299 + rgb[1].to_i * 587 + rgb[2].to_i * 114) / 1000.0 + end + + # Equivalent to dark-light-diff() in variables.scss + def self.dark_light_diff(adjusted_color, comparison_color, lightness, darkness) + if brightness(adjusted_color) < brightness(comparison_color) + scale_color_lightness(adjusted_color, lightness) + else + scale_color_lightness(adjusted_color, darkness) + end + end + + # Equivalent to scale_color(color, lightness: ) in sass + def self.scale_color_lightness(color, adjustment) + rgb = Converters.hex_to_rgb(color) + h, s, l = Converters.rgb_to_hsl(*rgb) + + l = + if adjustment > 0 + l + (100 - l) * adjustment + else + l + l * adjustment + end + + rgb = Converters.hsl_to_rgb(h, s, l) + Converters.rgb_to_hex(rgb) + end + + module Converters + # Adapted from https://github.com/anilyanduri/color_math + # + # The MIT License (MIT) + # + # Copyright (c) 2016 Anil Yanduri + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + + def self.hex_to_rgb(color) + color = color.gsub(/(.)/, '\1\1') if color.length == 3 + raise new RuntimeError("Hex color must be 6 characters") if color.length != 6 + color.scan(/../).map { |c| c.to_i(16) } + end + + def self.rgb_to_hex(rgb) + rgb.map { |c| c.to_s(16).rjust(2, "0") }.join("") + end + + def self.rgb_to_hsl(r, g, b) + r /= 255.0 + g /= 255.0 + b /= 255.0 + max = [r, g, b].max + min = [r, g, b].min + h = (max + min) / 2.0 + s = (max + min) / 2.0 + l = (max + min) / 2.0 + + if (max == min) + h = 0 + s = 0 # achromatic + else + d = max - min + s = l >= 0.5 ? d / (2.0 - max - min) : d / (max + min) + case max + when r + h = (g - b) / d + (g < b ? 6.0 : 0) + when g + h = (b - r) / d + 2.0 + when b + h = (r - g) / d + 4.0 + end + h /= 6.0 + end + [(h * 360).round, (s * 100).round, (l * 100).round] + end + + def self.hsl_to_rgb(h, s, l) + h = h / 360.0 + s = s / 100.0 + l = l / 100.0 + + r = 0.0 + g = 0.0 + b = 0.0 + + if (s == 0.0) + r = l.to_f + g = l.to_f + b = l.to_f #achromatic + else + q = l < 0.5 ? l * (1 + s) : l + s - l * s + p = 2 * l - q + r = hue_to_rgb(p, q, h + 1 / 3.0) + g = hue_to_rgb(p, q, h) + b = hue_to_rgb(p, q, h - 1 / 3.0) + end + + [(r * 255).round, (g * 255).round, (b * 255).round] + end + + def self.hue_to_rgb(p, q, t) + t += 1 if (t < 0) + t -= 1 if (t > 1) + return(p + (q - p) * 6 * t) if (t < 1 / 6.0) + return q if (t < 1 / 2.0) + return(p + (q - p) * (2 / 3.0 - t) * 6) if (t < 2 / 3.0) + p + end + end +end diff --git a/lib/common_passwords.rb b/lib/common_passwords.rb index 5b4aec28e85..cd676ab78e9 100644 --- a/lib/common_passwords.rb +++ b/lib/common_passwords.rb @@ -31,7 +31,7 @@ class CommonPasswords end def self.password_list - @mutex.synchronize { load_passwords unless redis.scard(LIST_KEY) > 0 } + @mutex.synchronize { load_passwords if redis.scard(LIST_KEY) <= 0 } RedisPasswordList.new end diff --git a/lib/composer_messages_finder.rb b/lib/composer_messages_finder.rb index 878727f20c9..a74820cc078 100644 --- a/lib/composer_messages_finder.rb +++ b/lib/composer_messages_finder.rb @@ -8,7 +8,7 @@ class ComposerMessagesFinder end def self.check_methods - @check_methods ||= instance_methods.find_all { |m| m =~ /^check\_/ } + @check_methods ||= instance_methods.find_all { |m| m =~ /\Acheck\_/ } end def find @@ -197,8 +197,8 @@ class ComposerMessagesFinder .pluck(:reply_to_user_id) .find_all { |uid| uid != @user.id && uid == reply_to_user_id } - return unless last_x_replies.size == SiteSetting.get_a_room_threshold - return unless @topic.posts.count("distinct user_id") >= min_users_posted + return if last_x_replies.size != SiteSetting.get_a_room_threshold + return if @topic.posts.count("distinct user_id") < min_users_posted UserHistory.create!( action: UserHistory.actions[:notified_about_get_a_room], @@ -206,7 +206,7 @@ class ComposerMessagesFinder topic_id: @details[:topic_id], ) - reply_username = User.where(id: last_x_replies[0]).pluck_first(:username) + reply_username = User.where(id: last_x_replies[0]).pick(:username) { id: "get_a_room", @@ -226,6 +226,33 @@ class ComposerMessagesFinder } end + def check_dont_feed_the_trolls + return if !replying? + + post = + if @details[:post_id] + Post.find_by(id: @details[:post_id]) + else + @topic&.first_post + end + + return if post.blank? + + flags = post.flags.active.group(:user_id).count + flagged_by_replier = flags[@user.id].to_i > 0 + flagged_by_others = flags.values.sum >= SiteSetting.dont_feed_the_trolls_threshold + + return if !flagged_by_replier && !flagged_by_others + + { + id: "dont_feed_the_trolls", + templateName: "education", + wait_for_typing: false, + extraClass: "urgent", + body: PrettyText.cook(I18n.t("education.dont_feed_the_trolls")), + } + end + def check_reviving_old_topic return unless replying? if @topic.nil? || SiteSetting.warn_reviving_old_topic_age < 1 || @topic.last_posted_at.nil? || @@ -243,7 +270,7 @@ class ComposerMessagesFinder I18n.t( "education.reviving_old_topic", time_ago: - FreedomPatches::Rails4.time_ago_in_words( + AgeWords.time_ago_in_words( @topic.last_posted_at, false, scope: :"datetime.distance_in_words_verbose", diff --git a/lib/compression/gzip.rb b/lib/compression/gzip.rb index c668b088f72..f32836f957e 100644 --- a/lib/compression/gzip.rb +++ b/lib/compression/gzip.rb @@ -38,7 +38,7 @@ module Compression def build_entry_path(dest_path, _, compressed_file_path) basename = File.basename(compressed_file_path) - basename.gsub!(/#{Regexp.escape(extension)}$/, "") + basename.gsub!(/#{Regexp.escape(extension)}\z/, "") File.join(dest_path, basename) end diff --git a/lib/configurable_urls.rb b/lib/configurable_urls.rb index c196b74b12f..feb256ab162 100644 --- a/lib/configurable_urls.rb +++ b/lib/configurable_urls.rb @@ -5,15 +5,11 @@ module ConfigurableUrls SiteSetting.faq_url.blank? ? "#{Discourse.base_path}/faq" : SiteSetting.faq_url end - def tos_path - SiteSetting.tos_url.blank? ? "#{Discourse.base_path}/tos" : SiteSetting.tos_url + def tos_url + Discourse.tos_url end - def privacy_path - if SiteSetting.privacy_policy_url.blank? - "#{Discourse.base_path}/privacy" - else - SiteSetting.privacy_policy_url - end + def privacy_policy_url + Discourse.privacy_policy_url end end diff --git a/lib/content_buffer.rb b/lib/content_buffer.rb index 6d6895811f0..a1582cb55b5 100644 --- a/lib/content_buffer.rb +++ b/lib/content_buffer.rb @@ -48,7 +48,7 @@ class ContentBuffer @lines.insert(start_row + i, line) i += 1 end - @lines.insert(i, "") unless @lines.length > i + @lines.insert(i, "") if @lines.length <= i @lines[i] = split[-1] + @lines[i] end end diff --git a/lib/content_security_policy/extension.rb b/lib/content_security_policy/extension.rb index 150e0048622..6996e6747de 100644 --- a/lib/content_security_policy/extension.rb +++ b/lib/content_security_policy/extension.rb @@ -27,7 +27,7 @@ class ContentSecurityPolicy def theme_extensions(theme_id) key = "theme_extensions_#{theme_id}" - cache[key] ||= find_theme_extensions(theme_id) + cache.defer_get_set(key) { find_theme_extensions(theme_id) } end def clear_theme_extensions_cache! @@ -80,7 +80,7 @@ class ContentSecurityPolicy uri.query = nil # CSP should not include query part of url - uri_string = uri.to_s.sub(%r{^//}, "") # Protocol-less CSP should not have // at beginning of URL + uri_string = uri.to_s.sub(%r{\A//}, "") # Protocol-less CSP should not have // at beginning of URL auto_script_src_extension[:script_src] << uri_string rescue URI::Error diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb index 01463bb7761..910d5068d1e 100644 --- a/lib/cooked_post_processor.rb +++ b/lib/cooked_post_processor.rb @@ -26,7 +26,7 @@ class CookedPostProcessor @category_id = @post&.topic&.category_id cooked = post.cook(post.raw, @cooking_options) - @doc = Loofah.fragment(cooked) + @doc = Loofah.html5_fragment(cooked) @has_oneboxes = post.post_analyzer.found_oneboxes? @size_cache = {} @@ -209,7 +209,7 @@ class CookedPostProcessor return if upload.animated? - if img.ancestors(".onebox, .onebox-body, .quote").blank? && !img.classes.include?("onebox") + if img.ancestors(".onebox, .onebox-body").blank? && !img.classes.include?("onebox") add_lightbox!(img, original_width, original_height, upload, cropped: crop) end @@ -228,6 +228,7 @@ class CookedPostProcessor def optimize_image!(img, upload, cropped: false) w, h = img["width"].to_i, img["height"].to_i + onebox = img.ancestors(".onebox, .onebox-body").first # note: optimize_urls cooks the src further after this thumbnail = upload.thumbnail(w, h) @@ -236,21 +237,27 @@ class CookedPostProcessor srcset = +"" - each_responsive_ratio do |ratio| - resized_w = (w * ratio).to_i - resized_h = (h * ratio).to_i + # Skip srcset for onebox images. Because onebox thumbnails by default + # are fairly small the width/height of the smallest thumbnail is likely larger + # than what the onebox thumbnail size will be displayed at, so we shouldn't + # need to upscale for retina devices + if !onebox + each_responsive_ratio do |ratio| + resized_w = (w * ratio).to_i + resized_h = (h * ratio).to_i - if !cropped && upload.width && resized_w > upload.width - cooked_url = UrlHelper.cook_url(upload.url, secure: @post.with_secure_uploads?) - srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" - elsif t = upload.thumbnail(resized_w, resized_h) - cooked_url = UrlHelper.cook_url(t.url, secure: @post.with_secure_uploads?) - srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" + if !cropped && upload.width && resized_w > upload.width + cooked_url = UrlHelper.cook_url(upload.url, secure: @post.with_secure_uploads?) + srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0\z/, "")}x" + elsif t = upload.thumbnail(resized_w, resized_h) + cooked_url = UrlHelper.cook_url(t.url, secure: @post.with_secure_uploads?) + srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0\z/, "")}x" + end + + img[ + "srcset" + ] = "#{UrlHelper.cook_url(img["src"], secure: @post.with_secure_uploads?)}#{srcset}" if srcset.present? end - - img[ - "srcset" - ] = "#{UrlHelper.cook_url(img["src"], secure: @post.with_secure_uploads?)}#{srcset}" if srcset.present? end else img["src"] = upload.url @@ -295,7 +302,7 @@ class CookedPostProcessor def get_filename(upload, src) return File.basename(src) unless upload - return upload.original_filename unless upload.original_filename =~ /^blob(\.png)?$/i + return upload.original_filename unless upload.original_filename =~ /\Ablob(\.png)?\z/i I18n.t("upload.pasted_image_filename") end @@ -385,6 +392,8 @@ class CookedPostProcessor end def process_hotlinked_image(img) + onebox = img.ancestors(".onebox, .onebox-body").first + @hotlinked_map ||= @post.post_hotlinked_media.preload(:upload).map { |r| [r.url, r] }.to_h normalized_src = PostHotlinkedMedia.normalize_src(img["src"] || img[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR]) @@ -393,7 +402,7 @@ class CookedPostProcessor still_an_image = true if info&.too_large? - if img.ancestors(".onebox, .onebox-body").blank? + if !onebox || onebox.element_children.size == 1 add_large_image_placeholder!(img) else img.remove @@ -401,7 +410,7 @@ class CookedPostProcessor still_an_image = false elsif info&.download_failed? - if img.ancestors(".onebox, .onebox-body").blank? + if !onebox || onebox.element_children.size == 1 add_broken_image_placeholder!(img) else img.remove @@ -410,6 +419,7 @@ class CookedPostProcessor still_an_image = false elsif info&.downloaded? && upload = info&.upload img["src"] = UrlHelper.cook_url(upload.url, secure: @with_secure_uploads) + img["data-dominant-color"] = upload.dominant_color(calculate_if_missing: true).presence img.delete(PrettyText::BLOCKED_HOTLINKED_SRC_ATTR) end diff --git a/lib/cooked_processor_mixin.rb b/lib/cooked_processor_mixin.rb index a3c2f466918..1ad4d74853a 100644 --- a/lib/cooked_processor_mixin.rb +++ b/lib/cooked_processor_mixin.rb @@ -70,7 +70,7 @@ module CookedProcessorMixin found = false parent = img while parent = parent.parent - if parent["class"] && parent["class"].include?("allowlistedgeneric") + if parent["class"] && parent["class"].match?(/\b(allowlistedgeneric|discoursetopic)\b/) found = true break end @@ -135,7 +135,7 @@ module CookedProcessorMixin def get_size_from_attributes(img) w, h = img["width"].to_i, img["height"].to_i - return w, h unless w <= 0 || h <= 0 + return w, h if w > 0 && h > 0 # if only width or height are specified attempt to scale image if w > 0 || h > 0 w = w.to_f @@ -174,7 +174,7 @@ module CookedProcessorMixin return @size_cache[url] if @size_cache.has_key?(url) absolute_url = url - absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ %r{^/[^/]} + absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ %r{\A/[^/]} return unless absolute_url diff --git a/lib/discourse.rb b/lib/discourse.rb index 577ae781008..521b3ec2186 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -191,6 +191,18 @@ module Discourse reset_job_exception_stats! + if Rails.env.test? + def self.catch_job_exceptions! + raise "tests only" if !Rails.env.test? + @catch_job_exceptions = true + end + + def self.reset_catch_job_exceptions! + raise "tests only" if !Rails.env.test? + remove_instance_variable(:@catch_job_exceptions) + end + end + # Log an exception. # # If your code is in a scheduled job, it is recommended to use the @@ -220,7 +232,7 @@ module Discourse { current_db: cm.current_db, current_hostname: cm.current_hostname }.merge(context), ) - raise ex if Rails.env.test? + raise ex if Rails.env.test? && !@catch_job_exceptions end # Expected less matches than what we got in a find @@ -315,23 +327,17 @@ module Discourse @anonymous_top_menu_items ||= Discourse.anonymous_filters + %i[categories top] end + # list of pixel ratios Discourse tries to optimize for PIXEL_RATIOS ||= [1, 1.5, 2, 3] def self.avatar_sizes # TODO: should cache these when we get a notification system for site settings - set = Set.new - - SiteSetting - .avatar_sizes - .split("|") - .map(&:to_i) - .each { |size| PIXEL_RATIOS.each { |pixel_ratio| set << (size * pixel_ratio).to_i } } - - set + Set.new(SiteSetting.avatar_sizes.split("|").map(&:to_i)) end def self.activate_plugins! @plugins = [] + @plugins_by_name = {} Plugin::Instance .find_all("#{Rails.root}/plugins") .each do |p| @@ -339,6 +345,18 @@ module Discourse if Discourse.has_needed_version?(Discourse::VERSION::STRING, v) p.activate! @plugins << p + @plugins_by_name[p.name] = p + + # The plugin directory name and metadata name should match, but that + # is not always the case + dir_name = p.path.split("/")[-2] + if p.name != dir_name + STDERR.puts "Plugin name is '#{p.name}', but plugin directory is named '#{dir_name}'" + # Plugins are looked up by directory name in SiteSettingExtension + # because SiteSetting.load_settings uses directory name as plugin + # name. We alias the two names just to make sure the look up works + @plugins_by_name[dir_name] = p + end else STDERR.puts "Could not activate #{p.metadata.name}, discourse does not meet required version (#{v})" end @@ -346,20 +364,16 @@ module Discourse DiscourseEvent.trigger(:after_plugin_activation) end - def self.disabled_plugin_names - plugins.select { |p| !p.enabled? }.map(&:name) - end - def self.plugins @plugins ||= [] end - def self.hidden_plugins - @hidden_plugins ||= [] + def self.plugins_by_name + @plugins_by_name ||= {} end def self.visible_plugins - self.plugins - self.hidden_plugins + plugins.filter(&:visible?) end def self.plugin_themes @@ -417,6 +431,7 @@ module Discourse end end + assets.map! { |asset| "#{asset}_rtl" } if args[:rtl] assets end @@ -582,6 +597,48 @@ module Discourse alias_method :base_url_no_path, :base_url_no_prefix end + def self.urls_cache + @urls_cache ||= DistributedCache.new("urls_cache") + end + + def self.tos_url + if SiteSetting.tos_url.present? + SiteSetting.tos_url + else + urls_cache["tos"] ||= ( + if SiteSetting.tos_topic_id > 0 && Topic.exists?(id: SiteSetting.tos_topic_id) + "#{Discourse.base_path}/tos" + else + :nil + end + ) + + urls_cache["tos"] != :nil ? urls_cache["tos"] : nil + end + end + + def self.privacy_policy_url + if SiteSetting.privacy_policy_url.present? + SiteSetting.privacy_policy_url + else + urls_cache["privacy_policy"] ||= ( + if SiteSetting.privacy_topic_id > 0 && Topic.exists?(id: SiteSetting.privacy_topic_id) + "#{Discourse.base_path}/privacy" + else + :nil + end + ) + + urls_cache["privacy_policy"] != :nil ? urls_cache["privacy_policy"] : nil + end + end + + def self.clear_urls! + urls_cache.clear + end + + LAST_POSTGRES_READONLY_KEY = "postgres:last_readonly" + READONLY_MODE_KEY_TTL ||= 60 READONLY_MODE_KEY ||= "readonly_mode" PG_READONLY_MODE_KEY ||= "readonly_mode:postgres" @@ -589,7 +646,7 @@ module Discourse USER_READONLY_MODE_KEY ||= "readonly_mode:user" PG_FORCE_READONLY_MODE_KEY ||= "readonly_mode:postgres_force" - # Psuedo readonly mode, where staff can still write + # Pseudo readonly mode, where staff can still write STAFF_WRITES_ONLY_MODE_KEY ||= "readonly_mode:staff_writes_only" READONLY_KEYS ||= [ @@ -679,7 +736,7 @@ module Discourse end def self.readonly_mode?(keys = READONLY_KEYS) - recently_readonly? || Discourse.redis.exists?(*keys) + recently_readonly? || GlobalSetting.pg_force_readonly_mode || Discourse.redis.exists?(*keys) end def self.staff_writes_only_mode? @@ -692,7 +749,7 @@ module Discourse # Shared between processes def self.postgres_last_read_only - @postgres_last_read_only ||= DistributedCache.new("postgres_last_read_only", namespace: false) + @postgres_last_read_only ||= DistributedCache.new("postgres_last_read_only") end # Per-process @@ -700,20 +757,30 @@ module Discourse @redis_last_read_only ||= {} end + def self.postgres_recently_readonly? + seconds = + postgres_last_read_only.defer_get_set("timestamp") { redis.get(LAST_POSTGRES_READONLY_KEY) } + + seconds ? Time.zone.at(seconds.to_i) > 15.seconds.ago : false + end + def self.recently_readonly? - postgres_read_only = postgres_last_read_only[Discourse.redis.namespace] redis_read_only = redis_last_read_only[Discourse.redis.namespace] - (redis_read_only.present? && redis_read_only > 15.seconds.ago) || - (postgres_read_only.present? && postgres_read_only > 15.seconds.ago) + (redis_read_only.present? && redis_read_only > 15.seconds.ago) || postgres_recently_readonly? end def self.received_postgres_readonly! - postgres_last_read_only[Discourse.redis.namespace] = Time.zone.now + time = Time.zone.now + redis.set(LAST_POSTGRES_READONLY_KEY, time.to_i.to_s) + postgres_last_read_only.clear(after_commit: false) + + time end def self.clear_postgres_readonly! - postgres_last_read_only[Discourse.redis.namespace] = nil + redis.del(LAST_POSTGRES_READONLY_KEY) + postgres_last_read_only.clear(after_commit: false) end def self.received_redis_readonly! @@ -753,10 +820,8 @@ module Discourse def self.git_branch @git_branch ||= - begin - git_cmd = "git rev-parse --abbrev-ref HEAD" - self.try_git(git_cmd, "unknown") - end + self.try_git("git branch --show-current", nil) || + self.try_git("git config user.discourse-version", "unknown") end def self.full_version @@ -777,17 +842,11 @@ module Discourse end def self.try_git(git_cmd, default_value) - version_value = false - begin - version_value = `#{git_cmd}`.strip + `#{git_cmd}`.strip rescue StandardError - version_value = default_value - end - - version_value = default_value if version_value.empty? - - version_value + default_value + end.presence || default_value end # Either returns the site_contact_username user or the first admin. @@ -952,14 +1011,15 @@ module Discourse raise Deprecation.new(warning) if raise_error - STDERR.puts(warning) if Rails.env == "development" + STDERR.puts(warning) if Rails.env.development? - STDERR.puts(warning) if output_in_test && Rails.env == "test" + STDERR.puts(warning) if output_in_test && Rails.env.test? digest = Digest::MD5.hexdigest(warning) redis_key = "deprecate-notice-#{digest}" - if Rails.logger && !Discourse.redis.without_namespace.get(redis_key) + if !Rails.env.development? && Rails.logger && !GlobalSetting.skip_redis? && + !Discourse.redis.without_namespace.get(redis_key) Rails.logger.warn(warning) begin Discourse.redis.without_namespace.setex(redis_key, 3600, "x") @@ -1081,6 +1141,7 @@ module Discourse Discourse.git_version Discourse.git_branch Discourse.full_version + Discourse.plugins.each { |p| p.commit_url } end, Thread.new do require "actionview_precompiler" diff --git a/lib/discourse_connect_base.rb b/lib/discourse_connect_base.rb index b5e04d8f212..f39c2556fb6 100644 --- a/lib/discourse_connect_base.rb +++ b/lib/discourse_connect_base.rb @@ -99,7 +99,7 @@ class DiscourseConnectBase end decoded_hash.each do |k, v| - if field = k[/^custom\.(.+)$/, 1] + if field = k[/\Acustom\.(.+)\z/, 1] sso.custom_fields[field] = v end end diff --git a/lib/discourse_dev/email_log.rb b/lib/discourse_dev/email_log.rb new file mode 100644 index 00000000000..b7d94c27090 --- /dev/null +++ b/lib/discourse_dev/email_log.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class EmailLog < Record + def initialize + super(::EmailLog, DiscourseDev.config.email_logs[:count]) + end + + def create_sent! + ::EmailLog.create!(email_log_data) + end + + def create_bounced! + bounce_key = SecureRandom.hex + email_local_part, email_domain = SiteSetting.notification_email.split("@") + bounced_to_address = "#{email_local_part}+verp-#{bounce_key}@#{email_domain}" + bounce_data = + email_log_data.merge( + to_address: bounced_to_address, + bounced: true, + bounce_key: bounce_key, + bounce_error_code: "5.0.0", + ) + + # Bounced email logs require a matching incoming email record + ::IncomingEmail.create!( + incoming_email_data.merge(to_addresses: bounced_to_address, is_bounce: true), + ) + ::EmailLog.create!(bounce_data) + end + + def create_rejected! + ::IncomingEmail.create!(incoming_email_data) + end + + def email_log_data + { + to_address: User.random.email, + email_type: :digest, + user_id: User.random.id, + raw: Faker::Lorem.paragraph, + } + end + + def incoming_email_data + user = User.random + subject = Faker::Lorem.sentence + email_content = <<-EMAIL + Return-Path: #{user.email} + From: #{user.email} + Date: #{Date.today} + Mime-Version: "1.0" + Content-Type: "text/plain" + Content-Transfer-Encoding: "7bit" + + #{Faker::Lorem.paragraph} + EMAIL + + { + user_id: user.id, + from_address: user.email, + raw: email_content, + error: Faker::Lorem.sentence, + rejection_message: I18n.t("emails.incoming.errors.bounced_email_error"), + } + end + + def populate! + @count.times { create_sent! } + @count.times { create_bounced! } + @count.times { create_rejected! } + end + end +end diff --git a/lib/discourse_dev/record.rb b/lib/discourse_dev/record.rb index b1b84097df2..d01baa5fa3e 100644 --- a/lib/discourse_dev/record.rb +++ b/lib/discourse_dev/record.rb @@ -37,7 +37,7 @@ module DiscourseDev raise 'To run this rake task in a production site, set the value of `ALLOW_DEV_POPULATE` environment variable to "1"' end - unless ignore_current_count + unless ignore_current_count || @ignore_current_count if current_count >= @count puts "Already have #{current_count} #{type} records" @@ -68,8 +68,8 @@ module DiscourseDev model.count end - def self.populate!(*args) - self.new(*args).populate! + def self.populate!(**args) + self.new(**args).populate! end def self.random(model, use_existing_records: true) diff --git a/lib/discourse_diff.rb b/lib/discourse_diff.rb index e31a699f85a..16a27b67472 100644 --- a/lib/discourse_diff.rb +++ b/lib/discourse_diff.rb @@ -160,7 +160,7 @@ class DiscourseDiff while i < text.size if text[i] =~ /\w/ t << text[i] - elsif text[i] =~ /[ \t]/ && t.join =~ /^\w+$/ + elsif text[i] =~ /[ \t]/ && t.join =~ /\A\w+\z/ begin t << text[i] i += 1 diff --git a/lib/discourse_event.rb b/lib/discourse_event.rb index 43c85487651..64a467bd77c 100644 --- a/lib/discourse_event.rb +++ b/lib/discourse_event.rb @@ -13,14 +13,15 @@ class DiscourseEvent end def self.on(event_name, &block) - if event_name == :site_setting_saved + if event_name == :user_badge_removed Discourse.deprecate( - "The :site_setting_saved event is deprecated. Please use :site_setting_changed instead", - since: "2.3.0beta8", - drop_from: "2.4", - raise_error: true, + "The :user_badge_removed event is deprecated. Please use :user_badge_revoked instead", + since: "3.1.0.beta5", + drop_from: "3.2.0.beta1", + output_in_test: true, ) end + events[event_name] << block end diff --git a/lib/discourse_hub.rb b/lib/discourse_hub.rb index 4b5b36d4811..74f18dbcf42 100644 --- a/lib/discourse_hub.rb +++ b/lib/discourse_hub.rb @@ -5,7 +5,7 @@ module DiscourseHub def self.version_check_payload default_payload = { installed_version: Discourse::VERSION::STRING }.merge!( - Discourse.git_branch == "unknown" ? {} : { branch: Discourse.git_branch }, + Discourse.git_branch == "unknown" && !Rails.env.test? ? {} : { branch: Discourse.git_branch }, ) default_payload.merge!(get_payload) end diff --git a/lib/discourse_js_processor.rb b/lib/discourse_js_processor.rb index ae167ffe57f..d9c16de74f9 100644 --- a/lib/discourse_js_processor.rb +++ b/lib/discourse_js_processor.rb @@ -6,22 +6,14 @@ class DiscourseJsProcessor class TranspileError < StandardError end + # To generate a list of babel plugins used by ember-cli, set + # babel: { debug: true } in ember-cli-build.js, then run `yarn ember build -prod` DISCOURSE_COMMON_BABEL_PLUGINS = [ - "proposal-optional-chaining", ["proposal-decorators", { legacy: true }], - "transform-template-literals", "proposal-class-properties", - "proposal-class-static-block", - "proposal-private-property-in-object", "proposal-private-methods", - "proposal-numeric-separator", - "proposal-logical-assignment-operators", - "proposal-nullish-coalescing-operator", - "proposal-json-strings", - "proposal-optional-catch-binding", + "proposal-class-static-block", "transform-parameters", - "proposal-async-generator-functions", - "proposal-object-rest-spread", "proposal-export-namespace-from", ] @@ -168,6 +160,16 @@ class DiscourseJsProcessor "node_modules/babel-plugin-ember-template-compilation/src/expression-parser.js", wrap_in_module: "babel-plugin-ember-template-compilation/expression-parser", ) + load_file_in_context( + ctx, + "node_modules/babel-plugin-ember-template-compilation/src/js-utils.js", + wrap_in_module: "babel-plugin-ember-template-compilation/js-utils", + ) + load_file_in_context( + ctx, + "node_modules/babel-plugin-ember-template-compilation/src/public-types.js", + wrap_in_module: "babel-plugin-ember-template-compilation/public-types", + ) load_file_in_context( ctx, "node_modules/babel-import-util/src/index.js", @@ -180,7 +182,10 @@ class DiscourseJsProcessor ) # Widget HBS compiler - widget_hbs_compiler_source = File.read("#{Rails.root}/lib/javascripts/widget-hbs-compiler.js") + widget_hbs_compiler_source = + File.read( + "#{Rails.root}/app/assets/javascripts/discourse-widget-hbs/lib/widget-hbs-compiler.js", + ) widget_hbs_compiler_source = <<~JS define("widget-hbs-compiler", ["exports"], function(exports){ #{widget_hbs_compiler_source} @@ -288,7 +293,7 @@ class DiscourseJsProcessor "transpile", source, { - skip_module: @skip_module, + skipModule: @skip_module, moduleId: module_name(root_path, logical_path), filename: logical_path || "unknown", themeId: theme_id, diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb index 9f0fd9e6082..697d93474f4 100644 --- a/lib/discourse_plugin_registry.rb +++ b/lib/discourse_plugin_registry.rb @@ -72,6 +72,7 @@ class DiscoursePluginRegistry define_register :seedfu_filter, Set define_register :demon_processes, Set define_register :groups_callback_for_users_search_controller_action, Hash + define_register :mail_pollers, Set define_filtered_register :staff_user_custom_fields define_filtered_register :public_user_custom_fields @@ -106,10 +107,23 @@ class DiscoursePluginRegistry define_filtered_register :hashtag_autocomplete_data_sources define_filtered_register :hashtag_autocomplete_contextual_type_priorities + define_filtered_register :search_groups_set_query_callbacks + + define_filtered_register :about_stat_groups + define_filtered_register :bookmarkables + + define_filtered_register :list_suggested_for_providers + + define_filtered_register :summarization_strategies + def self.register_auth_provider(auth_provider) self.auth_providers << auth_provider end + def self.register_mail_poller(mail_poller) + self.mail_pollers << mail_poller + end + def register_js(filename, options = {}) # If we have a server side option, add that too. self.class.javascripts << filename @@ -156,8 +170,8 @@ class DiscoursePluginRegistry end end - JS_REGEX = /\.js$|\.js\.erb$|\.js\.es6$/ - HANDLEBARS_REGEX = /\.(hb[rs]|js\.handlebars)$/ + JS_REGEX = /\.js$|\.js\.erb$|\.js\.es6\z/ + HANDLEBARS_REGEX = /\.(hb[rs]|js\.handlebars)\z/ def self.register_asset(asset, opts = nil, plugin_directory_name = nil) if asset =~ JS_REGEX @@ -170,7 +184,7 @@ class DiscoursePluginRegistry else self.javascripts << asset end - elsif asset =~ /\.css$|\.scss$/ + elsif asset =~ /\.css$|\.scss\z/ if opts == :mobile self.mobile_stylesheets[plugin_directory_name] ||= Set.new self.mobile_stylesheets[plugin_directory_name] << asset @@ -239,8 +253,54 @@ class DiscoursePluginRegistry asset end + def self.clear_modifiers! + if Rails.env.test? && GlobalSetting.load_plugins? + raise "Clearing modifiers during a plugin spec run will affect all future specs. Use unregister_modifier instead." + end + @modifiers = nil + end + + def self.register_modifier(plugin_instance, name, &blk) + @modifiers ||= {} + modifiers = @modifiers[name] ||= [] + modifiers << [plugin_instance, blk] + end + + def self.unregister_modifier(plugin_instance, name, &blk) + raise "unregister_modifier can only be used in tests" if !Rails.env.test? + + modifiers_for_name = @modifiers&.[](name) + raise "no #{name} modifiers found" if !modifiers_for_name + + i = modifiers_for_name.find_index { |info| info == [plugin_instance, blk] } + raise "no modifier found for that plugin/block combination" if !i + + modifiers_for_name.delete_at(i) + end + + def self.apply_modifier(name, arg, *more_args) + return arg if !@modifiers + + registered_modifiers = @modifiers[name] + return arg if !registered_modifiers + + # iterate as fast as possible to minimize cost (avoiding each) + # also erases one stack frame + length = registered_modifiers.length + index = 0 + while index < length + plugin_instance, block = registered_modifiers[index] + arg = block.call(arg, *more_args) if plugin_instance.enabled? + + index += 1 + end + + arg + end + def self.reset! @@register_names.each { |name| instance_variable_set(:"@#{name}", nil) } + clear_modifiers! end def self.reset_register!(register_name) diff --git a/lib/discourse_redis.rb b/lib/discourse_redis.rb index e47e9734c56..b719082336f 100644 --- a/lib/discourse_redis.rb +++ b/lib/discourse_redis.rb @@ -213,10 +213,6 @@ class DiscourseRedis end end - def delete_prefixed(prefix) - DiscourseRedis.ignore_readonly { keys("#{prefix}*").each { |k| Discourse.redis.del(k) } } - end - def reconnect @redis._client.reconnect end @@ -276,7 +272,7 @@ class DiscourseRedis def eval(redis, *args, **kwargs) redis.evalsha @sha1, *args, **kwargs rescue ::Redis::CommandError => e - if e.to_s =~ /^NOSCRIPT/ + if e.to_s =~ /\ANOSCRIPT/ redis.eval @script, *args, **kwargs else raise diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 0fd1583be1a..6aac1518881 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -102,6 +102,12 @@ module DiscourseTagging end end + # tests if there are conflicts between tags on tag groups that only allow one tag from the group before adding + # mandatory parent tags because later we want to test if the mandatory parent tags introduce any conflicts + # and be able to pinpoint the tag that is introducing it + # guardian like above is nil to prevent stripping tags that already passed validation + return false unless validate_one_tag_from_group_per_topic(nil, topic, category, tags) + # add missing mandatory parent tags tag_ids = tags.map(&:id) @@ -132,7 +138,57 @@ module DiscourseTagging .compact .uniq - tags = tags + Tag.where(id: missing_parent_tag_ids).all unless missing_parent_tag_ids.empty? + missing_parent_tags = Tag.where(id: missing_parent_tag_ids).all + + tags = tags + missing_parent_tags unless missing_parent_tags.empty? + + parent_tag_conflicts = + filter_tags_violating_one_tag_from_group_per_topic( + nil, # guardian like above is nil to prevent stripping tags that already passed validation + topic.category, + tags, + ) + + if parent_tag_conflicts.present? + # we need to get the original tag names that introduced conflicting missing parent tags to return an useful + # error message + parent_child_names_map = {} + parent_tags_map.each do |tag_id, parent_tag_ids| + next if (tag_ids & parent_tag_ids).size > 0 # tag already has a parent tag + + parent_tag = tags.select { |t| t.id == parent_tag_ids.first }.first + original_child_tag = tags.select { |t| t.id == tag_id }.first + + next unless parent_tag.present? && original_child_tag.present? + parent_child_names_map[parent_tag.name] = original_child_tag.name + end + + # replaces the added missing parent tags with the original tag + parent_tag_conflicts.map do |_, conflicting_tags| + topic.errors.add( + :base, + I18n.t( + "tags.limited_to_one_tag_from_group", + tags: + conflicting_tags + .map do |tag| + tag_name = tag.name + + if parent_child_names_map[tag_name].present? + parent_child_names_map[tag_name] + else + tag_name + end + end + .uniq + .sort + .join(", "), + ), + ) + end + + return false + end return false unless validate_min_required_tags_for_category(guardian, topic, category, tags) return false unless validate_required_tags_from_group(guardian, topic, category, tags) @@ -163,6 +219,19 @@ module DiscourseTagging false end + def self.validate_category_tags(guardian, model, category, tags = []) + existing_tags = tags.present? ? Tag.where(name: tags) : [] + valid_tags = guardian.can_create_tag? ? tags : existing_tags + + # all add to model (topic) errors + valid = validate_min_required_tags_for_category(guardian, model, category, valid_tags) + valid &&= validate_required_tags_from_group(guardian, model, category, existing_tags) + valid &&= validate_category_restricted_tags(guardian, model, category, valid_tags) + valid &&= validate_one_tag_from_group_per_topic(guardian, model, category, valid_tags) + + valid + end + def self.validate_min_required_tags_for_category(guardian, model, category, tags = []) if !guardian.is_staff? && category && category.minimum_required_tags > 0 && tags.length < category.minimum_required_tags @@ -250,6 +319,54 @@ module DiscourseTagging true end + def self.validate_one_tag_from_group_per_topic(guardian, model, category, tags = []) + tags_cant_be_used = filter_tags_violating_one_tag_from_group_per_topic(guardian, category, tags) + + return true if tags_cant_be_used.blank? + + tags_cant_be_used.each do |_, incompatible_tags| + model.errors.add( + :base, + I18n.t( + "tags.limited_to_one_tag_from_group", + tags: incompatible_tags.map(&:name).sort.join(", "), + ), + ) + end + + false + end + + def self.filter_tags_violating_one_tag_from_group_per_topic(guardian, category, tags = []) + return [] if tags.size < 2 + + # ensures that tags are a list of tag names + tags = tags.map(&:name) if Tag === tags[0] + + allowed_tags = + filter_allowed_tags( + guardian, + category: category, + only_tag_names: tags, + for_topic: true, + order_search_results: true, + ) + + return {} if allowed_tags.size < 2 + + tags_by_group_map = + allowed_tags + .sort_by { |tag| [tag.tag_group_id || -1, tag.name] } + .inject({}) do |hash, tag| + next hash unless tag.one_per_topic + + hash[tag.tag_group_id] = (hash[tag.tag_group_id] || []) << tag + hash + end + + tags_by_group_map.select { |_, group_tags| group_tags.size > 1 } + end + TAG_GROUP_RESTRICTIONS_SQL ||= <<~SQL tag_group_restrictions AS ( SELECT t.id as tag_id, tgm.id as tgm_id, tg.id as tag_group_id, tg.parent_tag_id as parent_tag_id, @@ -457,7 +574,7 @@ module DiscourseTagging if !one_tag_per_group_ids.empty? builder.where( - "tag_group_id IS NULL OR tag_group_id NOT IN (?) OR id IN (:selected_tag_ids)", + "t.id NOT IN (SELECT DISTINCT tag_id FROM tag_group_restrictions WHERE tag_group_id IN (?)) OR id IN (:selected_tag_ids)", one_tag_per_group_ids, ) end @@ -634,7 +751,10 @@ module DiscourseTagging taggable.tags = Tag.where_name(tag_names).all new_tag_names = taggable.tags.size < tag_names.size ? tag_names - taggable.tags.map(&:name) : [] - taggable.tags << Tag.where(target_tag_id: taggable.tags.map(&:id)).all + taggable.tags << Tag + .where(target_tag_id: taggable.tags.map(&:id)) + .where.not(id: taggable.tags.map(&:id)) + .all new_tag_names.each { |name| taggable.tags << Tag.create(name: name) } end end @@ -653,6 +773,16 @@ module DiscourseTagging successful = existing.select { |t| !t.errors.present? } synonyms_ids = successful.map(&:id) TopicTag.where(topic_id: target_tag.topics.with_deleted, tag_id: synonyms_ids).delete_all + TopicTag.joins(DB.sql_fragment(<<~SQL, synonyms_ids: synonyms_ids)).delete_all + INNER JOIN ( + SELECT MIN(id) AS id, topic_id + FROM topic_tags + WHERE tag_id IN (:synonyms_ids) + GROUP BY topic_id + ) AS tt ON tt.id < topic_tags.id + AND tt.topic_id = topic_tags.topic_id + AND topic_tags.tag_id IN (:synonyms_ids) + SQL TopicTag.where(tag_id: synonyms_ids).update_all(tag_id: target_tag.id) Scheduler::Defer.later "Update tag topic counts" do Tag.ensure_consistency! diff --git a/lib/distributed_cache.rb b/lib/distributed_cache.rb index 80fa7bf1f82..b041b39d1d4 100644 --- a/lib/distributed_cache.rb +++ b/lib/distributed_cache.rb @@ -19,4 +19,12 @@ class DistributedCache < MessageBus::DistributedCache self.defer_set(k, value) value end + + def clear(after_commit: true) + if after_commit && !GlobalSetting.skip_db? + DB.after_commit { super() } + else + super() + end + end end diff --git a/lib/email/message_id_service.rb b/lib/email/message_id_service.rb index 4ba6b0d7375..e1a9fea0e88 100644 --- a/lib/email/message_id_service.rb +++ b/lib/email/message_id_service.rb @@ -5,14 +5,21 @@ module Email # Email Message-IDs are used in both our outbound and inbound email # flow. For the outbound flow via Email::Sender, we assign a unique # Message-ID for any emails sent out from the application. - # If we are sending an email related to a topic, such as through the + # If we are sending an email related to a post, such as through the # PostAlerter class, then the Message-ID will contain references to - # the topic ID, and if it is for a specific post, the post ID, - # along with a random suffix to make the Message-ID truly unique. - # The host must also be included on the Message-IDs. + # the post ID. The host must also be included on the Message-IDs. + # The format looks like this: + # + # discourse/post/POST_ID@HOST + # + # We previously had the following formats, but support for these + # will be removed in 2023: + # + # topic/TOPIC_ID/POST_ID@HOST + # topic/TOPIC_ID@HOST # # For the inbound email flow via Email::Receiver, we use Message-IDs - # to discern which topic or post the inbound email reply should be + # to discern which topic and post the inbound email reply should be # in response to. In this case, the Message-ID is extracted from the # References and/or In-Reply-To headers, and compared with either # the IncomingEmail table, the Post table, or the IncomingEmail to @@ -29,38 +36,6 @@ module Email "<#{SecureRandom.uuid}@#{host}>" end - # TODO (martin) 2023-01-01 Deprecated, remove this once the new threading - # systems have been in place for a while. - def generate_for_post(post, use_incoming_email_if_present: false) - if use_incoming_email_if_present && post.incoming_email&.message_id.present? - return "<#{post.incoming_email.message_id}>" - end - - "" - end - - # TODO (martin) 2023-01-01 Deprecated, remove this once the new threading - # systems have been in place for a while. - def generate_for_topic(topic, use_incoming_email_if_present: false, canonical: false) - first_post = topic.ordered_posts.first - incoming_email = first_post.incoming_email - - # If the incoming email was created by handle_mail, then it was an - # inbound email sent to Discourse and handled by Email::Receiver, - # this is the only case where we want to use the original Message-ID - # because we want to maintain threading in the original mail client. - if use_incoming_email_if_present && incoming_email&.message_id.present? && - incoming_email&.created_via == IncomingEmail.created_via_types[:handle_mail] - return "<#{first_post.incoming_email.message_id}>" - end - - if canonical - "" - else - "" - end - end - ## # The outbound_message_id may be present because either: # @@ -96,7 +71,7 @@ module Email def find_post_from_message_ids(message_ids) message_ids = message_ids.map { |message_id| message_id_clean(message_id) } - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. topic_ids = @@ -131,11 +106,7 @@ module Email Post.where(id: post_ids).order(:created_at).last end - def random_suffix - SecureRandom.hex(12) - end - - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. def discourse_generated_message_id?(message_id) @@ -144,7 +115,7 @@ module Email !!(message_id =~ message_id_discourse_regexp) end - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. def message_id_post_id_regexp @@ -165,7 +136,7 @@ module Email def message_id_clean(message_id) if message_id.present? && is_message_id_rfc?(message_id) - message_id.gsub(/^<|>$/, "") + message_id.gsub(/\A<|>\z/, "") else message_id end diff --git a/lib/email/poller.rb b/lib/email/poller.rb new file mode 100644 index 00000000000..a364d28c936 --- /dev/null +++ b/lib/email/poller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Email + class Poller + # To be implemented by concrete classes. + # This function takes as input a function that processes the incoming email. + # The function passed as argument should take as an argument the MIME string of the email. + # An example of function to pass is `process_popmail` in `app/jobs/scheduled/poll_mailbox.rb` + def poll_mailbox(process_cb) + raise NotImplementedError + end + + # Child class can override this + def enabled? + true + end + end +end diff --git a/lib/email/processor.rb b/lib/email/processor.rb index 9c807fc2b8e..30c50c2baef 100644 --- a/lib/email/processor.rb +++ b/lib/email/processor.rb @@ -24,7 +24,7 @@ module Email raise end rescue => e - return handle_bounce(e) if @receiver.is_bounce? + return handle_bounce(e) if @receiver&.is_bounce? log_email_process_failure(@mail, e) incoming_email = @receiver.try(:incoming_email) @@ -156,7 +156,7 @@ module Email end def can_send_rejection_email?(email, type) - return false if @receiver.sent_to_mailinglist_mirror? + return false if @receiver&.sent_to_mailinglist_mirror? return true if type == :email_reject_unrecognized_error key = "rejection_email:#{email}:#{type}:#{Date.today}" diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 05b51c7eb00..93ecc91aa57 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -107,7 +107,7 @@ module Email Email::Validator.ensure_valid!(@mail) - @from_email, @from_display_name = parse_from_field + @from_email, @from_display_name = parse_from_field(@mail) @from_user = User.find_by_email(@from_email) @incoming_email = create_incoming_email @@ -381,9 +381,9 @@ module Email @mail[:precedence].to_s[/list|junk|bulk|auto_reply/i] || @mail[:from].to_s[/(mailer[\-_]?daemon|post[\-_]?master|no[\-_]?reply)@/i] || @mail[:subject].to_s[ - /^\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i + /\A\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i ] || - @mail.header.to_s[ + @mail.header.reject { |h| h.name.downcase == "x-auto-response-suppress" }.to_s[ /auto[\-_]?(response|submitted|replied|reply|generated|respond)|holidayreply|machinegenerated/i ] end @@ -393,7 +393,7 @@ module Email when "X-Spam-Flag" @mail[:x_spam_flag].to_s[/YES/i] when "X-Spam-Status" - @mail[:x_spam_status].to_s[/^Yes, /i] + @mail[:x_spam_status].to_s[/\AYes, /i] when "X-SES-Spam-Verdict" @mail[:x_ses_spam_verdict].to_s[/FAIL/i] else @@ -639,7 +639,7 @@ module Email .uniq @previous_replies_regex ||= - /^--[- ]\n\*(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\*\n/im + /\A--[- ]\n\*(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\*\n/im end def reply_above_line_regex @@ -660,14 +660,15 @@ module Email reply.split(reply_above_line_regex)[0] end - def parse_from_field(mail = nil) - mail ||= @mail - + def parse_from_field(mail, process_forwarded_emails: true) if email_log.present? email = email_log.to_address || email_log.user&.email return email, email_log.user&.username elsif mail.bounced? - Array.wrap(mail.final_recipient).each { |from| return extract_from_address_and_name(from) } + # "Final-Recipient" has a specific format ( ;
    ) + # cf. https://www.ietf.org/rfc/rfc2298.html#section-3.2.4 + address_type, generic_address = mail.final_recipient.to_s.split(";").map { _1.to_s.strip } + return generic_address, nil if generic_address.include?("@") && address_type == "rfc822" end return unless mail[:from] @@ -675,87 +676,81 @@ module Email # For forwarded emails, where the from address matches a group incoming # email, we want to use the from address of the original email sender, # which we can extract from embedded_email_raw. - if has_been_forwarded? - if mail[:from].to_s =~ group_incoming_emails_regex && embedded_email[:from].errors.blank? - from_address, from_display_name = extract_from_fields_from_header(embedded_email, :from) - return from_address, from_display_name if from_address + if process_forwarded_emails && has_been_forwarded? + if mail[:from].to_s =~ group_incoming_emails_regex + if embedded_email && embedded_email[:from].errors.blank? + from_address, from_display_name = + Email::Receiver.extract_email_address_and_name(embedded_email[:from]) + return from_address, from_display_name if from_address + end end end + # extract proper sender when using mailman mailing list + if mail[:x_mailman_version].present? + address, name = Email::Receiver.extract_email_address_and_name_from_mailman(mail) + return address, name if address + end + # For now we are only using the Reply-To header if the email has # been forwarded via Google Groups, which is why we are checking the # X-Original-From header too. In future we may want to use the Reply-To # header in more cases. - if mail["X-Original-From"].present? - if mail[:reply_to] && mail[:reply_to].errors.blank? - from_address, from_display_name = - extract_from_fields_from_header( - mail, - :reply_to, - comparison_headers: ["X-Original-From"], - ) - return from_address, from_display_name if from_address - end + if mail[:x_original_from].present? && mail[:reply_to].present? + original_from_address, _ = + Email::Receiver.extract_email_address_and_name(mail[:x_original_from]) + reply_to_address, reply_to_name = + Email::Receiver.extract_email_address_and_name(mail[:reply_to]) + return reply_to_address, reply_to_name if original_from_address == reply_to_address end - if mail[:from].errors.blank? - from_address, from_display_name = extract_from_fields_from_header(mail, :from) - return from_address, from_display_name if from_address - end - - return extract_from_address_and_name(mail.from) if mail.from.is_a? String - - if mail.from.is_a? Mail::AddressContainer - mail.from.each do |from| - from_address, from_display_name = extract_from_address_and_name(from) - return from_address, from_display_name if from_address - end - end - - nil + Email::Receiver.extract_email_address_and_name(mail[:from]) rescue StandardError nil end - def extract_from_fields_from_header(mail_object, header, comparison_headers: []) - mail_object[header].each do |address_field| - from_address = address_field.address - from_display_name = address_field.display_name&.to_s + def self.extract_email_address_and_name_from_mailman(mail) + list_address, _ = Email::Receiver.extract_email_address_and_name(mail[:list_post]) + list_address, _ = + Email::Receiver.extract_email_address_and_name(mail[:x_beenthere]) if list_address.blank? - comparison_failed = false - comparison_headers.each do |comparison_header| - comparison_header_address = mail_object[comparison_header].to_s[/<([^>]+)>/, 1] - if comparison_header_address != from_address - comparison_failed = true - break - end + return if list_address.blank? + + # the CC header often includes the name of the sender + address_to_name = mail[:cc]&.element&.addresses&.to_h { [_1.address, _1.name] } || {} + + %i[from reply_to x_mailfrom x_original_from].each do |header| + next if mail[header].blank? + email, name = Email::Receiver.extract_email_address_and_name(mail[header]) + if email.present? && email != list_address + return email, name.presence || address_to_name[email] end - - next if comparison_failed - next if !from_address&.include?("@") - return from_address&.downcase, from_display_name&.strip end - - [nil, nil] end - def extract_from_address_and_name(value) - if value[";"] - from_display_name, from_address = value.split(";") - return from_address&.strip&.downcase, from_display_name&.strip + def self.extract_email_address_and_name(value) + begin + # ensure the email header value is a string + value = value.to_s + # in embedded emails, converts [mailto:foo@bar.com] to + value = value.gsub(/\[mailto:([^\[\]]+?)\]/, "<\\1>") + # 'mailto:' suffix isn't supported by Mail::Address parsing + value = value.gsub("mailto:", "") + # parse the email header value + parsed = Mail::Address.new(value) + # extract the email address and name + mail = parsed.address.to_s.downcase.strip + name = parsed.name.to_s.strip + # ensure the email address is "valid" + if mail.include?("@") + # remove surrounding quotes from the name + name = name[1...-1] if name.size > 2 && name[/\A(['"]).+(\1)\z/] + # return the email address and name + [mail, name] + end + rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError => e + # something went wrong parsing the email header value, return nil end - - if value[/<[^>]+>/] - from_address = value[/<([^>]+)>/, 1] - from_display_name = value[/^([^<]+)/, 1] - end - - if (from_address.blank? || !from_address["@"]) && value[/\[mailto:[^\]]+\]/] - from_address = value[/\[mailto:([^\]]+)\]/, 1] - from_display_name = value[/^([^\[]+)/, 1] - end - - [from_address&.downcase, from_display_name&.strip] end def subject @@ -1016,7 +1011,7 @@ module Email end def has_been_forwarded? - subject[/^[[:blank:]]*(fwd?|tr)[[:blank:]]?:/i] && embedded_email_raw.present? + subject[/\A[[:blank:]]*(fwd?|tr)[[:blank:]]?:/i] && embedded_email_raw.present? end def embedded_email_raw @@ -1089,24 +1084,28 @@ module Email end def forwarded_email_create_replies(destination, user) - embedded = Mail.new(embedded_email_raw) - email, display_name = parse_from_field(embedded) + forwarded_by_address, forwarded_by_name = + Email::Receiver.extract_email_address_and_name(@mail[:from]) if forwarded_by_address && forwarded_by_name @forwarded_by_user = stage_sender_user(forwarded_by_address, forwarded_by_name) end - return false if email.blank? || !email["@"] + email_address, display_name = + parse_from_field(embedded_email, process_forwarded_emails: false) + + return false if email_address.blank? || !email_address.include?("@") post = forwarded_email_create_topic( destination: destination, user: user, - raw: try_to_encode(embedded.decoded, "UTF-8").presence || embedded.to_s, - title: embedded.subject.presence || subject, - date: embedded.date, - embedded_user: lambda { find_or_create_user(email, display_name) }, + raw: try_to_encode(embedded_email.decoded, "UTF-8").presence || embedded_email.to_s, + title: embedded_email.subject.presence || subject, + date: embedded_email.date, + embedded_user: lambda { find_or_create_user(email_address, display_name) }, ) + return false unless post if post.topic @@ -1143,25 +1142,12 @@ module Email true end - def forwarded_by_sender - @forwarded_by_sender ||= extract_from_fields_from_header(@mail, :from) - end - - def forwarded_by_address - @forwarded_by_address ||= forwarded_by_sender&.first - end - - def forwarded_by_name - @forwarded_by_name ||= forwarded_by_sender&.first - end - def forwarded_email_quote_forwarded(destination, user) - embedded = embedded_email_raw raw = <<~MD #{@before_embedded} [quote] - #{PlainTextToMarkdown.new(embedded).to_markdown} + #{PlainTextToMarkdown.new(@embedded_email_raw).to_markdown} [/quote] MD @@ -1510,7 +1496,8 @@ module Email def self.elided_html(elided) html = +"\n\n" << "
    " << "\n" - html << "···" << "\n\n" + html << "···" << + "\n\n" html << elided << "\n\n" html << "
    " << "\n" html @@ -1527,7 +1514,7 @@ module Email address_field.decoded email = address_field.address.downcase display_name = address_field.display_name.try(:to_s) - next unless email["@"] + next if !email.include?("@") if should_invite?(email) user = User.find_by_email(email) diff --git a/lib/email/sender.rb b/lib/email/sender.rb index 1f3c59efd04..fceb484322b 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -122,7 +122,7 @@ module Email if from_address.blank? nil else - Group.where(email_username: from_address, smtp_enabled: true).pluck_first(:id) + Group.where(email_username: from_address, smtp_enabled: true).pick(:id) end ) @@ -294,6 +294,8 @@ module Email return skip(SkippedEmailLog.reason_types[:custom], custom_reason: e.message) end + DiscourseEvent.trigger(:after_email_send, @message, @email_type) + email_log.save! email_log end @@ -361,7 +363,7 @@ module Email if attached_upload.local? Discourse.store.path_for(attached_upload) else - Discourse.store.download(attached_upload).path + Discourse.store.download!(attached_upload).path end @message_attachments_index[original_upload.sha1] = @message.attachments.size diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 25b8a66ed8f..d1976f3c517 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -84,9 +84,9 @@ module Email if img["src"] # ensure all urls are absolute - img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{^/[^/]}] + img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{\A/[^/]}] # ensure no schemaless urls - img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{^//}] + img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{\A//}] end end @@ -110,7 +110,7 @@ module Email .css("a.attachment") .each do |a| # ensure all urls are absolute - a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{^/[^/]} + a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{\A/[^/]} # ensure no schemaless urls a["href"] = "#{uri.scheme}:#{a["href"]}" if a["href"] && a["href"].starts_with?("//") diff --git a/lib/email_controller_helper/topic_email_unsubscriber.rb b/lib/email_controller_helper/topic_email_unsubscriber.rb index eda37b7d667..e31ef94e1b0 100644 --- a/lib/email_controller_helper/topic_email_unsubscriber.rb +++ b/lib/email_controller_helper/topic_email_unsubscriber.rb @@ -8,6 +8,8 @@ module EmailControllerHelper topic = unsubscribe_key.associated_topic + return if topic.blank? + controller.instance_variable_set(:@topic, topic) controller.instance_variable_set( :@watching_topic, diff --git a/lib/email_cook.rb b/lib/email_cook.rb index 2c76e1f2ff2..8af5b3c5fe6 100644 --- a/lib/email_cook.rb +++ b/lib/email_cook.rb @@ -4,7 +4,7 @@ class EmailCook def self.raw_regexp @raw_regexp ||= - %r{^\[plaintext\]$\n(.*)\n^\[/plaintext\]$(?:\s^\[attachments\]$\n(.*)\n^\[/attachments\]$)?(?:\s^\[elided\]$\n(.*)\n^\[/elided\]$)?}m + %r{\A\[plaintext\]$\n(.*)\n^\[/plaintext\]$(?:\s^\[attachments\]$\n(.*)\n^\[/attachments\]$)?(?:\s^\[elided\]$\n(.*)\n^\[/elided\]$)?}m end def initialize(raw) @@ -14,7 +14,7 @@ class EmailCook def add_quote(result, buffer) if buffer.present? - return if buffer =~ /\A(
    )+\z$/ + return if buffer =~ /\A(
    )+\z\z/ result << "
    #{buffer}
    " end end @@ -22,7 +22,7 @@ class EmailCook def link_string!(line, unescaped_line) unescaped_line = unescaped_line.strip line.gsub!(/\S+/) do |str| - if str.match?(%r{^(https?://)[\S]+$}i) + if str.match?(%r{\A(https?://)[\S]+\z}i) begin url = URI.parse(str).to_s if unescaped_line == url @@ -48,11 +48,11 @@ class EmailCook text.each_line do |line| # replace indentation with non-breaking spaces - line.sub!(/^\s{2,}/) { |s| "\u00A0" * s.length } + line.sub!(/\A\s{2,}/) { |s| "\u00A0" * s.length } - if line =~ /^\s*>/ + if line =~ /\A\s*>/ in_quote = true - line.sub!(/^[\s>]*/, "") + line.sub!(/\A[\s>]*/, "") unescaped_line = line line = CGI.escapeHTML(line) diff --git a/lib/ember_cli.rb b/lib/ember_cli.rb index 00c65610bad..291d4429d99 100644 --- a/lib/ember_cli.rb +++ b/lib/ember_cli.rb @@ -16,6 +16,13 @@ module EmberCli assets += Dir.glob("app/assets/javascripts/discourse/scripts/*.js").map { |f| File.basename(f) } + if workbox_dir_name + assets += + Dir + .glob("app/assets/javascripts/discourse/dist/assets/#{workbox_dir_name}/*") + .map { |f| "#{workbox_dir_name}/#{File.basename(f)}" } + end + Discourse .find_plugin_js_assets(include_disabled: true) .each do |file| @@ -62,4 +69,13 @@ module EmberCli JSON.parse(ember_source_package_raw)["version"] end end + + def self.workbox_dir_name + return @workbox_base_dir if defined?(@workbox_base_dir) + + @workbox_base_dir = + if (full_path = Dir.glob("app/assets/javascripts/discourse/dist/assets/workbox-*")[0]) + File.basename(full_path) + end + end end diff --git a/lib/emoji/db.json b/lib/emoji/db.json index 8b3f530c5d9..0cbab5704b8 100644 --- a/lib/emoji/db.json +++ b/lib/emoji/db.json @@ -58,7 +58,7 @@ }, { "code": "263a", - "name": "relaxed" + "name": "smiling_face" }, { "code": "1f60b", @@ -8310,6 +8310,9 @@ ], "frowning_with_open_mouth": [ "frowning_face_with_open_mouth" + ], + "smiling_face": [ + "relaxed" ] }, "searchAliases": { @@ -17285,4 +17288,4 @@ ":$": "blush", ":-$": "blush" } -} \ No newline at end of file +} diff --git a/lib/emoji/groups.json b/lib/emoji/groups.json index 04395ce4d32..fdb56fd6285 100644 --- a/lib/emoji/groups.json +++ b/lib/emoji/groups.json @@ -7449,4 +7449,4 @@ } ] } -] \ No newline at end of file +] diff --git a/lib/excerpt_parser.rb b/lib/excerpt_parser.rb index 95d9f398d53..0811cb3129a 100644 --- a/lib/excerpt_parser.rb +++ b/lib/excerpt_parser.rb @@ -22,6 +22,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document @keep_svg = options[:keep_svg] == true @remap_emoji = options[:remap_emoji] == true @start_excerpt = false + @start_hashtag_icon = false @in_details_depth = 0 @summary_contents = +"" @detail_contents = +"" @@ -98,7 +99,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document end when "aside" attributes = Hash[*attributes.flatten] - unless (@keep_onebox_source || @keep_onebox_body) && attributes["class"]&.include?("onebox") + if !(@keep_onebox_source || @keep_onebox_body) || !attributes["class"]&.include?("onebox") @in_quote = true end @@ -112,10 +113,18 @@ class ExcerptParser < Nokogiri::XML::SAX::Document when "header" @in_quote = !@keep_onebox_source if attributes.include?(%w[class source]) when "div", "span" - if attributes.include?(%w[class excerpt]) + attributes = Hash[*attributes.flatten] + + # Only match "excerpt" class if it does not specifically equal "excerpt + # hidden" in order to prevent internal links with GitHub oneboxes from + # being empty https://meta.discourse.org/t/269436 + if attributes["class"]&.include?("excerpt") && !attributes["class"]&.match?("excerpt hidden") @excerpt = +"" @current_length = 0 @start_excerpt = true + elsif attributes["class"]&.include?("hashtag-icon-placeholder") + @start_hashtag_icon = true + include_tag(name, attributes) end when "details" @detail_contents = +"" if @in_details_depth == 0 @@ -180,6 +189,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document @in_summary = false if @in_details_depth == 1 when "div", "span" throw :done if @start_excerpt + characters("", truncate: false, count_it: false, encode: false) if @start_hashtag_icon when "svg" characters("", truncate: false, count_it: false, encode: false) if @keep_svg @in_svg = false diff --git a/lib/external_upload_helpers.rb b/lib/external_upload_helpers.rb index 3ac4cea5bcc..81cdfa5d91d 100644 --- a/lib/external_upload_helpers.rb +++ b/lib/external_upload_helpers.rb @@ -8,10 +8,6 @@ module ExternalUploadHelpers class ExternalUploadValidationError < StandardError end - PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE = 10 - CREATE_MULTIPART_RATE_LIMIT_PER_MINUTE = 10 - COMPLETE_MULTIPART_RATE_LIMIT_PER_MINUTE = 10 - included do before_action :external_store_check, only: %i[ @@ -38,7 +34,7 @@ module ExternalUploadHelpers RateLimiter.new( current_user, "generate-presigned-put-upload-stub", - ExternalUploadHelpers::PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE, + SiteSetting.max_presigned_put_per_minute, 1.minute, ).performed! @@ -81,7 +77,7 @@ module ExternalUploadHelpers RateLimiter.new( current_user, "create-multipart-upload", - ExternalUploadHelpers::CREATE_MULTIPART_RATE_LIMIT_PER_MINUTE, + SiteSetting.max_create_multipart_per_minute, 1.minute, ).performed! @@ -127,9 +123,6 @@ module ExternalUploadHelpers # a 1.5GB upload with 5mb parts this could mean 60 requests to the server to get all # the part URLs. If the user's upload speed is super fast they may request all 60 # batches in a minute, if it is slow they may request 5 batches in a minute. - # - # The other external upload endpoints are not hit as often, so they can stay as constant - # values for now. RateLimiter.new( current_user, "batch-presign", @@ -225,7 +218,7 @@ module ExternalUploadHelpers RateLimiter.new( current_user, "complete-multipart-upload", - ExternalUploadHelpers::COMPLETE_MULTIPART_RATE_LIMIT_PER_MINUTE, + SiteSetting.max_complete_multipart_per_minute, 1.minute, ).performed! @@ -256,12 +249,11 @@ module ExternalUploadHelpers .sort_by { |part| part[:part_number] } begin - complete_response = - store.complete_multipart( - upload_id: external_upload_stub.external_upload_identifier, - key: external_upload_stub.key, - parts: parts, - ) + store.complete_multipart( + upload_id: external_upload_stub.external_upload_identifier, + key: external_upload_stub.key, + parts: parts, + ) rescue Aws::S3::Errors::ServiceError => err return( render_json_error( diff --git a/lib/file_helper.rb b/lib/file_helper.rb index 9b57251f83c..229705fdd70 100644 --- a/lib/file_helper.rb +++ b/lib/file_helper.rb @@ -52,7 +52,7 @@ class FileHelper retain_on_max_file_size_exceeded: false ) url = "https:" + url if url.start_with?("//") - raise Discourse::InvalidParameters.new(:url) unless url =~ %r{^https?://} + raise Discourse::InvalidParameters.new(:url) unless url =~ %r{\Ahttps?://} tmp = nil @@ -158,7 +158,7 @@ class FileHelper end def self.supported_images - @@supported_images ||= Set.new %w[jpg jpeg png gif svg ico webp] + @@supported_images ||= Set.new %w[jpg jpeg png gif svg ico webp avif] end def self.inline_images @@ -175,26 +175,26 @@ class FileHelper end def self.supported_video_regexp - @@supported_video_regexp ||= /\.(#{supported_video.to_a.join("|")})$/i + @@supported_video_regexp ||= /\.(#{supported_video.to_a.join("|")})\z/i end def self.supported_audio_regexp - @@supported_audio_regexp ||= /\.(#{supported_audio.to_a.join("|")})$/i + @@supported_audio_regexp ||= /\.(#{supported_audio.to_a.join("|")})\z/i end def self.supported_images_regexp - @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})$/i + @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})\z/i end def self.inline_images_regexp - @@inline_images_regexp ||= /\.(#{inline_images.to_a.join("|")})$/i + @@inline_images_regexp ||= /\.(#{inline_images.to_a.join("|")})\z/i end def self.supported_media_regexp @@supported_media_regexp ||= begin media = supported_images | supported_audio | supported_video - /\.(#{media.to_a.join("|")})$/i + /\.(#{media.to_a.join("|")})\z/i end end @@ -202,7 +202,7 @@ class FileHelper @@supported_playable_media_regexp ||= begin media = supported_audio | supported_video - /\.(#{media.to_a.join("|")})$/i + /\.(#{media.to_a.join("|")})\z/i end end end diff --git a/lib/file_store/base_store.rb b/lib/file_store/base_store.rb index 9645513ab1f..ff98b06231a 100644 --- a/lib/file_store/base_store.rb +++ b/lib/file_store/base_store.rb @@ -105,7 +105,7 @@ module FileStore end def download(object, max_file_size_kb: nil, print_deprecation: true) - Discourse.deprecate(<<~MESSAGE, output_in_test: true) if print_deprecation + Discourse.deprecate(<<~MESSAGE) if print_deprecation In a future version `FileStore#download` will no longer raise an error when the download fails, and will instead return `nil`. If you need a method that raises an error, use `FileStore#download!`, which raises a `FileStore::DownloadError`. @@ -135,7 +135,7 @@ module FileStore end ) - url = SiteSetting.scheme + ":" + url if url =~ %r{^//} + url = SiteSetting.scheme + ":" + url if url =~ %r{\A//} file = FileHelper.download( url, diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb index c0461aa50a7..3a6f1137557 100644 --- a/lib/file_store/local_store.rb +++ b/lib/file_store/local_store.rb @@ -128,7 +128,7 @@ module FileStore count = 0 model.find_each do |upload| # could be a remote image - next unless upload.url =~ %r{^/[^/]} + next unless upload.url =~ %r{\A/[^/]} path = "#{public_dir}#{upload.url}" bad = true diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 8fdbd2d9fab..a36a452dbbb 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -87,7 +87,7 @@ module FileStore # cache file locally when needed cache_file(file, File.basename(path)) if opts[:cache_locally] options = { - acl: opts[:private_acl] ? "private" : "public-read", + acl: SiteSetting.s3_use_acls ? (opts[:private_acl] ? "private" : "public-read") : nil, cache_control: "max-age=31556952, public, immutable", content_type: opts[:content_type].presence || MiniMime.lookup_by_filename(filename)&.content_type, @@ -216,7 +216,7 @@ module FileStore def path_for(upload) url = upload&.url - FileStore::LocalStore.new.path_for(upload) if url && url[%r{^/[^/]}] + FileStore::LocalStore.new.path_for(upload) if url && url[%r{\A/[^/]}] end def url_for(upload, force_download: false) @@ -226,6 +226,8 @@ module FileStore force_download: force_download, filename: upload.original_filename, ) + elsif SiteSetting.s3_use_cdn_url_for_all_uploads + cdn_url(upload.url) else upload.url end @@ -233,7 +235,7 @@ module FileStore def cdn_url(url) return url if SiteSetting.Upload.s3_cdn_url.blank? - schema = url[%r{^(https?:)?//}, 1] + schema = url[%r{\A(https?:)?//}, 1] folder = s3_bucket_folder_path.nil? ? "" : "#{s3_bucket_folder_path}/" url.sub( File.join("#{schema}#{absolute_base_url}", folder), @@ -262,7 +264,7 @@ module FileStore expires_in: expires_in, opts: { metadata: metadata, - acl: "private", + acl: SiteSetting.s3_use_acls ? "private" : nil, }, ) end @@ -309,25 +311,29 @@ module FileStore key = get_upload_key(upload) update_ACL(key, upload.secure?) - # if we do find_each when the images have already been preloaded with + # If we do find_each when the images have already been preloaded with # includes(:optimized_images), then the optimized_images are fetched # from the database again, negating the preloading if this operation # is done on a large amount of uploads at once (see Jobs::SyncAclsForUploads) if optimized_images_preloaded upload.optimized_images.each do |optimized_image| - optimized_image_key = get_path_for_optimized_image(optimized_image) - update_ACL(optimized_image_key, upload.secure?) + update_optimized_image_acl(optimized_image, secure: upload.secure) end else upload.optimized_images.find_each do |optimized_image| - optimized_image_key = get_path_for_optimized_image(optimized_image) - update_ACL(optimized_image_key, upload.secure?) + update_optimized_image_acl(optimized_image, secure: upload.secure) end end true end + def update_optimized_image_acl(optimized_image, secure: false) + optimized_image_key = get_path_for_optimized_image(optimized_image) + optimized_image_key.prepend(File.join(upload_path, "/")) if Rails.configuration.multisite + update_ACL(optimized_image_key, secure) + end + def download_file(upload, destination_path) s3_helper.download_file(get_upload_key(upload), destination_path) end @@ -393,7 +399,9 @@ module FileStore def update_ACL(key, secure) begin - object_from_path(key).acl.put(acl: secure ? "private" : "public-read") + object_from_path(key).acl.put( + acl: SiteSetting.s3_use_acls ? (secure ? "private" : "public-read") : nil, + ) rescue Aws::S3::Errors::NoSuchKey Rails.logger.warn("Could not update ACL on upload with key: '#{key}'. Upload is missing.") end @@ -409,7 +417,7 @@ module FileStore verified_ids = [] files.each do |f| - id = model.where("url LIKE '%#{f.key}' AND etag = '#{f.etag}'").pluck_first(:id) + id = model.where("url LIKE '%#{f.key}' AND etag = '#{f.etag}'").pick(:id) verified_ids << id if id.present? marker = f.key end diff --git a/lib/file_store/to_s3_migration.rb b/lib/file_store/to_s3_migration.rb index b99c911a6b9..ba8b79ee518 100644 --- a/lib/file_store/to_s3_migration.rb +++ b/lib/file_store/to_s3_migration.rb @@ -9,17 +9,11 @@ module FileStore MISSING_UPLOADS_RAKE_TASK_NAME ||= "posts:missing_uploads" UPLOAD_CONCURRENCY ||= 20 - def initialize( - s3_options:, - dry_run: false, - migrate_to_multisite: false, - skip_etag_verify: false - ) + def initialize(s3_options:, dry_run: false, migrate_to_multisite: false) @s3_bucket = s3_options[:bucket] @s3_client_options = s3_options[:client_options] @dry_run = dry_run @migrate_to_multisite = migrate_to_multisite - @skip_etag_verify = skip_etag_verify @current_db = RailsMultisite::ConnectionManagement.current_db end @@ -31,13 +25,13 @@ module FileStore end def self.s3_options_from_env - unless ENV["DISCOURSE_S3_BUCKET"].present? && ENV["DISCOURSE_S3_REGION"].present? && - ( - ( - ENV["DISCOURSE_S3_ACCESS_KEY_ID"].present? && - ENV["DISCOURSE_S3_SECRET_ACCESS_KEY"].present? - ) || ENV["DISCOURSE_S3_USE_IAM_PROFILE"].present? - ) + if ENV["DISCOURSE_S3_BUCKET"].blank? || ENV["DISCOURSE_S3_REGION"].blank? || + !( + ( + ENV["DISCOURSE_S3_ACCESS_KEY_ID"].present? && + ENV["DISCOURSE_S3_SECRET_ACCESS_KEY"].present? + ) || ENV["DISCOURSE_S3_USE_IAM_PROFILE"].present? + ) raise ToS3MigrationError.new(<<~TEXT) Please provide the following environment variables: - DISCOURSE_S3_BUCKET @@ -217,7 +211,7 @@ module FileStore UPLOAD_CONCURRENCY.times.map do Thread.new do while obj = queue.pop - if s3.put_object(obj[:options]).etag[obj[:etag]] + if s3.put_object(obj[:options]) putc "." lock.synchronize { synced += 1 } else @@ -231,20 +225,22 @@ module FileStore local_files.each do |file| path = File.join(public_directory, file) name = File.basename(path) - etag = Digest::MD5.file(path).hexdigest unless @skip_etag_verify + content_md5 = Digest::MD5.file(path).base64digest key = file[file.index(prefix)..-1] key.prepend(folder) if bucket_has_folder_path original_path = file.sub("uploads/#{@current_db}", "") - if s3_object = s3_objects.find { |obj| obj.key.ends_with?(original_path) } - next if File.size(path) == s3_object.size && (@skip_etag_verify || s3_object.etag[etag]) + if (s3_object = s3_objects.find { |obj| obj.key.ends_with?(original_path) }) && + File.size(path) == s3_object.size + next end options = { - acl: "public-read", + acl: SiteSetting.s3_use_acls ? "public-read" : nil, body: File.open(path, "rb"), bucket: bucket, content_type: MiniMime.lookup_by_filename(name)&.content_type, + content_md5: content_md5, key: key, } @@ -267,13 +263,11 @@ module FileStore ) end - etag ||= Digest::MD5.file(path).hexdigest - if @dry_run log "#{file} => #{options[:key]}" synced += 1 else - queue << { path: path, options: options, etag: etag } + queue << { path: path, options: options, content_md5: content_md5 } end end diff --git a/lib/final_destination.rb b/lib/final_destination.rb index 893b38e9c58..c722fdc2410 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -8,6 +8,9 @@ require "url_helper" # Determine the final endpoint for a Web URI, following redirects class FinalDestination + class SSRFError < SocketError + end + MAX_REQUEST_TIME_SECONDS = 10 MAX_REQUEST_SIZE_BYTES = 5_242_880 # 1024 * 1024 * 5 diff --git a/lib/final_destination/http.rb b/lib/final_destination/http.rb index ffceff99deb..4973b149a33 100644 --- a/lib/final_destination/http.rb +++ b/lib/final_destination/http.rb @@ -23,7 +23,7 @@ class FinalDestination::HTTP < Net::HTTP @open_timeout = remaining_time return super - rescue SystemCallError, Net::OpenTimeout => e + rescue OpenSSL::SSL::SSLError, SystemCallError, Net::OpenTimeout => e debug "[FinalDestination] Error connecting to #{ip}... #{e.message}" was_last_attempt = index == ips.length - 1 raise if was_last_attempt diff --git a/lib/final_destination/resolver.rb b/lib/final_destination/resolver.rb index 843a6a313bc..5d97ca99df3 100644 --- a/lib/final_destination/resolver.rb +++ b/lib/final_destination/resolver.rb @@ -3,7 +3,8 @@ class FinalDestination::Resolver @mutex = Mutex.new def self.lookup(addr, timeout: nil) - timeout ||= 2 + timeout ||= default_dns_query_timeout + @mutex.synchronize do @result = nil @@ -36,6 +37,14 @@ class FinalDestination::Resolver private + def self.default_dns_query_timeout + if gs = GlobalSetting.dns_query_timeout_secs.presence + Integer(gs) + else + 2 + end + end + def self.ensure_lookup_thread return if @thread&.alive? diff --git a/lib/final_destination/ssrf_detector.rb b/lib/final_destination/ssrf_detector.rb index 7a3bf1f0369..dcb20644f73 100644 --- a/lib/final_destination/ssrf_detector.rb +++ b/lib/final_destination/ssrf_detector.rb @@ -2,9 +2,9 @@ class FinalDestination module SSRFDetector - class DisallowedIpError < SocketError + class DisallowedIpError < SSRFError end - class LookupFailedError < SocketError + class LookupFailedError < SSRFError end # This is a list of private IPv4 IP ranges that are not allowed to be globally reachable as given by diff --git a/lib/freedom_patches/pluck_first.rb b/lib/freedom_patches/pluck_first.rb index 6f3d520187f..616cbad93d5 100644 --- a/lib/freedom_patches/pluck_first.rb +++ b/lib/freedom_patches/pluck_first.rb @@ -2,15 +2,17 @@ class ActiveRecord::Relation def pluck_first(*attributes) - limit(1).pluck(*attributes).first + Discourse.deprecate("`#pluck_first` is deprecated, use `#pick` instead.") + pick(*attributes) end def pluck_first!(*attributes) - items = limit(1).pluck(*attributes) + Discourse.deprecate("`#pluck_first!` is deprecated without replacement.") + items = pick(*attributes) - raise_record_not_found_exception! if items.empty? + raise_record_not_found_exception! if items.nil? - items.first + items end end diff --git a/lib/freedom_patches/rails4.rb b/lib/freedom_patches/rails4.rb index 806813b7c87..e5751d63130 100644 --- a/lib/freedom_patches/rails4.rb +++ b/lib/freedom_patches/rails4.rb @@ -1,95 +1,27 @@ # frozen_string_literal: true -# Sam: This has now forked of rails. Trouble is we would never like to use "about 1 month" ever, we only want months for 2 or more months. -# -# Backporting a fix to rails itself may get too complex module FreedomPatches module Rails4 - def self.distance_of_time_in_words( - from_time, - to_time = 0, - include_seconds = false, - options = {} - ) - options = { scope: :"datetime.distance_in_words" }.merge!(options) + def self.distance_of_time_in_words(*args) + Discourse.deprecate( + "FreedomPatches::Rails4.distance_of_time_in_words has moved to AgeWords.distance_of_time_in_words", + output_in_test: true, + since: "3.1.0.beta5", + drop_from: "3.2.0.beta1", + ) - from_time = from_time.to_time if from_time.respond_to?(:to_time) - to_time = to_time.to_time if to_time.respond_to?(:to_time) - distance = (to_time.to_f - from_time.to_f).abs - distance_in_minutes = (distance / 60.0).round - distance_in_seconds = distance.round - - I18n.with_options locale: options[:locale], scope: options[:scope] do |locale| - case distance_in_minutes - when 0..1 - unless include_seconds - return( - ( - if distance_in_minutes == 0 - locale.t(:less_than_x_minutes, count: 1) - else - locale.t(:x_minutes, count: distance_in_minutes) - end - ) - ) - end - - case distance_in_seconds - when 0..4 - locale.t :less_than_x_seconds, count: 5 - when 5..9 - locale.t :less_than_x_seconds, count: 10 - when 10..19 - locale.t :less_than_x_seconds, count: 20 - when 20..39 - locale.t :half_a_minute - when 40..59 - locale.t :less_than_x_minutes, count: 1 - else - locale.t :x_minutes, count: 1 - end - when 2..44 - locale.t :x_minutes, count: distance_in_minutes - when 45..89 - locale.t :about_x_hours, count: 1 - when 90..1439 - locale.t :about_x_hours, count: (distance_in_minutes.to_f / 60.0).round - when 1440..2519 - locale.t :x_days, count: 1 - - # this is were we diverge from Rails - when 2520..129_599 - locale.t :x_days, count: (distance_in_minutes.to_f / 1440.0).round - when 129_600..525_599 - locale.t :x_months, count: (distance_in_minutes.to_f / 43200.0).round - else - fyear = from_time.year - fyear += 1 if from_time.month >= 3 - tyear = to_time.year - tyear -= 1 if to_time.month < 3 - leap_years = (fyear > tyear) ? 0 : (fyear..tyear).count { |x| Date.leap?(x) } - minute_offset_for_leap_year = leap_years * 1440 - # Discount the leap year days when calculating year distance. - # e.g. if there are 20 leap year days between 2 dates having the same day - # and month then the based on 365 days calculation - # the distance in years will come out to over 80 years when in written - # english it would read better as about 80 years. - minutes_with_offset = distance_in_minutes - minute_offset_for_leap_year - remainder = (minutes_with_offset % 525_600) - distance_in_years = (minutes_with_offset / 525_600) - if remainder < 131_400 - locale.t(:about_x_years, count: distance_in_years) - elsif remainder < 394_200 - locale.t(:over_x_years, count: distance_in_years) - else - locale.t(:almost_x_years, count: distance_in_years + 1) - end - end - end + AgeWords.distance_of_time_in_words(*args) end - def self.time_ago_in_words(from_time, include_seconds = false, options = {}) - distance_of_time_in_words(from_time, Time.now, include_seconds, options) + def self.time_ago_in_words(*args) + Discourse.deprecate( + "FreedomPatches::Rails4.time_ago_in_words has moved to AgeWords.time_ago_in_words", + output_in_test: true, + since: "3.1.0.beta5", + drop_from: "3.2.0.beta1", + ) + + AgeWords.time_ago_in_words(*args) end end end diff --git a/lib/freedom_patches/rails_multisite.rb b/lib/freedom_patches/rails_multisite.rb index 0a78baaf320..608bf580b03 100644 --- a/lib/freedom_patches/rails_multisite.rb +++ b/lib/freedom_patches/rails_multisite.rb @@ -12,18 +12,17 @@ module RailsMultisite reading_role = :"#{db}_#{ActiveRecord.reading_role}" spec = RailsMultisite::ConnectionManagement.connection_spec(db: db) + handler = ActiveRecord::Base.connection_handler - ActiveRecord::Base.connection_handlers[reading_role] ||= begin - handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new - RailsFailover::ActiveRecord.establish_reading_connection(handler, spec) - handler - end - + RailsFailover::ActiveRecord.establish_reading_connection( + handler, + spec.to_hash, + role: reading_role, + ) ActiveRecord::Base.connected_to(role: reading_role) { yield(db) if block_given? } rescue => e STDERR.puts "URGENT: Failed to initialize site #{db}: " \ "#{e.class} #{e.message}\n#{e.backtrace.join("\n")}" - # the show must go on, don't stop startup if multisite fails end end diff --git a/lib/freedom_patches/sprockets_patches.rb b/lib/freedom_patches/sprockets_patches.rb index b9fb4974143..449e94de35c 100644 --- a/lib/freedom_patches/sprockets_patches.rb +++ b/lib/freedom_patches/sprockets_patches.rb @@ -36,3 +36,26 @@ if Rails.env.development? || Rails.env.test? alias_method :public_compute_asset_path, :compute_asset_path end end + +# By default, the Sprockets DirectiveProcessor introduces a newline between possible 'header' comments +# and the rest of the JS file. (https://github.com/rails/sprockets/blob/f4d3dae71e/lib/sprockets/directive_processor.rb#L121) +# This causes sourcemaps to be offset by 1 line, and therefore breaks browser tooling. +# We know that Ember-Cli assets do not use Sprockets directives, so we can totally bypass the DirectiveProcessor for those files. +Sprockets::DirectiveProcessor.prepend( + Module.new do + def process_source(source) + return source, [] if EmberCli.is_ember_cli_asset?(File.basename(@filename)) + super + end + end, +) + +# Skip digest path for workbox assets. They are already in a folder with a digest in the name. +Sprockets::Asset.prepend( + Module.new do + def digest_path + return logical_path if logical_path.match?(%r{^workbox-.*/}) + super + end + end, +) diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb index b13464012b5..047a4754506 100644 --- a/lib/freedom_patches/translate_accelerator.rb +++ b/lib/freedom_patches/translate_accelerator.rb @@ -39,7 +39,7 @@ module I18n if @loaded_locales.empty? # load all rb files - I18n.backend.load_translations(I18n.load_path.grep(/\.rb$/)) + I18n.backend.load_translations(I18n.load_path.grep(/\.rb\z/)) # load plural rules from plugins DiscoursePluginRegistry.locales.each do |plugin_locale, options| @@ -50,14 +50,14 @@ module I18n end # load it - I18n.backend.load_translations(I18n.load_path.grep(/\.#{Regexp.escape locale}\.yml$/)) + I18n.backend.load_translations(I18n.load_path.grep(/\.#{Regexp.escape locale}\.yml\z/)) if Discourse.allow_dev_populate? I18n.backend.load_translations( - I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}\.yml$}), + I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}\.yml\z}), ) I18n.backend.load_translations( - I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}/.*\.yml$}), + I18n.load_path.grep(%r{.*faker.*/#{Regexp.escape locale}/.*\.yml\z}), ) end @@ -148,7 +148,7 @@ module I18n elsif should_raise raise I18n::MissingTranslationData.new(locale, key) else - -"translation missing: #{locale}.#{key}" + -"Translation missing: #{locale}.#{key}" end end diff --git a/lib/git_repo.rb b/lib/git_repo.rb new file mode 100644 index 00000000000..4d8dcb952ac --- /dev/null +++ b/lib/git_repo.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class GitRepo + attr_reader :path, :name + + def initialize(path, name = nil) + @path = path + @name = name + @memoize = {} + end + + def url + url = run("config --get remote.origin.url") + return if url.blank? + + url.sub!(/\Agit@github\.com:/, "https://github.com/") + url.sub!(/\.git\z/, "") + url + end + + def latest_local_commit + run "rev-parse HEAD" + end + + protected + + def run(cmd) + @memoize[cmd] ||= begin + cmd = "git #{cmd}".split(" ") + Discourse::Utils.execute_command(*cmd, chdir: path).strip + rescue => e + Discourse.warn_exception(e, message: "Error running git command: #{cmd} in #{path}") + nil + end + end +end diff --git a/lib/git_url.rb b/lib/git_url.rb index 90eefc0eb95..f991d585c2c 100644 --- a/lib/git_url.rb +++ b/lib/git_url.rb @@ -10,7 +10,7 @@ module GitUrl end if url.start_with?("https://github.com/") && !url.end_with?(".git") - url = url.gsub(%r{/$}, "") + url = url.gsub(%r{/\z}, "") url += ".git" end diff --git a/lib/global_path.rb b/lib/global_path.rb index 318aa346f30..fb0ad36239e 100644 --- a/lib/global_path.rb +++ b/lib/global_path.rb @@ -12,7 +12,7 @@ module GlobalPath def upload_cdn_path(p) p = Discourse.store.cdn_url(p) if SiteSetting.Upload.s3_cdn_url.present? - (p =~ /^http/ || p =~ %r{^//}) ? p : cdn_path(p) + (p =~ /\Ahttp/ || p =~ %r{\A//}) ? p : cdn_path(p) end def cdn_relative_path(path) diff --git a/lib/group_lookup.rb b/lib/group_lookup.rb new file mode 100644 index 00000000000..cd942a80c0a --- /dev/null +++ b/lib/group_lookup.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class GroupLookup + def initialize(group_ids = []) + @group_ids = group_ids.flatten.compact.uniq + end + + # Lookup a group by id + def [](group_id) + group_names[group_id] + end + + private + + def group_names + @group_names ||= Group.where(id: @group_ids).pluck(:id, :name).to_h + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb index 1a9b2dd52f3..cfc9e345490 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -1,26 +1,28 @@ # frozen_string_literal: true +require "guardian/bookmark_guardian" require "guardian/category_guardian" require "guardian/ensure_magic" +require "guardian/group_guardian" require "guardian/post_guardian" -require "guardian/bookmark_guardian" +require "guardian/post_revision_guardian" +require "guardian/sidebar_guardian" +require "guardian/tag_guardian" require "guardian/topic_guardian" require "guardian/user_guardian" -require "guardian/post_revision_guardian" -require "guardian/group_guardian" -require "guardian/tag_guardian" # The guardian is responsible for confirming access to various site resources and operations class Guardian - include EnsureMagic - include CategoryGuardian - include PostGuardian include BookmarkGuardian + include CategoryGuardian + include EnsureMagic + include GroupGuardian + include PostGuardian + include PostRevisionGuardian + include SidebarGuardian + include TagGuardian include TopicGuardian include UserGuardian - include PostRevisionGuardian - include GroupGuardian - include TagGuardian class AnonymousUser def blank? @@ -160,7 +162,7 @@ class Guardian def can_see?(obj) if obj see_method = method_name_for :see, obj - (see_method ? public_send(see_method, obj) : true) + see_method && public_send(see_method, obj) end end @@ -230,7 +232,11 @@ class Guardian end def can_delete_reviewable_queued_post?(reviewable) - reviewable.present? && authenticated? && reviewable.created_by_id == @user.id + return false if reviewable.blank? + return false if !authenticated? + return true if is_api? && is_admin? + + reviewable.created_by_id == @user.id end def can_see_group?(group) @@ -295,7 +301,7 @@ class Guardian # Can we impersonate this user? def can_impersonate?(target) - target && + GlobalSetting.allow_impersonation && target && # You must be an admin to impersonate is_admin? && # You may not impersonate other admins unless you are a dev @@ -366,8 +372,7 @@ class Guardian def can_use_flair_group?(user, group_id = nil) return false if !user || !group_id || !user.group_ids.include?(group_id.to_i) - flair_icon, flair_upload_id = - Group.where(id: group_id.to_i).pluck_first(:flair_icon, :flair_upload_id) + flair_icon, flair_upload_id = Group.where(id: group_id.to_i).pick(:flair_icon, :flair_upload_id) flair_icon.present? || flair_upload_id.present? end @@ -615,10 +620,14 @@ class Guardian private def is_my_own?(obj) - unless anonymous? - return obj.user_id == @user.id if obj.respond_to?(:user_id) && obj.user_id && @user.id - return obj.user == @user if obj.respond_to?(:user) + if anonymous? + return( + SiteSetting.allow_anonymous_likes? && obj.class == PostAction && obj.is_like? && + obj.user_id == @user.id + ) end + return obj.user_id == @user.id if obj.respond_to?(:user_id) && obj.user_id && @user.id + return obj.user == @user if obj.respond_to?(:user) false end @@ -649,6 +658,10 @@ class Guardian end end + def is_api? + @user && request&.env&.dig(Auth::DefaultCurrentUserProvider::API_KEY_ENV) + end + protected def category_group_moderation_allowed? diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index e437455dfc5..62243d7ba2f 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -49,7 +49,6 @@ module CategoryGuardian return false unless category return false if is_anonymous? return true if is_admin? - return true if !category.read_restricted Category.post_create_allowed(self).exists?(id: category.id) end diff --git a/lib/guardian/ensure_magic.rb b/lib/guardian/ensure_magic.rb index 62cece83b61..3a2dbdea657 100644 --- a/lib/guardian/ensure_magic.rb +++ b/lib/guardian/ensure_magic.rb @@ -3,7 +3,7 @@ # Support for ensure_{blah}! methods. module EnsureMagic def method_missing(method, *args, &block) - if method.to_s =~ /^ensure_(.*)\!$/ + if method.to_s =~ /\Aensure_(.*)\!\z/ can_method = :"#{Regexp.last_match[1]}?" if respond_to?(can_method) diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 6ca1256896d..7c775b5453a 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -25,7 +25,7 @@ module PostGuardian # Can the user act on the post in a particular way. # taken_actions = the list of actions the user has already taken def post_can_act?(post, action_key, opts: {}, can_see_post: nil) - return false unless (can_see_post.nil? && can_see_post?(post)) || can_see_post + return false if !(can_see_post.nil? && can_see_post?(post)) && !can_see_post # no warnings except for staff if action_key == :notify_user && @@ -43,7 +43,10 @@ module PostGuardian already_did_flagging = taken.any? && (taken & PostActionType.notify_flag_types.values).any? result = - if authenticated? && post && !@user.anonymous? + if authenticated? && post + # Allow anonymous users to like if feature is enabled and short-circuit otherwise + return SiteSetting.allow_anonymous_likes? && (action_key == :like) if @user.anonymous? + # Silenced users can't flag return false if is_flag && @user.silenced? @@ -129,11 +132,7 @@ module PostGuardian # Must be staff to edit a locked post return false if post.locked? && !is_staff? - if ( - is_staff? || - (SiteSetting.trusted_users_can_edit_others? && @user.has_trust_level?(TrustLevel[4])) || - is_category_group_moderator?(post.topic&.category) - ) + if (is_staff? || is_in_edit_post_groups? || is_category_group_moderator?(post.topic&.category)) return can_create_post?(post.topic) end @@ -173,6 +172,11 @@ module PostGuardian false end + def is_in_edit_post_groups? + SiteSetting.edit_all_post_groups.present? && + user.in_any_groups?(SiteSetting.edit_all_post_groups.to_s.split("|").map(&:to_i)) + end + def can_edit_hidden_post?(post) return false if post.nil? post.hidden_at.nil? || @@ -191,6 +195,8 @@ module PostGuardian return true if is_staff? || is_category_group_moderator?(post.topic&.category) + return true if SiteSetting.tl4_delete_posts_and_topics && user.has_trust_level?(TrustLevel[4]) + # Can't delete posts in archived topics unless you are staff return false if post.topic&.archived? @@ -247,6 +253,18 @@ module PostGuardian !post_action.post&.topic&.archived? end + def can_receive_post_notifications?(post) + return false if !authenticated? + + if is_admin? && SiteSetting.suppress_secured_categories_from_admin + topic = post.topic + if !topic.private_message? && topic.category.read_restricted + return secure_category_ids.include?(topic.category_id) + end + end + can_see_post?(post) + end + def can_see_post?(post) return false if post.blank? return true if is_admin? @@ -255,7 +273,10 @@ module PostGuardian return false end return true if is_moderator? || is_category_group_moderator?(post.topic.category) - return true if !post.trashed? || can_see_deleted_post?(post) + if (!post.trashed? || can_see_deleted_post?(post)) && + (!post.hidden? || can_see_hidden_post?(post)) + return true + end false end @@ -266,6 +287,15 @@ module PostGuardian post.deleted_by_id == @user.id && @user.has_trust_level?(TrustLevel[4]) end + def can_see_hidden_post?(post) + if SiteSetting.hidden_post_visible_groups_map.include?(Group::AUTO_GROUPS[:everyone]) + return true + end + return false if anonymous? + return true if is_staff? + post.user_id == @user.id || @user.in_any_groups?(SiteSetting.hidden_post_visible_groups_map) + end + def can_view_edit_history?(post) return false unless post @@ -311,7 +341,8 @@ module PostGuardian end def can_see_deleted_posts?(category = nil) - is_staff? || is_category_group_moderator?(category) + is_staff? || is_category_group_moderator?(category) || + (SiteSetting.tl4_delete_posts_and_topics && @user.has_trust_level?(TrustLevel[4])) end def can_view_raw_email?(post) diff --git a/lib/guardian/post_revision_guardian.rb b/lib/guardian/post_revision_guardian.rb index 1e61b19e746..9d2108ba2eb 100644 --- a/lib/guardian/post_revision_guardian.rb +++ b/lib/guardian/post_revision_guardian.rb @@ -13,6 +13,10 @@ module PostRevisionGuardian is_staff? end + def can_permanently_delete_post_revisions? + is_staff? && SiteSetting.can_permanently_delete + end + def can_show_post_revision?(post_revision) is_staff? end diff --git a/lib/guardian/sidebar_guardian.rb b/lib/guardian/sidebar_guardian.rb new file mode 100644 index 00000000000..855733b1862 --- /dev/null +++ b/lib/guardian/sidebar_guardian.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SidebarGuardian + def can_create_public_sidebar_section? + @user.admin? + end + + def can_edit_sidebar_section?(sidebar_section) + return @user.admin? if sidebar_section.public? + return @user.admin? if sidebar_section.section_type + is_my_own?(sidebar_section) + end + + def can_delete_sidebar_section?(sidebar_section) + return false if sidebar_section.section_type.present? + return @user.admin? if sidebar_section.public? + is_my_own?(sidebar_section) + end +end diff --git a/lib/guardian/tag_guardian.rb b/lib/guardian/tag_guardian.rb index db1ec7688ce..f6fd968e984 100644 --- a/lib/guardian/tag_guardian.rb +++ b/lib/guardian/tag_guardian.rb @@ -2,6 +2,10 @@ #mixin for all guardian methods dealing with tagging permissions module TagGuardian + def can_see_tag?(_tag) + true + end + def can_create_tag? SiteSetting.tagging_enabled && @user.has_trust_level_or_staff?(SiteSetting.min_trust_to_create_tag) diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 05fb6ad041d..0f8782f3bbf 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -119,18 +119,16 @@ module TopicGuardian return true end - # TL4 users can edit archived topics, but can not edit private messages if ( - SiteSetting.trusted_users_can_edit_others? && topic.archived && !topic.private_message? && - user.has_trust_level?(TrustLevel[4]) && can_create_post?(topic) + is_in_edit_post_groups? && topic.archived && !topic.private_message? && + can_create_post?(topic) ) return true end - # TL3 users can not edit archived topics and private messages if ( - SiteSetting.trusted_users_can_edit_others? && !topic.archived && !topic.private_message? && - user.has_trust_level?(TrustLevel[3]) && can_create_post?(topic) + is_in_edit_topic_groups? && !topic.archived && !topic.private_message? && + can_create_post?(topic) ) return true end @@ -141,8 +139,14 @@ module TopicGuardian (!first_post&.hidden? || can_edit_hidden_post?(first_post)) end + def is_in_edit_topic_groups? + SiteSetting.edit_all_topic_groups.present? && + user.in_any_groups?(SiteSetting.edit_all_topic_groups.to_s.split("|").map(&:to_i)) + end + def can_recover_topic?(topic) - if is_staff? || (topic&.category && is_category_group_moderator?(topic.category)) + if is_staff? || (topic&.category && is_category_group_moderator?(topic.category)) || + (SiteSetting.tl4_delete_posts_and_topics && user&.has_trust_level?(TrustLevel[4])) !!(topic && topic.deleted_at) else topic && can_recover_post?(topic.ordered_posts.first) @@ -156,7 +160,8 @@ module TopicGuardian ( is_my_own?(topic) && topic.posts_count <= 1 && topic.created_at && topic.created_at > 24.hours.ago - ) || is_category_group_moderator?(topic.category) + ) || is_category_group_moderator?(topic.category) || + (SiteSetting.tl4_delete_posts_and_topics && user.has_trust_level?(TrustLevel[4])) ) && !topic.is_category_topic? && !Discourse.static_doc_topic_ids.include?(topic.id) end @@ -209,7 +214,8 @@ module TopicGuardian end def can_see_deleted_topics?(category) - is_staff? || is_category_group_moderator?(category) + is_staff? || is_category_group_moderator?(category) || + (SiteSetting.tl4_delete_posts_and_topics && user&.has_trust_level?(TrustLevel[4])) end # Accepts an array of `Topic#id` and returns an array of `Topic#id` which the user can see. @@ -277,31 +283,33 @@ module TopicGuardian ) end + def can_see_unlisted_topics? + is_staff? || @user.has_trust_level?(TrustLevel[4]) + end + def can_get_access_to_topic?(topic) topic&.access_topic_via_group.present? && authenticated? end - def filter_allowed_categories(records) + def filter_allowed_categories(records, category_id_column: "topics.category_id") return records if is_admin? && !SiteSetting.suppress_secured_categories_from_admin records = - ( - if allowed_category_ids.size == 0 - records.where("topics.category_id IS NULL") - else - records.where( - "topics.category_id IS NULL or topics.category_id IN (?)", - allowed_category_ids, - ) - end - ) + if allowed_category_ids.size == 0 + records.where("#{category_id_column} IS NULL") + else + records.where( + "#{category_id_column} IS NULL or #{category_id_column} IN (?)", + allowed_category_ids, + ) + end records.references(:categories) end def can_edit_featured_link?(category_id) return false unless SiteSetting.topic_featured_link_enabled - return false unless @user.trust_level >= TrustLevel.levels[:basic] + return false if @user.trust_level == TrustLevel.levels[:newuser] Category.where( id: category_id || SiteSetting.uncategorized_category_id, topic_featured_link_allowed: true, diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 2879ad036f9..5f605adab5e 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -74,7 +74,7 @@ module UserGuardian end def can_anonymize_user?(user) - is_staff? && !user.nil? && !user.staff? + is_staff? && !user.nil? && !user.staff? && !user.email&.ends_with?(UserAnonymizer::EMAIL_SUFFIX) end def can_merge_user?(user) @@ -118,6 +118,10 @@ module UserGuardian user && can_administer_user?(user) end + def can_see_user?(_user) + true + end + def can_see_profile?(user) return false if user.blank? return true if !SiteSetting.allow_users_to_hide_profile? diff --git a/lib/html_prettify.rb b/lib/html_prettify.rb index 074a3c8a2a5..f56665a96a8 100644 --- a/lib/html_prettify.rb +++ b/lib/html_prettify.rb @@ -249,8 +249,8 @@ class HtmlPrettify < String # Special case if the very first character is a quote followed by # punctuation at a non-word-break. Close the quotes by brute # force: - str.gsub!(/^'(?=#{punct_class}\B)/, entity(:single_right_quote)) - str.gsub!(/^"(?=#{punct_class}\B)/, entity(:double_right_quote)) + str.gsub!(/\A'(?=#{punct_class}\B)/, entity(:single_right_quote)) + str.gsub!(/\A"(?=#{punct_class}\B)/, entity(:double_right_quote)) # Special case for double sets of quotes, e.g.: #

    He said, "'Quoted' words in a larger quote."

    diff --git a/lib/i18n/locale_file_checker.rb b/lib/i18n/locale_file_checker.rb index de9eae3056c..e0aa83028fa 100644 --- a/lib/i18n/locale_file_checker.rb +++ b/lib/i18n/locale_file_checker.rb @@ -19,8 +19,8 @@ class LocaleFileChecker @relative_locale_path = Pathname.new(locale_path).relative_path_from(Pathname.new(Rails.root)).to_s - @locale_yaml = YAML.load_file(locale_path) - @reference_yaml = YAML.load_file(reference_path) + @locale_yaml = YAML.load_file(locale_path, aliases: true) + @reference_yaml = YAML.load_file(reference_path, aliases: true) next if @locale_yaml.blank? || @locale_yaml.first[1].blank? @@ -49,7 +49,7 @@ class LocaleFileChecker end def reference_file(path) - path = path.gsub(/\.\w{2,}\.yml$/, ".#{REFERENCE_LOCALE}.yml") + path = path.gsub(/\.\w{2,}\.yml\z/, ".#{REFERENCE_LOCALE}.yml") path if File.exist?(path) end diff --git a/lib/javascripts/messageformat.js b/lib/javascripts/messageformat.js deleted file mode 100644 index 6626d51a168..00000000000 --- a/lib/javascripts/messageformat.js +++ /dev/null @@ -1,1593 +0,0 @@ -/** - * messageformat.js - * - * ICU PluralFormat + SelectFormat for JavaScript - * - * @author Alex Sexton - @SlexAxton - * @version 0.1.5 - * @license WTFPL - * @contributor_license Dojo CLA -*/ -(function ( root ) { - - // Create the constructor function - function MessageFormat ( locale, pluralFunc ) { - var fallbackLocale; - - if ( locale && pluralFunc ) { - MessageFormat.locale[ locale ] = pluralFunc; - } - - // Defaults - fallbackLocale = locale = locale || "en"; - pluralFunc = pluralFunc || MessageFormat.locale[ fallbackLocale = MessageFormat.Utils.getFallbackLocale( locale ) ]; - - if ( ! pluralFunc ) { - throw new Error( "Plural Function not found for locale: " + locale ); - } - - // Own Properties - this.pluralFunc = pluralFunc; - this.locale = locale; - this.fallbackLocale = fallbackLocale; - } - - // Set up the locales object. Add in english by default - MessageFormat.locale = { - "en" : function ( n ) { - if ( n === 1 ) { - return "one"; - } - return "other"; - } - }; - - // Build out our basic SafeString type - // more or less stolen from Handlebars by @wycats - MessageFormat.SafeString = function( string ) { - this.string = string; - }; - - MessageFormat.SafeString.prototype.toString = function () { - return this.string.toString(); - }; - - MessageFormat.Utils = { - numSub : function ( string, key, depth ) { - // make sure that it's not an escaped octothorpe - return string.replace( /^#|[^\\]#/g, function (m) { - var prefix = m && m.length === 2 ? m.charAt(0) : ''; - return prefix + '" + (function(){ var x = ' + - key+';\nif( isNaN(x) ){\nthrow new Error("MessageFormat: `"+lastkey_'+depth+'+"` isnt a number.");\n}\nreturn x;\n})() + "'; - }); - }, - escapeExpression : function (string) { - var escape = { - "\n": "\\n", - "\"": '\\"' - }, - badChars = /[\n"]/g, - possible = /[\n"]/, - escapeChar = function(chr) { - return escape[chr] || "&"; - }; - - // Don't escape SafeStrings, since they're already safe - if ( string instanceof MessageFormat.SafeString ) { - return string.toString(); - } - else if ( string === null || string === false ) { - return ""; - } - - if ( ! possible.test( string ) ) { - return string; - } - return string.replace( badChars, escapeChar ); - }, - getFallbackLocale: function( locale ) { - var tagSeparator = locale.indexOf("-") >= 0 ? "-" : "_"; - - // Lets just be friends, fallback through the language tags - while ( ! MessageFormat.locale.hasOwnProperty( locale ) ) { - locale = locale.substring(0, locale.lastIndexOf( tagSeparator )); - if (locale.length === 0) { - return null; - } - } - - return locale; - } - }; - - // This is generated and pulled in for browsers. - var mparser = (function(){ - /* - * Generated by PEG.js 0.7.0. - * - * http://pegjs.majda.cz/ - */ - - function quote(s) { - /* - * ECMA-262, 5th ed., 7.8.4: All characters may appear literally in a - * string literal except for the closing quote character, backslash, - * carriage return, line separator, paragraph separator, and line feed. - * Any character may appear in the form of an escape sequence. - * - * For portability, we also escape escape all control and non-ASCII - * characters. Note that "\0" and "\v" escape sequences are not used - * because JSHint does not like the first and IE the second. - */ - return '"' + s - .replace(/\\/g, '\\\\') // backslash - .replace(/"/g, '\\"') // closing quote character - .replace(/\x08/g, '\\b') // backspace - .replace(/\t/g, '\\t') // horizontal tab - .replace(/\n/g, '\\n') // line feed - .replace(/\f/g, '\\f') // form feed - .replace(/\r/g, '\\r') // carriage return - .replace(/[\x00-\x07\x0B\x0E-\x1F\x80-\uFFFF]/g, escape) - + '"'; - } - - var result = { - /* - * Parses the input with a generated parser. If the parsing is successful, - * returns a value explicitly or implicitly specified by the grammar from - * which the parser was generated (see |PEG.buildParser|). If the parsing is - * unsuccessful, throws |PEG.parser.SyntaxError| describing the error. - */ - parse: function(input, startRule) { - var parseFunctions = { - "start": parse_start, - "messageFormatPattern": parse_messageFormatPattern, - "messageFormatPatternRight": parse_messageFormatPatternRight, - "messageFormatElement": parse_messageFormatElement, - "elementFormat": parse_elementFormat, - "pluralStyle": parse_pluralStyle, - "selectStyle": parse_selectStyle, - "pluralFormatPattern": parse_pluralFormatPattern, - "offsetPattern": parse_offsetPattern, - "selectFormatPattern": parse_selectFormatPattern, - "pluralForms": parse_pluralForms, - "stringKey": parse_stringKey, - "string": parse_string, - "id": parse_id, - "chars": parse_chars, - "char": parse_char, - "digits": parse_digits, - "hexDigit": parse_hexDigit, - "_": parse__, - "whitespace": parse_whitespace - }; - - if (startRule !== undefined) { - if (parseFunctions[startRule] === undefined) { - throw new Error("Invalid rule name: " + quote(startRule) + "."); - } - } else { - startRule = "start"; - } - - var pos = 0; - var reportFailures = 0; - var rightmostFailuresPos = 0; - var rightmostFailuresExpected = []; - - function padLeft(input, padding, length) { - var result = input; - - var padLength = length - input.length; - for (var i = 0; i < padLength; i++) { - result = padding + result; - } - - return result; - } - - function escape(ch) { - var charCode = ch.charCodeAt(0); - var escapeChar; - var length; - - if (charCode <= 0xFF) { - escapeChar = 'x'; - length = 2; - } else { - escapeChar = 'u'; - length = 4; - } - - return '\\' + escapeChar + padLeft(charCode.toString(16).toUpperCase(), '0', length); - } - - function matchFailed(failure) { - if (pos < rightmostFailuresPos) { - return; - } - - if (pos > rightmostFailuresPos) { - rightmostFailuresPos = pos; - rightmostFailuresExpected = []; - } - - rightmostFailuresExpected.push(failure); - } - - function parse_start() { - var result0; - var pos0; - - pos0 = pos; - result0 = parse_messageFormatPattern(); - if (result0 !== null) { - result0 = (function(offset, messageFormatPattern) { return { type: "program", program: messageFormatPattern }; })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_messageFormatPattern() { - var result0, result1, result2; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse_string(); - if (result0 !== null) { - result1 = []; - result2 = parse_messageFormatPatternRight(); - while (result2 !== null) { - result1.push(result2); - result2 = parse_messageFormatPatternRight(); - } - if (result1 !== null) { - result0 = [result0, result1]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, s1, inner) { - var st = []; - if ( s1 && s1.val ) { - st.push( s1 ); - } - for( var i in inner ){ - if ( inner.hasOwnProperty( i ) ) { - st.push( inner[ i ] ); - } - } - return { type: 'messageFormatPattern', statements: st }; - })(pos0, result0[0], result0[1]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_messageFormatPatternRight() { - var result0, result1, result2, result3, result4, result5; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - if (input.charCodeAt(pos) === 123) { - result0 = "{"; - pos++; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"{\""); - } - } - if (result0 !== null) { - result1 = parse__(); - if (result1 !== null) { - result2 = parse_messageFormatElement(); - if (result2 !== null) { - result3 = parse__(); - if (result3 !== null) { - if (input.charCodeAt(pos) === 125) { - result4 = "}"; - pos++; - } else { - result4 = null; - if (reportFailures === 0) { - matchFailed("\"}\""); - } - } - if (result4 !== null) { - result5 = parse_string(); - if (result5 !== null) { - result0 = [result0, result1, result2, result3, result4, result5]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, mfe, s1) { - var res = []; - if ( mfe ) { - res.push(mfe); - } - if ( s1 && s1.val ) { - res.push( s1 ); - } - return { type: "messageFormatPatternRight", statements : res }; - })(pos0, result0[2], result0[5]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_messageFormatElement() { - var result0, result1, result2; - var pos0, pos1, pos2; - - pos0 = pos; - pos1 = pos; - result0 = parse_id(); - if (result0 !== null) { - pos2 = pos; - if (input.charCodeAt(pos) === 44) { - result1 = ","; - pos++; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("\",\""); - } - } - if (result1 !== null) { - result2 = parse_elementFormat(); - if (result2 !== null) { - result1 = [result1, result2]; - } else { - result1 = null; - pos = pos2; - } - } else { - result1 = null; - pos = pos2; - } - result1 = result1 !== null ? result1 : ""; - if (result1 !== null) { - result0 = [result0, result1]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, argIdx, efmt) { - var res = { - type: "messageFormatElement", - argumentIndex: argIdx - }; - if ( efmt && efmt.length ) { - res.elementFormat = efmt[1]; - } - else { - res.output = true; - } - return res; - })(pos0, result0[0], result0[1]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_elementFormat() { - var result0, result1, result2, result3, result4, result5, result6; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - if (input.substr(pos, 6) === "plural") { - result1 = "plural"; - pos += 6; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("\"plural\""); - } - } - if (result1 !== null) { - result2 = parse__(); - if (result2 !== null) { - if (input.charCodeAt(pos) === 44) { - result3 = ","; - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("\",\""); - } - } - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result5 = parse_pluralStyle(); - if (result5 !== null) { - result6 = parse__(); - if (result6 !== null) { - result0 = [result0, result1, result2, result3, result4, result5, result6]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, t, s) { - return { - type : "elementFormat", - key : t, - val : s.val - }; - })(pos0, result0[1], result0[5]); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - if (input.substr(pos, 6) === "select") { - result1 = "select"; - pos += 6; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("\"select\""); - } - } - if (result1 !== null) { - result2 = parse__(); - if (result2 !== null) { - if (input.charCodeAt(pos) === 44) { - result3 = ","; - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("\",\""); - } - } - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result5 = parse_selectStyle(); - if (result5 !== null) { - result6 = parse__(); - if (result6 !== null) { - result0 = [result0, result1, result2, result3, result4, result5, result6]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, t, s) { - return { - type : "elementFormat", - key : t, - val : s.val - }; - })(pos0, result0[1], result0[5]); - } - if (result0 === null) { - pos = pos0; - } - } - return result0; - } - - function parse_pluralStyle() { - var result0; - var pos0; - - pos0 = pos; - result0 = parse_pluralFormatPattern(); - if (result0 !== null) { - result0 = (function(offset, pfp) { - return { type: "pluralStyle", val: pfp }; - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_selectStyle() { - var result0; - var pos0; - - pos0 = pos; - result0 = parse_selectFormatPattern(); - if (result0 !== null) { - result0 = (function(offset, sfp) { - return { type: "selectStyle", val: sfp }; - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_pluralFormatPattern() { - var result0, result1, result2; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse_offsetPattern(); - result0 = result0 !== null ? result0 : ""; - if (result0 !== null) { - result1 = []; - result2 = parse_pluralForms(); - while (result2 !== null) { - result1.push(result2); - result2 = parse_pluralForms(); - } - if (result1 !== null) { - result0 = [result0, result1]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, op, pf) { - var res = { - type: "pluralFormatPattern", - pluralForms: pf - }; - if ( op ) { - res.offset = op; - } - else { - res.offset = 0; - } - return res; - })(pos0, result0[0], result0[1]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_offsetPattern() { - var result0, result1, result2, result3, result4, result5, result6; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - if (input.substr(pos, 6) === "offset") { - result1 = "offset"; - pos += 6; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("\"offset\""); - } - } - if (result1 !== null) { - result2 = parse__(); - if (result2 !== null) { - if (input.charCodeAt(pos) === 58) { - result3 = ":"; - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("\":\""); - } - } - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result5 = parse_digits(); - if (result5 !== null) { - result6 = parse__(); - if (result6 !== null) { - result0 = [result0, result1, result2, result3, result4, result5, result6]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, d) { - return d; - })(pos0, result0[5]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_selectFormatPattern() { - var result0, result1; - var pos0; - - pos0 = pos; - result0 = []; - result1 = parse_pluralForms(); - while (result1 !== null) { - result0.push(result1); - result1 = parse_pluralForms(); - } - if (result0 !== null) { - result0 = (function(offset, pf) { - return { - type: "selectFormatPattern", - pluralForms: pf - }; - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_pluralForms() { - var result0, result1, result2, result3, result4, result5, result6, result7; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - result1 = parse_stringKey(); - if (result1 !== null) { - result2 = parse__(); - if (result2 !== null) { - if (input.charCodeAt(pos) === 123) { - result3 = "{"; - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("\"{\""); - } - } - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result5 = parse_messageFormatPattern(); - if (result5 !== null) { - result6 = parse__(); - if (result6 !== null) { - if (input.charCodeAt(pos) === 125) { - result7 = "}"; - pos++; - } else { - result7 = null; - if (reportFailures === 0) { - matchFailed("\"}\""); - } - } - if (result7 !== null) { - result0 = [result0, result1, result2, result3, result4, result5, result6, result7]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, k, mfp) { - return { - type: "pluralForms", - key: k, - val: mfp - }; - })(pos0, result0[1], result0[5]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_stringKey() { - var result0, result1; - var pos0, pos1; - - pos0 = pos; - result0 = parse_id(); - if (result0 !== null) { - result0 = (function(offset, i) { - return i; - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - pos1 = pos; - if (input.charCodeAt(pos) === 61) { - result0 = "="; - pos++; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"=\""); - } - } - if (result0 !== null) { - result1 = parse_digits(); - if (result1 !== null) { - result0 = [result0, result1]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, d) { - return d; - })(pos0, result0[1]); - } - if (result0 === null) { - pos = pos0; - } - } - return result0; - } - - function parse_string() { - var result0, result1, result2, result3, result4; - var pos0, pos1, pos2; - - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - result1 = []; - pos2 = pos; - result2 = parse__(); - if (result2 !== null) { - result3 = parse_chars(); - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result2 = [result2, result3, result4]; - } else { - result2 = null; - pos = pos2; - } - } else { - result2 = null; - pos = pos2; - } - } else { - result2 = null; - pos = pos2; - } - while (result2 !== null) { - result1.push(result2); - pos2 = pos; - result2 = parse__(); - if (result2 !== null) { - result3 = parse_chars(); - if (result3 !== null) { - result4 = parse__(); - if (result4 !== null) { - result2 = [result2, result3, result4]; - } else { - result2 = null; - pos = pos2; - } - } else { - result2 = null; - pos = pos2; - } - } else { - result2 = null; - pos = pos2; - } - } - if (result1 !== null) { - result0 = [result0, result1]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, ws, s) { - var tmp = []; - for( var i = 0; i < s.length; ++i ) { - for( var j = 0; j < s[ i ].length; ++j ) { - tmp.push(s[i][j]); - } - } - return { - type: "string", - val: ws + tmp.join('') - }; - })(pos0, result0[0], result0[1]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_id() { - var result0, result1, result2, result3; - var pos0, pos1; - - pos0 = pos; - pos1 = pos; - result0 = parse__(); - if (result0 !== null) { - if (/^[a-zA-Z$_]/.test(input.charAt(pos))) { - result1 = input.charAt(pos); - pos++; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("[a-zA-Z$_]"); - } - } - if (result1 !== null) { - result2 = []; - if (/^[^ \t\n\r,.+={}]/.test(input.charAt(pos))) { - result3 = input.charAt(pos); - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("[^ \\t\\n\\r,.+={}]"); - } - } - while (result3 !== null) { - result2.push(result3); - if (/^[^ \t\n\r,.+={}]/.test(input.charAt(pos))) { - result3 = input.charAt(pos); - pos++; - } else { - result3 = null; - if (reportFailures === 0) { - matchFailed("[^ \\t\\n\\r,.+={}]"); - } - } - } - if (result2 !== null) { - result3 = parse__(); - if (result3 !== null) { - result0 = [result0, result1, result2, result3]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, s1, s2) { - return s1 + (s2 ? s2.join('') : ''); - })(pos0, result0[1], result0[2]); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_chars() { - var result0, result1; - var pos0; - - pos0 = pos; - result1 = parse_char(); - if (result1 !== null) { - result0 = []; - while (result1 !== null) { - result0.push(result1); - result1 = parse_char(); - } - } else { - result0 = null; - } - if (result0 !== null) { - result0 = (function(offset, chars) { return chars.join(''); })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_char() { - var result0, result1, result2, result3, result4; - var pos0, pos1; - - pos0 = pos; - if (/^[^{}\\\0-\x1F \t\n\r]/.test(input.charAt(pos))) { - result0 = input.charAt(pos); - pos++; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("[^{}\\\\\\0-\\x1F \\t\\n\\r]"); - } - } - if (result0 !== null) { - result0 = (function(offset, x) { - return x; - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - if (input.substr(pos, 2) === "\\#") { - result0 = "\\#"; - pos += 2; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"\\\\#\""); - } - } - if (result0 !== null) { - result0 = (function(offset) { - return "\\#"; - })(pos0); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - if (input.substr(pos, 2) === "\\{") { - result0 = "\\{"; - pos += 2; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"\\\\{\""); - } - } - if (result0 !== null) { - result0 = (function(offset) { - return "\u007B"; - })(pos0); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - if (input.substr(pos, 2) === "\\}") { - result0 = "\\}"; - pos += 2; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"\\\\}\""); - } - } - if (result0 !== null) { - result0 = (function(offset) { - return "\u007D"; - })(pos0); - } - if (result0 === null) { - pos = pos0; - } - if (result0 === null) { - pos0 = pos; - pos1 = pos; - if (input.substr(pos, 2) === "\\u") { - result0 = "\\u"; - pos += 2; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("\"\\\\u\""); - } - } - if (result0 !== null) { - result1 = parse_hexDigit(); - if (result1 !== null) { - result2 = parse_hexDigit(); - if (result2 !== null) { - result3 = parse_hexDigit(); - if (result3 !== null) { - result4 = parse_hexDigit(); - if (result4 !== null) { - result0 = [result0, result1, result2, result3, result4]; - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - } else { - result0 = null; - pos = pos1; - } - if (result0 !== null) { - result0 = (function(offset, h1, h2, h3, h4) { - return String.fromCharCode(parseInt("0x" + h1 + h2 + h3 + h4)); - })(pos0, result0[1], result0[2], result0[3], result0[4]); - } - if (result0 === null) { - pos = pos0; - } - } - } - } - } - return result0; - } - - function parse_digits() { - var result0, result1; - var pos0; - - pos0 = pos; - if (/^[0-9]/.test(input.charAt(pos))) { - result1 = input.charAt(pos); - pos++; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("[0-9]"); - } - } - if (result1 !== null) { - result0 = []; - while (result1 !== null) { - result0.push(result1); - if (/^[0-9]/.test(input.charAt(pos))) { - result1 = input.charAt(pos); - pos++; - } else { - result1 = null; - if (reportFailures === 0) { - matchFailed("[0-9]"); - } - } - } - } else { - result0 = null; - } - if (result0 !== null) { - result0 = (function(offset, ds) { - return parseInt((ds.join('')), 10); - })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - return result0; - } - - function parse_hexDigit() { - var result0; - - if (/^[0-9a-fA-F]/.test(input.charAt(pos))) { - result0 = input.charAt(pos); - pos++; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("[0-9a-fA-F]"); - } - } - return result0; - } - - function parse__() { - var result0, result1; - var pos0; - - reportFailures++; - pos0 = pos; - result0 = []; - result1 = parse_whitespace(); - while (result1 !== null) { - result0.push(result1); - result1 = parse_whitespace(); - } - if (result0 !== null) { - result0 = (function(offset, w) { return w.join(''); })(pos0, result0); - } - if (result0 === null) { - pos = pos0; - } - reportFailures--; - if (reportFailures === 0 && result0 === null) { - matchFailed("whitespace"); - } - return result0; - } - - function parse_whitespace() { - var result0; - - if (/^[ \t\n\r]/.test(input.charAt(pos))) { - result0 = input.charAt(pos); - pos++; - } else { - result0 = null; - if (reportFailures === 0) { - matchFailed("[ \\t\\n\\r]"); - } - } - return result0; - } - - - function cleanupExpected(expected) { - expected.sort(); - - var lastExpected = null; - var cleanExpected = []; - for (var i = 0; i < expected.length; i++) { - if (expected[i] !== lastExpected) { - cleanExpected.push(expected[i]); - lastExpected = expected[i]; - } - } - return cleanExpected; - } - - function computeErrorPosition() { - /* - * The first idea was to use |String.split| to break the input up to the - * error position along newlines and derive the line and column from - * there. However IE's |split| implementation is so broken that it was - * enough to prevent it. - */ - - var line = 1; - var column = 1; - var seenCR = false; - - for (var i = 0; i < Math.max(pos, rightmostFailuresPos); i++) { - var ch = input.charAt(i); - if (ch === "\n") { - if (!seenCR) { line++; } - column = 1; - seenCR = false; - } else if (ch === "\r" || ch === "\u2028" || ch === "\u2029") { - line++; - column = 1; - seenCR = true; - } else { - column++; - seenCR = false; - } - } - - return { line: line, column: column }; - } - - - var result = parseFunctions[startRule](); - - /* - * The parser is now in one of the following three states: - * - * 1. The parser successfully parsed the whole input. - * - * - |result !== null| - * - |pos === input.length| - * - |rightmostFailuresExpected| may or may not contain something - * - * 2. The parser successfully parsed only a part of the input. - * - * - |result !== null| - * - |pos < input.length| - * - |rightmostFailuresExpected| may or may not contain something - * - * 3. The parser did not successfully parse any part of the input. - * - * - |result === null| - * - |pos === 0| - * - |rightmostFailuresExpected| contains at least one failure - * - * All code following this comment (including called functions) must - * handle these states. - */ - if (result === null || pos !== input.length) { - var offset = Math.max(pos, rightmostFailuresPos); - var found = offset < input.length ? input.charAt(offset) : null; - var errorPosition = computeErrorPosition(); - - throw new this.SyntaxError( - cleanupExpected(rightmostFailuresExpected), - found, - offset, - errorPosition.line, - errorPosition.column - ); - } - - return result; - }, - - /* Returns the parser source code. */ - toSource: function() { return this._source; } - }; - - /* Thrown when a parser encounters a syntax error. */ - - result.SyntaxError = function(expected, found, offset, line, column) { - function buildMessage(expected, found) { - var expectedHumanized, foundHumanized; - - switch (expected.length) { - case 0: - expectedHumanized = "end of input"; - break; - case 1: - expectedHumanized = expected[0]; - break; - default: - expectedHumanized = expected.slice(0, expected.length - 1).join(", ") - + " or " - + expected[expected.length - 1]; - } - - foundHumanized = found ? quote(found) : "end of input"; - - return "Expected " + expectedHumanized + " but " + foundHumanized + " found."; - } - - this.name = "SyntaxError"; - this.expected = expected; - this.found = found; - this.message = buildMessage(expected, found); - this.offset = offset; - this.line = line; - this.column = column; - }; - - result.SyntaxError.prototype = Error.prototype; - - return result; - })(); - - MessageFormat.prototype.parse = function () { - // Bind to itself so error handling works - return mparser.parse.apply( mparser, arguments ); - }; - - MessageFormat.prototype.precompile = function ( ast ) { - var self = this, - needOther = false, - fp = { - begin: 'function(d){\nvar r = "";\n', - end : "return r;\n}" - }; - - function interpMFP ( ast, data ) { - // Set some default data - data = data || {}; - var s = '', i, tmp, lastkeyname; - - switch ( ast.type ) { - case 'program': - return interpMFP( ast.program ); - case 'messageFormatPattern': - for ( i = 0; i < ast.statements.length; ++i ) { - s += interpMFP( ast.statements[i], data ); - } - return fp.begin + s + fp.end; - case 'messageFormatPatternRight': - for ( i = 0; i < ast.statements.length; ++i ) { - s += interpMFP( ast.statements[i], data ); - } - return s; - case 'messageFormatElement': - data.pf_count = data.pf_count || 0; - s += 'if(!d){\nthrow new Error("MessageFormat: No data passed to function.");\n}\n'; - if ( ast.output ) { - s += 'r += d["' + ast.argumentIndex + '"];\n'; - } - else { - lastkeyname = 'lastkey_'+(data.pf_count+1); - s += 'var '+lastkeyname+' = "'+ast.argumentIndex+'";\n'; - s += 'var k_'+(data.pf_count+1)+'=d['+lastkeyname+'];\n'; - s += interpMFP( ast.elementFormat, data ); - } - return s; - case 'elementFormat': - if ( ast.key === 'select' ) { - s += interpMFP( ast.val, data ); - s += 'r += (pf_' + - data.pf_count + - '[ k_' + (data.pf_count+1) + ' ] || pf_'+data.pf_count+'[ "other" ])( d );\n'; - } - else if ( ast.key === 'plural' ) { - s += interpMFP( ast.val, data ); - s += 'if ( pf_'+(data.pf_count)+'[ k_'+(data.pf_count+1)+' + "" ] ) {\n'; - s += 'r += pf_'+data.pf_count+'[ k_'+(data.pf_count+1)+' + "" ]( d ); \n'; - s += '}\nelse {\n'; - s += 'r += (pf_' + - data.pf_count + - '[ MessageFormat.locale["' + - self.fallbackLocale + - '"]( k_'+(data.pf_count+1)+' - off_'+(data.pf_count)+' ) ] || pf_'+data.pf_count+'[ "other" ] )( d );\n'; - s += '}\n'; - } - return s; - /* // Unreachable cases. - case 'pluralStyle': - case 'selectStyle':*/ - case 'pluralFormatPattern': - data.pf_count = data.pf_count || 0; - s += 'var off_'+data.pf_count+' = '+ast.offset+';\n'; - s += 'var pf_' + data.pf_count + ' = { \n'; - needOther = true; - // We're going to simultaneously check to make sure we hit the required 'other' option. - - for ( i = 0; i < ast.pluralForms.length; ++i ) { - if ( ast.pluralForms[ i ].key === 'other' ) { - needOther = false; - } - if ( tmp ) { - s += ',\n'; - } - else{ - tmp = 1; - } - s += '"' + ast.pluralForms[ i ].key + '" : ' + interpMFP( ast.pluralForms[ i ].val, - (function(){ var res = JSON.parse(JSON.stringify(data)); res.pf_count++; return res; })() ); - } - s += '\n};\n'; - if ( needOther ) { - throw new Error("No 'other' form found in pluralFormatPattern " + data.pf_count); - } - return s; - case 'selectFormatPattern': - - data.pf_count = data.pf_count || 0; - s += 'var off_'+data.pf_count+' = 0;\n'; - s += 'var pf_' + data.pf_count + ' = { \n'; - needOther = true; - - for ( i = 0; i < ast.pluralForms.length; ++i ) { - if ( ast.pluralForms[ i ].key === 'other' ) { - needOther = false; - } - if ( tmp ) { - s += ',\n'; - } - else{ - tmp = 1; - } - s += '"' + ast.pluralForms[ i ].key + '" : ' + interpMFP( ast.pluralForms[ i ].val, - (function(){ - var res = JSON.parse( JSON.stringify( data ) ); - res.pf_count++; - return res; - })() - ); - } - s += '\n};\n'; - if ( needOther ) { - throw new Error("No 'other' form found in selectFormatPattern " + data.pf_count); - } - return s; - /* // Unreachable - case 'pluralForms': - */ - case 'string': - return 'r += "' + MessageFormat.Utils.numSub( - MessageFormat.Utils.escapeExpression( ast.val ), - 'k_' + data.pf_count + ' - off_' + ( data.pf_count - 1 ), - data.pf_count - ) + '";\n'; - default: - throw new Error( 'Bad AST type: ' + ast.type ); - } - } - return interpMFP( ast ); - }; - - MessageFormat.prototype.compile = function ( message ) { - return (new Function( 'MessageFormat', - 'return ' + - this.precompile( - this.parse( message ) - ) - ))(MessageFormat); - }; - - - if (typeof exports !== 'undefined') { - if (typeof module !== 'undefined' && module.exports) { - exports = module.exports = MessageFormat; - } - exports.MessageFormat = MessageFormat; - } - else if (typeof define === 'function' && define.amd) { - define(function() { - return MessageFormat; - }); - } - else { - root['MessageFormat'] = MessageFormat; - } - -})( this ); diff --git a/lib/job_time_spacer.rb b/lib/job_time_spacer.rb new file mode 100644 index 00000000000..18a7913ddd4 --- /dev/null +++ b/lib/job_time_spacer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +## +# In some cases we may want to enqueue_at several of the same job with +# batches, spacing them out or incrementing by some amount of seconds, +# in case the jobs do heavy work or send many MessageBus messages and the like. +# This class handles figuring out the seconds increments. +# +# @example +# spacer = JobTimeSpacer.new +# user_ids.in_groups_of(200, false) do |user_id_batch| +# spacer.enqueue(:kick_users_from_topic, { topic_id: topic_id, user_ids: user_id_batch }) +# end +class JobTimeSpacer + def initialize(seconds_space_increment: 1, seconds_delay: 5) + @seconds_space_increment = seconds_space_increment + @seconds_space_modifier = seconds_space_increment + @seconds_step = seconds_delay + end + + def enqueue(job_name, job_args = {}) + Jobs.enqueue_at((@seconds_step * @seconds_space_modifier).seconds.from_now, job_name, job_args) + @seconds_space_modifier += @seconds_space_increment + end +end diff --git a/lib/js_locale_helper.rb b/lib/js_locale_helper.rb index 2b8db855754..6ded40afd38 100644 --- a/lib/js_locale_helper.rb +++ b/lib/js_locale_helper.rb @@ -329,7 +329,9 @@ module JsLocaleHelper @ctx ||= begin ctx = MiniRacer::Context.new(timeout: 15_000, ensure_gc_after_idle: 2000) - ctx.load("#{Rails.root}/lib/javascripts/messageformat.js") + ctx.load( + "#{Rails.root}/app/assets/javascripts/node_modules/messageformat/messageformat.js", + ) ctx end ) diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index 70f776ec8a8..d41069c92e0 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -25,8 +25,8 @@ module Middleware def self.compile_key_builder method = +"def self.__compiled_key_builder(h)\n \"" cache_key_segments.each do |k, v| - raise "Invalid key name" unless k =~ /^[a-z]+$/ - raise "Invalid method name" unless v =~ /^key_[a-z_\?]+$/ + raise "Invalid key name" unless k =~ /\A[a-z]+\z/ + raise "Invalid method name" unless v =~ /\Akey_[a-z_\?]+\z/ method << "|#{k}=#\{h.#{v}}" end method << "\"\nend" diff --git a/lib/middleware/discourse_public_exceptions.rb b/lib/middleware/discourse_public_exceptions.rb index b507bc867a3..54e070c433a 100644 --- a/lib/middleware/discourse_public_exceptions.rb +++ b/lib/middleware/discourse_public_exceptions.rb @@ -10,6 +10,7 @@ module Middleware Rack::QueryParser::InvalidParameterError, ActionController::BadRequest, ActionDispatch::Http::Parameters::ParseError, + ActionController::RoutingError, ], ) @@ -48,12 +49,16 @@ module Middleware # Or badly formatted multipart requests begin request.POST - rescue EOFError - return [ - 400, - { "Cache-Control" => "private, max-age=0, must-revalidate" }, - ["Invalid request"] - ] + rescue ActionController::BadRequest => error + if error.cause.is_a?(EOFError) + return [ + 400, + { "Cache-Control" => "private, max-age=0, must-revalidate" }, + ["Invalid request"] + ] + else + raise + end end if ApplicationController.rescue_with_handler(exception, object: fake_controller) diff --git a/lib/middleware/missing_avatars.rb b/lib/middleware/missing_avatars.rb index 958aecaa3ee..b0f6d18a14d 100644 --- a/lib/middleware/missing_avatars.rb +++ b/lib/middleware/missing_avatars.rb @@ -11,7 +11,7 @@ module Middleware end def call(env) - if (env["REQUEST_PATH"] =~ %r{^/uploads/default/avatars}) + if (env["REQUEST_PATH"] =~ %r{\A/uploads/default/avatars}) path = "#{Rails.root}/public#{env["REQUEST_PATH"]}" unless File.exist?(path) default_image = "#{Rails.root}/public/images/d-logo-sketch-small.png" diff --git a/lib/migration/helpers.rb b/lib/migration/helpers.rb new file mode 100644 index 00000000000..3cf60dc05fc --- /dev/null +++ b/lib/migration/helpers.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Migration + module Helpers + def self.site_created_at + result = DB.query_single <<~SQL + SELECT created_at + FROM schema_migration_details + ORDER BY created_at + LIMIT 1 + SQL + result.first + end + + def self.existing_site? + site_created_at < 1.hour.ago + end + + def self.new_site? + !old_site? + end + end +end diff --git a/lib/migration/safe_migrate.rb b/lib/migration/safe_migrate.rb index ce3013300e0..b43bc38b029 100644 --- a/lib/migration/safe_migrate.rb +++ b/lib/migration/safe_migrate.rb @@ -116,7 +116,7 @@ class Migration::SafeMigrate end def self.protect!(sql) - if sql =~ /^\s*(?:drop\s+table|alter\s+table.*rename\s+to)\s+/i + if sql =~ /\A\s*(?:drop\s+table|alter\s+table.*rename\s+to)\s+/i $stdout.puts("", <<~TEXT) WARNING ------------------------------------------------------------------------------------- @@ -129,7 +129,7 @@ class Migration::SafeMigrate in use by live applications. TEXT raise Discourse::InvalidMigration, "Attempt was made to drop a table" - elsif sql =~ /^\s*alter\s+table.*(?:rename|drop)\s+/i + elsif sql =~ /\A\s*alter\s+table.*(?:rename|drop(?!\s+not\s+null))\s+/i $stdout.puts("", <<~TEXT) WARNING ------------------------------------------------------------------------------------- diff --git a/lib/migration/table_dropper.rb b/lib/migration/table_dropper.rb index f2f9849cb9b..0a122a877f0 100644 --- a/lib/migration/table_dropper.rb +++ b/lib/migration/table_dropper.rb @@ -3,7 +3,7 @@ require "migration/base_dropper" module Migration - class Migration::TableDropper + class TableDropper def self.read_only_table(table_name) BaseDropper.create_readonly_function(table_name) diff --git a/lib/mobile_detection.rb b/lib/mobile_detection.rb index 93bd12bae4a..aa4a8056eb1 100644 --- a/lib/mobile_detection.rb +++ b/lib/mobile_detection.rb @@ -26,8 +26,8 @@ module MobileDetection MODERN_MOBILE_REGEX = %r{ - \(.*iPhone\ OS\ 1[3-9].*\)| - \(.*iPad.*OS\ 1[3-9].*\)| + \(.*iPhone\ OS\ 1[5-9].*\)| + \(.*iPad.*OS\ 1[5-9].*\)| Chrome\/8[89]| Chrome\/9[0-9]| Chrome\/1[0-9][0-9]| diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb index 3903c5dd53d..80338633480 100644 --- a/lib/new_post_manager.rb +++ b/lib/new_post_manager.rb @@ -259,10 +259,11 @@ class NewPostManager reviewable = ReviewableQueuedPost.new( - created_by: @user, + created_by: Discourse.system_user, payload: payload, topic_id: @args[:topic_id], reviewable_by_moderator: true, + target_created_by: @user, ) reviewable.payload["title"] = @args[:title] if @args[:title].present? reviewable.category_id = args[:category] if args[:category].present? @@ -299,7 +300,7 @@ class NewPostManager result.reviewable = reviewable result.reason = reason if reason result.check_errors(errors) - result.pending_count = ReviewableQueuedPost.where(created_by: @user).pending.count + result.pending_count = ReviewableQueuedPost.where(target_created_by: @user).pending.count result end diff --git a/lib/onebox/engine.rb b/lib/onebox/engine.rb index 838986e685b..d4958068bea 100644 --- a/lib/onebox/engine.rb +++ b/lib/onebox/engine.rb @@ -7,7 +7,7 @@ module Onebox end def self.engines - constants.select { |constant| constant.to_s =~ /Onebox$/ }.sort.map(&method(:const_get)) + constants.select { |constant| constant.to_s =~ /Onebox\z/ }.sort.map(&method(:const_get)) end def self.all_iframe_origins @@ -165,6 +165,7 @@ require_relative "engine/google_play_app_onebox" require_relative "engine/image_onebox" require_relative "engine/video_onebox" require_relative "engine/audio_onebox" +require_relative "engine/threads_status_onebox" require_relative "engine/stack_exchange_onebox" require_relative "engine/twitter_status_onebox" require_relative "engine/wikimedia_onebox" @@ -210,3 +211,4 @@ require_relative "engine/google_drive_onebox" require_relative "engine/facebook_media_onebox" require_relative "engine/hackernews_onebox" require_relative "engine/motoko_onebox" +require_relative "engine/tiktok_onebox" diff --git a/lib/onebox/engine/allowlisted_generic_onebox.rb b/lib/onebox/engine/allowlisted_generic_onebox.rb index 3c8a2359eba..3618c6028cc 100644 --- a/lib/onebox/engine/allowlisted_generic_onebox.rb +++ b/lib/onebox/engine/allowlisted_generic_onebox.rb @@ -145,12 +145,12 @@ module Onebox !!AllowlistedGenericOnebox.allowed_twitter_labels.find { |l| d[:label2] =~ /#{l}/i } - unless Onebox::Helpers.blank?(d[:label_1]) - d[:label_2] = Onebox::Helpers.truncate(d[:label2]) - d[:data_2] = Onebox::Helpers.truncate(d[:data2]) - else + if Onebox::Helpers.blank?(d[:label_1]) d[:label_1] = Onebox::Helpers.truncate(d[:label2]) d[:data_1] = Onebox::Helpers.truncate(d[:data2]) + else + d[:label_2] = Onebox::Helpers.truncate(d[:label2]) + d[:data_2] = Onebox::Helpers.truncate(d[:data2]) end end diff --git a/lib/onebox/engine/discourse_topic_onebox.rb b/lib/onebox/engine/discourse_topic_onebox.rb new file mode 100644 index 00000000000..0247299d13e --- /dev/null +++ b/lib/onebox/engine/discourse_topic_onebox.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class DiscourseTopicOnebox + include Engine + include StandardEmbed + include LayoutSupport + + matches_regexp(%r{/t/.*(/\d+)?}) + + def data + @data ||= { + categories: categories, + link: link, + article_published_time: published_time.strftime("%-d %b %y"), + article_published_time_title: published_time.strftime("%I:%M%p - %d %B %Y"), + domain: html_entities.decode(raw[:site_name].truncate(80, separator: " ")), + description: html_entities.decode(raw[:description].truncate(250, separator: " ")), + title: html_entities.decode(raw[:title].truncate(80, separator: " ")), + image: image, + render_tags?: render_tags?, + render_category_block?: render_category_block?, + }.reverse_merge(raw) + end + alias verified_data data + + private + + def categories + Array + .wrap(raw[:article_sections]) + .map + .with_index { |name, index| { name: name, color: raw[:article_section_colors][index] } } + end + + def published_time + @published_time ||= Time.parse(raw[:published_time]) + end + + def html_entities + @html_entities ||= HTMLEntities.new + end + + def image + image = Onebox::Helpers.get_absolute_image_url(raw[:image], @url) + Onebox::Helpers.normalize_url_for_output(html_entities.decode(image)) + end + + def render_tags? + raw[:article_tags].present? + end + + def render_category_block? + render_tags? || categories.present? + end + end + end +end diff --git a/lib/onebox/engine/google_maps_onebox.rb b/lib/onebox/engine/google_maps_onebox.rb index 828c7fd5dcb..cd3072a93ae 100644 --- a/lib/onebox/engine/google_maps_onebox.rb +++ b/lib/onebox/engine/google_maps_onebox.rb @@ -96,7 +96,7 @@ module Onebox # Fallback for map URLs that don't resolve into an easily embeddable old-style URI # Roadmaps use a "z" zoomlevel, satellite maps use "m" the horizontal width in meters # TODO: tilted satellite maps using "a,y,t" - match = @url.match(/@(?[\d.-]+),(?[\d.-]+),(?\d+)(?[mz])/) + match = @url.match(/@(?[\d.-]+),(?[\d.-]+),(?\d+)(\.\d+)?(?[mz])/) raise "unexpected standard url #{@url}" unless match zoom = match[:mz] == "z" ? match[:zoom] : Math.log2(57280048.0 / match[:zoom].to_f).round location = "#{match[:lon]},#{match[:lat]}" diff --git a/lib/onebox/engine/motoko_onebox.rb b/lib/onebox/engine/motoko_onebox.rb index 1656150fbfd..9a0243bf83f 100644 --- a/lib/onebox/engine/motoko_onebox.rb +++ b/lib/onebox/engine/motoko_onebox.rb @@ -6,8 +6,8 @@ module Onebox include Engine include StandardEmbed - matches_regexp(%r{^https?://embed\.smartcontracts\.org/?.*}) - requires_iframe_origins "https://embed.smartcontracts.org" + matches_regexp(%r{^https?://embed\.(motoko|smartcontracts)\.org/?.*}) + requires_iframe_origins("https://embed.motoko.org", "https://embed.smartcontracts.org") always_https def to_html @@ -21,7 +21,7 @@ module Onebox protected def get_oembed_url - "https://embed.smartcontracts.org/api/onebox?url=#{url}" + "https://embed.smartcontracts.org/services/onebox?url=#{url}" end end end diff --git a/lib/onebox/engine/threads_status_onebox.rb b/lib/onebox/engine/threads_status_onebox.rb new file mode 100644 index 00000000000..1b835ce83f2 --- /dev/null +++ b/lib/onebox/engine/threads_status_onebox.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class ThreadsStatusOnebox + include Engine + include LayoutSupport + include HTML + + matches_regexp(%r{^https?://www\.threads\.net/t/(?[\d\w_-]+)/?.*?$}) + always_https + + def self.priority + 1 + end + + private + + def link + raw.css("link[rel='canonical']").first["href"] + end + + def likes + @og[:description].split(" ").first + end + + def replies + @og[:description].split(", ").drop(1).join(", ").split(" repl").first + end + + def description + text = @og[:description].split(". ").drop(1).join(". ") + linkify_mentions(text) + end + + def title + @og[:title].split(" (@").first + end + + def screen_name + @og[:title].split(" (@").drop(1).join(" (@").split(") on Threads")[0] + end + + def avatar + poster_response = + begin + Onebox::Helpers.fetch_response("https://www.threads.net/@#{screen_name}") + rescue StandardError + return nil + end + poster_html = Nokogiri.HTML(poster_response) + poster_data = ::Onebox::OpenGraph.new(poster_html).data + poster_data[:image] + end + + def image + @og[:image] + end + + def favicon + raw.css("link[rel='icon']").first["href"] + end + + def linkify_mentions(text) + text.gsub(/@([\w\d]+)/, "@\\1") + end + + def data + @og = ::Onebox::OpenGraph.new(raw).data + + @data ||= { + favicon: favicon, + link: link, + description: description, + image: image, + title: title, + screen_name: screen_name, + avatar: avatar, + likes: likes, + replies: replies, + } + + # if the image is the same as the avatar, don't show it + # means it's a thread with no image + @data[:image] = nil if @data[:image].split("?").first == @data[:avatar].split("?").first + + @data + end + end + end +end diff --git a/lib/onebox/engine/tiktok_onebox.rb b/lib/onebox/engine/tiktok_onebox.rb new file mode 100644 index 00000000000..d015479c924 --- /dev/null +++ b/lib/onebox/engine/tiktok_onebox.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class TiktokOnebox + include Engine + include StandardEmbed + + matches_regexp(%r{^https?://((?:m|www)\.)?tiktok\.com(?:/@(.+)\/video/|/v/)\d+(/\w+)?/?}) + requires_iframe_origins "https://www.tiktok.com" + always_https + + def placeholder_html + <<-HTML + + HTML + end + + def to_html + video_height = oembed_data.thumbnail_height < 1024 ? 998 : oembed_data.thumbnail_height + height = (323.0 / 576) * video_height + + <<-HTML + + HTML + end + + private + + def oembed_data + @oembed_data = get_oembed + end + + def get_oembed_url + "https://www.tiktok.com/oembed?url=#{url}" + end + end + end +end diff --git a/lib/onebox/engine/twitter_status_onebox.rb b/lib/onebox/engine/twitter_status_onebox.rb index c409e20071e..706751b4b98 100644 --- a/lib/onebox/engine/twitter_status_onebox.rb +++ b/lib/onebox/engine/twitter_status_onebox.rb @@ -6,17 +6,13 @@ module Onebox include Engine include LayoutSupport include HTML + include ActionView::Helpers::NumberHelper matches_regexp( %r{^https?://(mobile\.|www\.)?twitter\.com/.+?/status(es)?/\d+(/(video|photo)/\d?+)?+(/?\?.*)?/?$}, ) always_https - def self.===(other) - client = Onebox.options.twitter_client - client && !client.twitter_credentials_missing? && super - end - def http_params { "User-Agent" => "DiscourseBot/1.0" } end @@ -27,10 +23,46 @@ module Onebox private + def get_twitter_data + response = + begin + Onebox::Helpers.fetch_response(url, headers: http_params) + rescue StandardError + return nil + end + html = Nokogiri.HTML(response) + twitter_data = {} + html + .css("meta") + .each do |m| + if m.attribute("property") && m.attribute("property").to_s.match(/^og:/i) + m_content = m.attribute("content").to_s.strip + m_property = m.attribute("property").to_s.gsub("og:", "").gsub(":", "_") + twitter_data[m_property.to_sym] = m_content + end + end + twitter_data + end + def match @match ||= @url.match(%r{twitter\.com/.+?/status(es)?/(?\d+)}) end + def twitter_data + @twitter_data ||= get_twitter_data + end + + def guess_tweet_index + usernames = meta_tags_data("additionalName").compact + usernames.each_with_index do |username, index| + return index if twitter_data[:url].to_s.include?(username) + end + end + + def tweet_index + @tweet_index ||= guess_tweet_index + end + def client Onebox.options.twitter_client end @@ -39,66 +71,145 @@ module Onebox client && !client.twitter_credentials_missing? end - def raw - @raw ||= client.status(match[:id]).to_hash if twitter_api_credentials_present? + def symbolize_keys(obj) + case obj + when Array + obj.map { |item| symbolize_keys(item) } + when Hash + obj.each_with_object({}) do |(key, value), result| + result[key.to_sym] = symbolize_keys(value) + end + else + obj + end end - def access(*keys) - keys.reduce(raw) do |memo, key| - next unless memo - memo[key] || memo[key.to_s] + def raw + if twitter_api_credentials_present? + @raw ||= symbolize_keys(client.status(match[:id])) + else + super end end def tweet - client.prettify_tweet(raw)&.strip + if twitter_api_credentials_present? + client.prettify_tweet(raw)&.strip + else + twitter_data[:description].gsub(/“(.+?)”/im) { $1 } if twitter_data[:description] + end end def timestamp - date = DateTime.strptime(access(:created_at), "%a %b %d %H:%M:%S %z %Y") - user_offset = access(:user, :utc_offset).to_i - offset = (user_offset >= 0 ? "+" : "-") + Time.at(user_offset.abs).gmtime.strftime("%H%M") - date.new_offset(offset).strftime("%-l:%M %p - %-d %b %Y") + if twitter_api_credentials_present? && (created_at = raw.dig(:data, :created_at)) + date = DateTime.strptime(created_at, "%Y-%m-%dT%H:%M:%S.%L%z") + date.strftime("%-l:%M %p - %-d %b %Y") + end end def title - access(:user, :name) + if twitter_api_credentials_present? + raw.dig(:includes, :users)&.first&.dig(:name) + else + meta_tags_data("givenName")[tweet_index] + end end def screen_name - access(:user, :screen_name) + if twitter_api_credentials_present? + raw.dig(:includes, :users)&.first&.dig(:username) + else + meta_tags_data("additionalName")[tweet_index] + end end def avatar - access(:user, :profile_image_url_https).sub("normal", "400x400") + if twitter_api_credentials_present? + raw.dig(:includes, :users)&.first&.dig(:profile_image_url) + end end def likes - prettify_number(access(:favorite_count).to_i) + if twitter_api_credentials_present? + prettify_number(raw.dig(:data, :public_metrics, :like_count).to_i) + end end def retweets - prettify_number(access(:retweet_count).to_i) + if twitter_api_credentials_present? + prettify_number(raw.dig(:data, :public_metrics, :retweet_count).to_i) + end + end + + def is_reply + if twitter_api_credentials_present? + raw.dig(:data, :referenced_tweets)&.any? { |tweet| tweet.dig(:type) == "replied_to" } + end end def quoted_full_name - access(:quoted_status, :user, :name) + if twitter_api_credentials_present? && quoted_tweet_author.present? + quoted_tweet_author[:name] + end end def quoted_screen_name - access(:quoted_status, :user, :screen_name) + if twitter_api_credentials_present? && quoted_tweet_author.present? + quoted_tweet_author[:username] + end end - def quoted_tweet - access(:quoted_status, :full_text) + def quoted_text + quoted_tweet[:text] if twitter_api_credentials_present? && quoted_tweet.present? end def quoted_link - "https://twitter.com/#{quoted_screen_name}/status/#{access(:quoted_status, :id)}" + if twitter_api_credentials_present? + "https://twitter.com/#{quoted_screen_name}/status/#{quoted_status_id}" + end + end + + def quoted_status_id + raw.dig(:data, :referenced_tweets)&.find { |ref| ref[:type] == "quoted" }&.dig(:id) + end + + def quoted_tweet + raw.dig(:includes, :tweets)&.find { |tweet| tweet[:id] == quoted_status_id } + end + + def quoted_tweet_author + raw.dig(:includes, :users)&.find { |user| user[:id] == quoted_tweet&.dig(:author_id) } end def prettify_number(count) - count > 0 ? client.prettify_number(count) : nil + if count > 0 + number_to_human( + count, + format: "%n%u", + precision: 2, + units: { + thousand: "K", + million: "M", + billion: "B", + }, + ) + end + end + + def attr_at_css(css_property, attribute_name) + raw.at_css(css_property)&.attr(attribute_name) + end + + def meta_tags_data(attribute_name) + data = [] + raw + .css("meta") + .each do |m| + if m.attribute("itemprop") && m.attribute("itemprop").to_s.strip == attribute_name + data.push(m.attribute("content").to_s.strip) + end + end + data end def data @@ -111,7 +222,8 @@ module Onebox avatar: avatar, likes: likes, retweets: retweets, - quoted_tweet: quoted_tweet, + is_reply: is_reply, + quoted_text: quoted_text, quoted_full_name: quoted_full_name, quoted_screen_name: quoted_screen_name, quoted_link: quoted_link, diff --git a/lib/onebox/engine/wikipedia_onebox.rb b/lib/onebox/engine/wikipedia_onebox.rb index 725436f3bbe..1ccb4839640 100644 --- a/lib/onebox/engine/wikipedia_onebox.rb +++ b/lib/onebox/engine/wikipedia_onebox.rb @@ -23,7 +23,9 @@ module Onebox m_url_hash_name = m_url_hash[1] end - unless m_url_hash.nil? + if m_url_hash.nil? # no hash found in url + paras = raw.search("p") # default get all the paras + else section_header_title = raw.xpath("//span[@id='#{CGI.unescape(m_url_hash_name)}']") if section_header_title.empty? @@ -49,8 +51,6 @@ module Onebox end end end - else # no hash found in url - paras = raw.search("p") # default get all the paras end unless paras.empty? diff --git a/lib/onebox/helpers.rb b/lib/onebox/helpers.rb index 57ea939680a..1e4da8016ab 100644 --- a/lib/onebox/helpers.rb +++ b/lib/onebox/helpers.rb @@ -40,8 +40,8 @@ module Onebox should_ignore_canonical = IGNORE_CANONICAL_DOMAINS.map { |hostname| uri.hostname.match?(hostname) }.any? - unless (ignore_canonical_tag && ignore_canonical_tag["content"].to_s == "true") || - should_ignore_canonical + if !(ignore_canonical_tag && ignore_canonical_tag["content"].to_s == "true") && + !should_ignore_canonical # prefer canonical link canonical_link = doc.at('//link[@rel="canonical"]/@href') canonical_uri = Addressable::URI.parse(canonical_link) diff --git a/lib/onebox/layout.rb b/lib/onebox/layout.rb index e6e31daa6f9..53c63bb834f 100644 --- a/lib/onebox/layout.rb +++ b/lib/onebox/layout.rb @@ -15,7 +15,7 @@ module Onebox @record = Onebox::Helpers.symbolize_keys(record) # Fix any relative paths - if @record[:image] && @record[:image] =~ %r{^/[^/]} + if @record[:image] && @record[:image] =~ %r{\A/[^/]} @record[:image] = "#{uri.scheme}://#{uri.host}/#{@record[:image]}" end @@ -40,7 +40,7 @@ module Onebox link: record[:link], title: record[:title], favicon: record[:favicon], - domain: record[:domain] || uri.host.to_s.sub(/^www\./, ""), + domain: record[:domain] || uri.host.to_s.sub(/\Awww\./, ""), article_published_time: record[:article_published_time], article_published_time_title: record[:article_published_time_title], metadata_1_label: record[:metadata_1_label], diff --git a/lib/onebox/mixins/git_blob_onebox.rb b/lib/onebox/mixins/git_blob_onebox.rb index 511a6b60849..b307cf5681b 100644 --- a/lib/onebox/mixins/git_blob_onebox.rb +++ b/lib/onebox/mixins/git_blob_onebox.rb @@ -119,16 +119,16 @@ module Onebox a_lines = str.lines a_lines.each do |l| l = l.chomp("\n") # remove new line - m = l.match(/^[ ]*/) # find leading spaces 0 or more - unless m.nil? || l.size == m[0].size || l.size == 0 # no match | only spaces in line | empty line + m = l.match(/\A[ ]*/) # find leading spaces 0 or more + if m.nil? || l.size == m[0].size || l.size == 0 + next # SKIP no match or line is only spaces + else # no match | only spaces in line | empty line m_str_length = m[0].size if m_str_length <= 1 # minimum space is 1 or nothing we can break we found our minimum min_space = m_str_length break #stop iteration end min_space = m_str_length if m_str_length < min_space - else - next # SKIP no match or line is only spaces end end a_lines.each do |l| @@ -166,7 +166,7 @@ module Onebox @file = m[:file] @lang = Onebox::FileTypeFinder.from_file_name(m[:file]) - if @lang == "stl" && link.match?(%r{^https?://(www\.)?github\.com.*/blob/}) + if @lang == "stl" && link.match?(%r{\Ahttps?://(www\.)?github\.com.*/blob/}) @model_file = @lang.dup @raw = "https://render.githubusercontent.com/view/solid?url=" + self.raw_template(m) else diff --git a/lib/onebox/mixins/twitch_onebox.rb b/lib/onebox/mixins/twitch_onebox.rb index ec3bc4a6742..8251bcb17c2 100644 --- a/lib/onebox/mixins/twitch_onebox.rb +++ b/lib/onebox/mixins/twitch_onebox.rb @@ -25,7 +25,7 @@ module Onebox def to_html <<~HTML - + HTML end end diff --git a/lib/onebox/normalizer.rb b/lib/onebox/normalizer.rb index ac4c26b541a..ecd0b509f15 100644 --- a/lib/onebox/normalizer.rb +++ b/lib/onebox/normalizer.rb @@ -4,19 +4,11 @@ module Onebox class Normalizer attr_reader :data - def get(attr, length = nil, sanitize = true) - return nil if Onebox::Helpers.blank?(data) - + def get(attr, *args) value = data[attr] - - return nil if Onebox::Helpers.blank?(value) - - value = html_entities.decode(value) - value = Sanitize.fragment(value) if sanitize - value.strip! - value = Onebox::Helpers.truncate(value, length) unless length.nil? - - value + return if value.blank? + return value.map { |v| sanitize_value(v, *args) } if value.is_a?(Array) + sanitize_value(value, *args) end def method_missing(attr, *args, &block) @@ -48,5 +40,13 @@ module Onebox def html_entities @html_entities ||= HTMLEntities.new end + + def sanitize_value(value, length = nil, sanitize = true) + value = html_entities.decode(value) + value = Sanitize.fragment(value) if sanitize + value.strip! + value = Onebox::Helpers.truncate(value, length) if length + value + end end end diff --git a/lib/onebox/open_graph.rb b/lib/onebox/open_graph.rb index 4fb25793472..10c1f165d9c 100644 --- a/lib/onebox/open_graph.rb +++ b/lib/onebox/open_graph.rb @@ -22,6 +22,8 @@ module Onebox private + COLLECTIONS = %i[article_section article_section_color article_tag] + def extract(doc) return {} if Onebox::Helpers.blank?(doc) @@ -30,10 +32,17 @@ module Onebox doc .css("meta") .each do |m| - if (m["property"] && m["property"][/^(?:og|article|product):(.+)$/i]) || - (m["name"] && m["name"][/^(?:og|article|product):(.+)$/i]) + if (m["property"] && m["property"][/\A(?:og|article|product):(.+)\z/i]) || + (m["name"] && m["name"][/\A(?:og|article|product):(.+)\z/i]) value = (m["content"] || m["value"]).to_s - data[$1.tr("-:", "_").to_sym] ||= value unless Onebox::Helpers.blank?(value) + next if Onebox::Helpers.blank?(value) + key = $1.tr("-:", "_").to_sym + data[key] ||= value + if key.in?(COLLECTIONS) + collection_name = "#{key}s".to_sym + data[collection_name] ||= [] + data[collection_name] << value + end end end diff --git a/lib/onebox/sanitize_config.rb b/lib/onebox/sanitize_config.rb index 49c552f05be..77f97ee9257 100644 --- a/lib/onebox/sanitize_config.rb +++ b/lib/onebox/sanitize_config.rb @@ -10,7 +10,7 @@ module Onebox Sanitize::Config::RELAXED, elements: Sanitize::Config::RELAXED[:elements] + - %w[audio details embed iframe source video svg path], + %w[audio details embed iframe source video svg path use], attributes: { "a" => Sanitize::Config::RELAXED[:attributes]["a"] + %w[target], "audio" => %w[controls controlslist], @@ -40,7 +40,8 @@ module Onebox "path" => %w[d fill-rule], "svg" => %w[aria-hidden width height viewbox], "div" => [:data], # any data-* attributes, - "span" => [:data], # any data-* attributes + "span" => [:data], # any data-* attributes, + "use" => %w[href], }, add_attributes: { "iframe" => { @@ -57,7 +58,7 @@ module Onebox next unless env[:node_name] == "a" a_tag = env[:node] a_tag["href"] ||= "#" - if a_tag["href"] =~ %r{^(?:[a-z]+:)?//} + if a_tag["href"] =~ %r{\A(?:[a-z]+:)?//} a_tag["rel"] = "nofollow ugc noopener" else a_tag.remove_attribute("target") @@ -89,6 +90,9 @@ module Onebox "source" => { "src" => HTTP_PROTOCOLS, }, + "use" => { + "href" => [:relative], + }, }, css: { properties: Sanitize::Config::RELAXED[:css][:properties] + %w[--aspect-ratio], diff --git a/lib/onebox/templates/discourse_user_onebox.mustache b/lib/onebox/templates/discourse_user_onebox.mustache index 9adb3bcaf92..e7b7cdd2fec 100644 --- a/lib/onebox/templates/discourse_user_onebox.mustache +++ b/lib/onebox/templates/discourse_user_onebox.mustache @@ -1,7 +1,7 @@ diff --git a/lib/onebox/templates/discoursetopic.mustache b/lib/onebox/templates/discoursetopic.mustache new file mode 100644 index 00000000000..706b91b9fb2 --- /dev/null +++ b/lib/onebox/templates/discoursetopic.mustache @@ -0,0 +1,42 @@ +{{#image}}{{/image}} + +
    +

    {{title}}

    + {{#render_category_block?}} +
    + {{#categories}} + + + + {{name}} + + + {{/categories}} + {{#render_tags?}} +
    +
    +
    + + {{#article_tags}} + {{.}} + {{/article_tags}} +
    +
    +
    + {{/render_tags?}} +
    + {{/render_category_block?}} +
    + +{{#description}} +

    {{description}}

    +{{/description}} + +{{#data1}} +

    + {{label1}}: {{data1}} + {{#data2}} + {{label2}}: {{data2}} + {{/data2}} +

    +{{/data1}} diff --git a/lib/onebox/templates/threadsstatus.mustache b/lib/onebox/templates/threadsstatus.mustache new file mode 100644 index 00000000000..b39da295cd6 --- /dev/null +++ b/lib/onebox/templates/threadsstatus.mustache @@ -0,0 +1,30 @@ +{{#avatar}}{{/avatar}} +

    {{title}}

    + + +
    + {{{description}}} + {{#image}} +
    + {{/image}} +
    + +
    + {{#likes}} + + {{/likes}} + + {{#replies}} + + + {{replies}} + + {{/replies}} +
    diff --git a/lib/onebox/templates/twitterstatus.mustache b/lib/onebox/templates/twitterstatus.mustache index 13fe2cfafd9..3c3d8497aab 100644 --- a/lib/onebox/templates/twitterstatus.mustache +++ b/lib/onebox/templates/twitterstatus.mustache @@ -3,16 +3,21 @@
    + {{#is_reply}} + + + + {{/is_reply}} {{{tweet}}} - {{#quoted_tweet}} + {{#quoted_text}}

    {{quoted_full_name}} @{{quoted_screen_name}}

    -
    {{quoted_tweet}}
    +
    {{quoted_text}}
    - {{/quoted_tweet}} + {{/quoted_text}}
    diff --git a/lib/onebox/templates/wikimedia.mustache b/lib/onebox/templates/wikimedia.mustache index 4b1ab487c0e..dbcd4bb40d3 100644 --- a/lib/onebox/templates/wikimedia.mustache +++ b/lib/onebox/templates/wikimedia.mustache @@ -1,3 +1,3 @@ -{{#image}}{{/image}} +{{#image}}{{/image}}

    {{title}}

    diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb index 801e6f75536..5349b8a1d05 100644 --- a/lib/oneboxer.rb +++ b/lib/oneboxer.rb @@ -6,8 +6,8 @@ Dir["#{Rails.root}/lib/onebox/engine/*_onebox.rb"].sort.each { |f| require f } module Oneboxer ONEBOX_CSS_CLASS = "onebox" - AUDIO_REGEX = /^\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)$/i - VIDEO_REGEX = /^\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)$/i + AUDIO_REGEX = /\A\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)\z/i + VIDEO_REGEX = /\A\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)\z/i # keep reloaders happy unless defined?(Oneboxer::Result) @@ -29,6 +29,7 @@ module Oneboxer "http://store.steampowered.com", "http://vimeo.com", "https://www.youtube.com", + "https://twitter.com", Discourse.base_url, ] end @@ -206,14 +207,14 @@ module Oneboxer def self.apply(string_or_doc, extra_paths: nil) doc = string_or_doc - doc = Loofah.fragment(doc) if doc.is_a?(String) + doc = Loofah.html5_fragment(doc) if doc.is_a?(String) changed = false each_onebox_link(doc, extra_paths: extra_paths) do |url, element| onebox, _ = yield(url, element) next if onebox.blank? - parsed_onebox = Loofah.fragment(onebox) + parsed_onebox = Loofah.html5_fragment(onebox) next if parsed_onebox.children.blank? changed = true @@ -455,12 +456,15 @@ module Oneboxer args = { user_id: user.id, username: user.username, - avatar: PrettyText.avatar_img(user.avatar_template, "extra_large"), + avatar: PrettyText.avatar_img(user.avatar_template, "huge"), name: name, bio: user.user_profile.bio_excerpt(230), location: Onebox::Helpers.sanitize(user.user_profile.location), - joined: I18n.t("joined"), - created_at: user.created_at.strftime(I18n.t("datetime_formats.formats.date_only")), + joined: + I18n.t( + "onebox.discourse.user_joined_community", + date: user.created_at.strftime(I18n.t("datetime_formats.formats.date_only")), + ), website: user.user_profile.website, website_name: UserSerializer.new(user).website_name, original_url: url, diff --git a/lib/password_hasher.rb b/lib/password_hasher.rb new file mode 100644 index 00000000000..755c9280144 --- /dev/null +++ b/lib/password_hasher.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class PasswordHasher + class InvalidAlgorithmError < StandardError + end + + class UnsupportedAlgorithmError < StandardError + end + + HANDLERS = {} + + def self.register_handler(id, &blk) + HANDLERS[id] = blk + end + + # Algorithm should be specified according to the id/params parts of the + # PHC string format. + # https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md + def self.hash_password(password:, salt:, algorithm:) + algorithm = algorithm.delete_prefix("$").delete_suffix("$") + + parts = algorithm.split("$") + raise InvalidAlgorithmError if parts.length != 2 + + algorithm_id, algorithm_params = parts + + algorithm_params = algorithm_params.split(",").map { |pair| pair.split("=") }.to_h + + handler = HANDLERS[algorithm_id] + if handler.nil? + raise UnsupportedAlgorithmError.new "#{algorithm_id} is not a supported password algorithm" + end + + handler.call(password: password, salt: salt, params: algorithm_params) + end + + register_handler("pbkdf2-sha256") do |password:, salt:, params:| + raise ArgumentError.new("Salt and password must be supplied") if password.blank? || salt.blank? + + if params["l"].to_i != 32 + raise UnsupportedAlgorithmError.new("pbkdf2 implementation only supports l=32") + end + + if params["i"].to_i < 1 + raise UnsupportedAlgorithmError.new("pbkdf2 iterations must be 1 or more") + end + + Pbkdf2.hash_password(password, salt, params["i"].to_i, "sha256") + end +end diff --git a/lib/pbkdf2.rb b/lib/pbkdf2.rb index 5546e64d671..385ca5963eb 100644 --- a/lib/pbkdf2.rb +++ b/lib/pbkdf2.rb @@ -1,28 +1,13 @@ # frozen_string_literal: true -# Note: This logic was originally extracted from the Pbkdf2 gem to fix Ruby 2.0 -# issues, but that gem has gone stale so we won't be returning to it. - -require "openssl" -require "xorcist" - class Pbkdf2 - def self.hash_password(password, salt, iterations, algorithm = "sha256") - h = OpenSSL::Digest.new(algorithm) - - u = ret = prf(h, password, salt + [1].pack("N")) - - 2.upto(iterations) do - u = prf(h, password, u) - Xorcist.xor!(ret, u) - end - - ret.bytes.map { |b| ("0" + b.to_s(16))[-2..-1] }.join("") - end - - protected - - def self.prf(hash_function, password, data) - OpenSSL::HMAC.digest(hash_function, password, data) + def self.hash_password(password, salt, iterations, algorithm = "sha256", length: 32) + OpenSSL::KDF.pbkdf2_hmac( + password, + salt: salt, + iterations: iterations, + length: length, + hash: algorithm, + ).unpack1("H*") end end diff --git a/lib/plain_text_to_markdown.rb b/lib/plain_text_to_markdown.rb index 8e582e6f365..d914cd867e5 100644 --- a/lib/plain_text_to_markdown.rb +++ b/lib/plain_text_to_markdown.rb @@ -100,7 +100,7 @@ class PlainTextToMarkdown # @param line [Line] def remove_quote_level_indicators!(line) - match_data = line.text.match(/^(?>+)\s?(?.*)/) + match_data = line.text.match(/\A(?>+)\s?(?.*)/) if match_data line.text = match_data[:text] @@ -128,7 +128,7 @@ class PlainTextToMarkdown def classify_line_as_code!(line, previous_line) line.code_block = previous_line.code_block unless previous_line.nil? || previous_line.valid_code_block? - return unless line.text =~ /^\s{0,3}```/ + return unless line.text =~ /\A\s{0,3}```/ if line.code_block.present? line.code_block.end_line = line @@ -173,7 +173,7 @@ class PlainTextToMarkdown end def indent_with_non_breaking_spaces(text) - text.sub(/^\s+/) do |s| + text.sub(/\A\s+/) do |s| # replace tabs with 2 spaces s.gsub!("\t", " ") diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index fc69c104760..a3f3887b3ab 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -103,13 +103,42 @@ class Plugin::Instance @admin_route = { label: label, location: location } end + def configurable? + true + end + + def visible? + configurable? && !@hidden + end + def enabled? + return false if !configurable? @enabled_site_setting ? SiteSetting.get(@enabled_site_setting) : true end delegate :name, to: :metadata - def add_to_serializer(serializer, attr, define_include_method = true, &block) + def add_to_serializer( + serializer, + attr, + deprecated_respect_plugin_enabled = nil, + respect_plugin_enabled: true, + include_condition: nil, + &block + ) + if !deprecated_respect_plugin_enabled.nil? + Discourse.deprecate( + "add_to_serializer's respect_plugin_enabled argument should be passed as a keyword argument", + ) + respect_plugin_enabled = deprecated_respect_plugin_enabled + end + + if attr.to_s.starts_with?("include_") + Discourse.deprecate( + "add_to_serializer should not be used to directly override include_*? methods. Use the include_condition keyword argument instead", + ) + end + reloadable_patch do |plugin| base = begin @@ -123,9 +152,13 @@ class Plugin::Instance unless attr.to_s.start_with?("include_") klass.attributes(attr) - if define_include_method + if respect_plugin_enabled || include_condition # Don't include serialized methods if the plugin is disabled - klass.public_send(:define_method, "include_#{attr}?") { plugin.enabled? } + klass.public_send(:define_method, "include_#{attr}?") do + next false if respect_plugin_enabled && !plugin.enabled? + next instance_exec(&include_condition) if include_condition + true + end end end @@ -134,6 +167,10 @@ class Plugin::Instance end end + def register_modifier(modifier_name, &blk) + DiscoursePluginRegistry.register_modifier(self, modifier_name, &blk) + end + # Applies to all sites in a multisite environment. Ignores plugin.enabled? def add_report(name, &block) reloadable_patch { |plugin| Report.add_report(name, &block) } @@ -152,28 +189,10 @@ class Plugin::Instance end end - def whitelist_staff_user_custom_field(field) - Discourse.deprecate( - "whitelist_staff_user_custom_field is deprecated, use the allow_staff_user_custom_field.", - drop_from: "2.6", - raise_error: true, - ) - allow_staff_user_custom_field(field) - end - def allow_staff_user_custom_field(field) DiscoursePluginRegistry.register_staff_user_custom_field(field, self) end - def whitelist_public_user_custom_field(field) - Discourse.deprecate( - "whitelist_public_user_custom_field is deprecated, use the allow_public_user_custom_field.", - drop_from: "2.6", - raise_error: true, - ) - allow_public_user_custom_field(field) - end - def allow_public_user_custom_field(field) DiscoursePluginRegistry.register_public_user_custom_field(field, self) end @@ -341,15 +360,6 @@ class Plugin::Instance end end - def topic_view_post_custom_fields_whitelister(&block) - Discourse.deprecate( - "topic_view_post_custom_fields_whitelister is deprecated, use the topic_view_post_custom_fields_allowlister.", - drop_from: "2.6", - raise_error: true, - ) - topic_view_post_custom_fields_allowlister(&block) - end - # Add a post_custom_fields_allowlister block to the TopicView, respecting if the plugin is enabled def topic_view_post_custom_fields_allowlister(&block) reloadable_patch do |plugin| @@ -479,6 +489,19 @@ class Plugin::Instance initializers << block end + def commit_hash + git_repo.latest_local_commit + end + + def commit_url + return if commit_hash.blank? + "#{git_repo.url}/commit/#{commit_hash}" + end + + def git_repo + @git_repo ||= GitRepo.new(directory, name) + end + def before_auth(&block) if @before_auth_complete raise "Auth providers must be registered before omniauth middleware. after_initialize is too late!" @@ -597,6 +620,11 @@ class Plugin::Instance end end + def register_email_poller(poller) + plugin = self + DiscoursePluginRegistry.register_mail_poller(poller) if plugin.enabled? + end + def register_asset(file, opts = nil) raise <<~ERROR if file.end_with?(".hbs", ".handlebars") [#{name}] Handlebars templates can no longer be included via `register_asset`. @@ -629,6 +657,7 @@ class Plugin::Instance end def register_emoji(name, url, group = Emoji::DEFAULT_GROUP) + name = name.gsub(/[^a-z0-9]+/i, "_").gsub(/_{2,}/, "_").downcase Plugin::CustomEmoji.register(name, url, group) Emoji.clear_cache end @@ -675,17 +704,17 @@ class Plugin::Instance DiscoursePluginRegistry.register_glob(admin_path, "hbr", admin: true) DiscourseJsProcessor.plugin_transpile_paths << root_path.sub(Rails.root.to_s, "").sub( - %r{^/*}, + %r{\A/*}, "", ) DiscourseJsProcessor.plugin_transpile_paths << admin_path.sub(Rails.root.to_s, "").sub( - %r{^/*}, + %r{\A/*}, "", ) test_path = "#{root_dir_name}/test/javascripts" DiscourseJsProcessor.plugin_transpile_paths << test_path.sub(Rails.root.to_s, "").sub( - %r{^/*}, + %r{\A/*}, "", ) end @@ -797,11 +826,7 @@ class Plugin::Instance end def hide_plugin - Discourse.hidden_plugins << self - end - - def enabled_site_setting_filter(filter = nil) - STDERR.puts("`enabled_site_setting_filter` is deprecated") + @hidden = true end def enabled_site_setting(setting = nil) @@ -946,36 +971,7 @@ class Plugin::Instance # # See Auth::DefaultCurrentUserProvider::PARAMETER_API_PATTERNS for more examples # and Auth::DefaultCurrentUserProvider#api_parameter_allowed? for implementation - def add_api_parameter_route( - method: nil, - methods: nil, - route: nil, - actions: nil, - format: nil, - formats: nil - ) - if Array(format).include?("*") - Discourse.deprecate( - "* is no longer a valid api_parameter_route format matcher. Use `nil` instead", - drop_from: "2.7", - raise_error: true, - ) - # Old API used * as wildcard. New api uses `nil` - format = nil - end - - # Backwards compatibility with old parameter names: - if method || route || format - Discourse.deprecate( - "method, route and format parameters for api_parameter_routes are deprecated. Use methods, actions and formats instead.", - drop_from: "2.7", - raise_error: true, - ) - methods ||= method - actions ||= route - formats ||= format - end - + def add_api_parameter_route(methods: nil, actions: nil, formats: nil) DiscoursePluginRegistry.register_api_parameter_route( RouteMatcher.new(methods: methods, actions: actions, formats: formats), self, @@ -1129,7 +1125,17 @@ class Plugin::Instance # table. Some stats may be needed purely for reporting purposes and thus # do not need to be shown in the UI to admins/users. def register_about_stat_group(plugin_stat_group_name, show_in_ui: false, &block) - About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block) + # We do not want to register and display the same group multiple times. + if DiscoursePluginRegistry.about_stat_groups.any? { |stat_group| + stat_group[:name] == plugin_stat_group_name + } + return + end + + DiscoursePluginRegistry.register_about_stat_group( + { name: plugin_stat_group_name, show_in_ui: show_in_ui, block: block }, + self, + ) end ## @@ -1211,6 +1217,27 @@ class Plugin::Instance DiscoursePluginRegistry.register_user_destroyer_on_content_deletion_callback(callback, self) end + ## + # Register a class that implements [BaseBookmarkable], which represents another + # [ActiveRecord::Model] that may be bookmarked via the [Bookmark] model's + # polymorphic association. The class handles create and destroy hooks, querying, + # and reminders among other things. + def register_bookmarkable(klass) + return if Bookmark.registered_bookmarkable_from_type(klass.model.name).present? + DiscoursePluginRegistry.register_bookmarkable(RegisteredBookmarkable.new(klass), self) + end + + ## + # Register an object that inherits from [Summarization::Base], which provides a way + # to summarize content. Staff can select which strategy to use + # through the `summarization_strategy` setting. + def register_summarization_strategy(strategy) + if !strategy.class.ancestors.include?(Summarization::Base) + raise ArgumentError.new("Not a valid summarization strategy") + end + DiscoursePluginRegistry.register_summarization_strategy(strategy, self) + end + protected def self.js_path @@ -1292,10 +1319,14 @@ class Plugin::Instance DiscoursePluginRegistry.register_topic_preloader_association(fields, self) end + def register_search_group_query_callback(callback) + DiscoursePluginRegistry.register_search_groups_set_query_callback(callback, self) + end + private def validate_directory_column_name(column_name) - match = /^[_a-z]+$/.match(column_name) + match = /\A[_a-z]+\z/.match(column_name) unless match raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores" end diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index ab59144079c..6197f8e990b 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -13,6 +13,7 @@ class Plugin::Metadata "Canned Replies", "discourse-adplugin", "discourse-affiliate", + "discourse-ai", "discourse-akismet", "discourse-algolia", "discourse-apple-auth", @@ -45,6 +46,7 @@ class Plugin::Metadata "discourse-graphviz", "discourse-group-tracker", "discourse-invite-tokens", + "discourse-lazy-videos", "discourse-local-dates", "discourse-login-with-amazon", "discourse-logster-rate-limit-checker", @@ -53,6 +55,7 @@ class Plugin::Metadata "discourse-math", "discourse-moderator-attention", "discourse-narrative-bot", + "discourse-newsletter-integration", "discourse-no-bump", "discourse-oauth2-basic", "discourse-openid-connect", @@ -91,9 +94,9 @@ class Plugin::Metadata "discourse-whos-online", "discourse-yearly-review", "discourse-zendesk-plugin", + "discourse-zoom", "docker_manager", "chat", - "lazy-yt", "poll", "styleguide", ], diff --git a/lib/plugin_gem.rb b/lib/plugin_gem.rb index bba295f43e1..8bbbd726737 100644 --- a/lib/plugin_gem.rb +++ b/lib/plugin_gem.rb @@ -9,10 +9,8 @@ module PluginGem spec_path = gems_path + "/specifications" spec_file = spec_path + "/#{name}-#{version}" - spec_file += "-#{opts[:platform]}" if opts[:platform] - spec_file += ".gemspec" - unless File.exist? spec_file + unless platform_variants(spec_file).find(&File.method(:exist?)).present? command = "gem install #{name} -v #{version} -i #{gems_path} --no-document --ignore-dependencies --no-user-install" command += " --source #{opts[:source]}" if opts[:source] @@ -21,15 +19,27 @@ module PluginGem Bundler.with_unbundled_env { puts `#{command}` } end - if File.exist? spec_file + spec_file_variant = platform_variants(spec_file).find(&File.method(:exist?)) + if spec_file_variant.present? Gem.path << gems_path - Gem::Specification.load(spec_file).activate + Gem::Specification.load(spec_file_variant).activate require opts[:require_name] ? opts[:require_name] : name unless opts[:require] == false else puts "You are specifying the gem #{name} in #{path}, however it does not exist!" - puts "Looked for: #{spec_file}" + puts "Looked for: \n- #{platform_variants(spec_file).join("\n- ")}" exit(-1) end end + + def self.platform_variants(spec_file) + platform_less = "#{spec_file}.gemspec" + + platform_full = "#{spec_file}-#{RUBY_PLATFORM}.gemspec" + + platform_version_less = + "#{spec_file}-#{Gem::Platform.local.cpu}-#{Gem::Platform.local.os}.gemspec" + + [platform_less, platform_full, platform_version_less] + end end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index b233de35403..f1afa7aac72 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -148,7 +148,7 @@ class PostCreator end end - unless @topic.present? && (@opts[:skip_guardian] || guardian.can_create?(Post, @topic)) + if @topic.blank? || !(@opts[:skip_guardian] || guardian.can_create?(Post, @topic)) errors.add(:base, I18n.t(:topic_not_found)) return false end @@ -190,7 +190,7 @@ class PostCreator update_user_counts create_embedded_topic @post.link_post_uploads - update_uploads_secure_status + @post.update_uploads_secure_status(source: "post creator") delete_owned_bookmarks ensure_in_allowed_users if guardian.is_staff? unarchive_message if !@opts[:import_mode] @@ -328,9 +328,8 @@ class PostCreator if PostCreator.track_post_stats sequence = DraftSequence.current(@user, draft_key) revisions = - Draft.where(sequence: sequence, user_id: @user.id, draft_key: draft_key).pluck_first( - :revisions, - ) || 0 + Draft.where(sequence: sequence, user_id: @user.id, draft_key: draft_key).pick(:revisions) || + 0 @post.build_post_stat( drafts_saved: revisions, @@ -403,10 +402,6 @@ class PostCreator rollback_from_errors!(embed) unless embed.save end - def update_uploads_secure_status - @post.update_uploads_secure_status(source: "post creator") if SiteSetting.secure_uploads? - end - def delete_owned_bookmarks return if !@post.topic_id BookmarkManager.new(@user).destroy_for_topic( diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index a7cd9a11b91..d8f67083e40 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -55,18 +55,17 @@ class PostDestroyer def initialize(user, post, opts = {}) @user = user @post = post - @topic = post.topic if post + @topic = post.topic || Topic.with_deleted.find_by(id: @post.topic_id) @opts = opts end def destroy payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(:post).exists? - topic = Topic.with_deleted.find_by(id: @post.topic_id) - is_first_post = @post.is_first_post? && topic + is_first_post = @post.is_first_post? && @topic has_topic_web_hooks = is_first_post && WebHook.active_web_hooks(:topic).exists? if has_topic_web_hooks - topic_view = TopicView.new(topic.id, Discourse.system_user, skip_staff_action: true) + topic_view = TopicView.new(@topic.id, Discourse.system_user, skip_staff_action: true) topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer) end @@ -74,7 +73,7 @@ class PostDestroyer @opts[:delete_removed_posts_after] || SiteSetting.delete_removed_posts_after if delete_removed_posts_after < 1 || post_is_reviewable? || - Guardian.new(@user).can_moderate_topic?(topic) || permanent? + Guardian.new(@user).can_moderate_topic?(@topic) || permanent? perform_delete elsif @user.id == @post.user_id mark_for_deletion(delete_removed_posts_after) @@ -84,13 +83,16 @@ class PostDestroyer DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user) WebHook.enqueue_post_hooks(:post_destroyed, @post, payload) - Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic + Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic if is_first_post - UserProfile.remove_featured_topic_from_all_profiles(topic) - UserActionManager.topic_destroyed(topic) - DiscourseEvent.trigger(:topic_destroyed, topic, @user) - WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload) if has_topic_web_hooks + UserProfile.remove_featured_topic_from_all_profiles(@topic) + UserActionManager.topic_destroyed(@topic) + DiscourseEvent.trigger(:topic_destroyed, @topic, @user) + WebHook.enqueue_topic_hooks(:topic_destroyed, @topic, topic_payload) if has_topic_web_hooks + if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id + Discourse.clear_urls! + end end end @@ -101,28 +103,31 @@ class PostDestroyer elsif @user.staff? || @user.id == @post.user_id user_recovered end - topic = Topic.with_deleted.find @post.topic_id - topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if !topic.user_id - topic.recover!(@user) if @post.is_first_post? - topic.update_statistics - Topic.publish_stats_to_clients!(topic.id, :recovered) + + @topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if !@topic.user_id + @topic.recover!(@user) if @post.is_first_post? + @topic.update_statistics + Topic.publish_stats_to_clients!(@topic.id, :recovered) UserActionManager.post_created(@post) DiscourseEvent.trigger(:post_recovered, @post, @opts, @user) - Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic + Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic Jobs.enqueue(:notify_mailing_list_subscribers, post_id: @post.id) if @post.is_first_post? - UserActionManager.topic_created(topic) - DiscourseEvent.trigger(:topic_recovered, topic, @user) + UserActionManager.topic_created(@topic) + DiscourseEvent.trigger(:topic_recovered, @topic, @user) if @user.id != @post.user_id StaffActionLogger.new(@user).log_topic_delete_recover( - topic, + @topic, "recover_topic", @opts.slice(:context), ) end update_imap_sync(@post, false) + if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id + Discourse.clear_urls! + end end end @@ -194,10 +199,11 @@ class PostDestroyer end end - if @post.topic && @post.is_first_post? - permanent? ? @post.topic.destroy! : @post.topic.trash!(@user) - PublishedPage.unpublish!(@user, @post.topic) if @post.topic.published_page + if @topic && @post.is_first_post? + permanent? ? @topic.destroy! : @topic.trash!(@user) + PublishedPage.unpublish!(@user, @topic) if @topic.published_page end + TopicLink.where(link_post_id: @post.id).destroy_all update_associated_category_latest_topic update_user_counts if !permanent? @@ -281,8 +287,7 @@ class PostDestroyer def post_is_reviewable? return true if @user.staff? - topic = @post.topic || Topic.with_deleted.find(@post.topic_id) - Guardian.new(@user).can_review_topic?(topic) && Reviewable.exists?(target: @post) + Guardian.new(@user).can_review_topic?(@topic) && Reviewable.exists?(target: @post) end # we need topics to change if ever a post in them is deleted or created @@ -300,13 +305,11 @@ class PostDestroyer def make_previous_post_the_last_one last_post = Post - .where("topic_id = ? and id <> ?", @post.topic_id, @post.id) .select(:created_at, :user_id, :post_number) .where("topic_id = ? and id <> ?", @post.topic_id, @post.id) .where.not(user_id: nil) .where.not(post_type: Post.types[:whisper]) .order("created_at desc") - .limit(1) .first if last_post.present? @@ -358,7 +361,7 @@ class PostDestroyer end def ignore(reviewable) - reviewable.perform_ignore(@user, post_was_deleted: true) + reviewable.perform_ignore_and_do_nothing(@user, post_was_deleted: true) reviewable.transition_to(:ignored, @user) end @@ -431,8 +434,8 @@ class PostDestroyer def update_associated_category_latest_topic return unless @post.topic && @post.topic.category - unless @post.id == @post.topic.category.latest_post_id || - (@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id) + if @post.id != @post.topic.category.latest_post_id && + !(@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id) return end diff --git a/lib/post_jobs_enqueuer.rb b/lib/post_jobs_enqueuer.rb index a1c0573f084..1fae8781fcf 100644 --- a/lib/post_jobs_enqueuer.rb +++ b/lib/post_jobs_enqueuer.rb @@ -44,7 +44,7 @@ class PostJobsEnqueuer def make_visible return if @topic.private_message? return unless SiteSetting.embed_unlisted? - return unless @post.post_number > 1 + return if @post.post_number == 1 return if @topic.visible? return if @post.post_type != Post.types[:regular] diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index 1ce1ef284be..6ab8c9afaec 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -79,25 +79,30 @@ class PostRevisor track_and_revise topic_changes, :archetype, attribute end - track_topic_field(:category_id) do |tc, category_id, fields| - if category_id == 0 && tc.topic.private_message? - tc.record_change("category_id", tc.topic.category_id, nil) + track_topic_field(:category_id) do |tc, new_category_id, fields| + current_category = tc.topic.category + new_category = + (new_category_id.nil? || new_category_id.zero?) ? nil : Category.find(new_category_id) + + if new_category.nil? && tc.topic.private_message? + tc.record_change("category_id", current_category.id, nil) tc.topic.category_id = nil - elsif category_id == 0 || tc.guardian.can_move_topic_to_category?(category_id) + elsif new_category.nil? || tc.guardian.can_move_topic_to_category?(new_category_id) tags = fields[:tags] || tc.topic.tags.map(&:name) - if category_id != 0 && - !DiscourseTagging.validate_min_required_tags_for_category( - tc.guardian, - tc.topic, - Category.find(category_id), - tags, - ) + if new_category && + !DiscourseTagging.validate_category_tags(tc.guardian, tc.topic, new_category, tags) tc.check_result(false) next end - tc.record_change("category_id", tc.topic.category_id, category_id) - tc.check_result(tc.topic.change_category_to_id(category_id)) + tc.record_change("category_id", current_category&.id, new_category&.id) + tc.check_result(tc.topic.change_category_to_id(new_category_id)) + create_small_action_for_category_change( + topic: tc.topic, + user: tc.user, + old_category: current_category, + new_category: new_category, + ) end end @@ -114,14 +119,25 @@ class PostRevisor DB.after_commit do post = tc.topic.ordered_posts.first notified_user_ids = [post.user_id, post.last_editor_id].uniq + + added_tags = tags - prev_tags + removed_tags = prev_tags - tags + if !SiteSetting.disable_tags_edit_notifications Jobs.enqueue( :notify_tag_change, post_id: post.id, notified_user_ids: notified_user_ids, - diff_tags: ((tags - prev_tags) | (prev_tags - tags)), + diff_tags: (added_tags | removed_tags), ) end + + create_small_action_for_tag_changes( + topic: tc.topic, + user: tc.user, + added_tags: added_tags, + removed_tags: removed_tags, + ) end end end @@ -137,6 +153,58 @@ class PostRevisor end end + def self.create_small_action_for_category_change(topic:, user:, old_category:, new_category:) + return if !SiteSetting.create_post_for_category_and_tag_changes + + topic.add_moderator_post( + user, + I18n.t( + "topic_category_changed", + from: category_name_raw(old_category), + to: category_name_raw(new_category), + ), + post_type: Post.types[:small_action], + action_code: "category_changed", + ) + end + + def self.category_name_raw(category) + "##{CategoryHashtagDataSource.category_to_hashtag_item(category).ref}" + end + + def self.create_small_action_for_tag_changes(topic:, user:, added_tags:, removed_tags:) + return if !SiteSetting.create_post_for_category_and_tag_changes + + topic.add_moderator_post( + user, + tags_changed_raw(added: added_tags, removed: removed_tags), + post_type: Post.types[:small_action], + action_code: "tags_changed", + custom_fields: { + tags_added: added_tags, + tags_removed: removed_tags, + }, + ) + end + + def self.tags_changed_raw(added:, removed:) + if removed.present? && added.present? + I18n.t( + "topic_tag_changed.added_and_removed", + added: tag_list_to_raw(added), + removed: tag_list_to_raw(removed), + ) + elsif added.present? + I18n.t("topic_tag_changed.added", added: tag_list_to_raw(added)) + elsif removed.present? + I18n.t("topic_tag_changed.removed", removed: tag_list_to_raw(removed)) + end + end + + def self.tag_list_to_raw(tag_list) + tag_list.sort.map { |tag_name| "##{tag_name}" }.join(", ") + end + # AVAILABLE OPTIONS: # - revised_at: changes the date of the revision # - force_new_version: bypass grace period edit window @@ -298,14 +366,18 @@ class PostRevisor def should_create_new_version? return false if @skip_revision - edited_by_another_user? || !grace_period_edit? || owner_changed? || force_new_version? || - edit_reason_specified? + edited_by_another_user? || flagged? || !grace_period_edit? || owner_changed? || + force_new_version? || edit_reason_specified? end def edit_reason_specified? @fields[:edit_reason].present? && @fields[:edit_reason] != @post.edit_reason end + def flagged? + @post.is_flagged? + end + def edited_by_another_user? @post.last_editor_id != @editor.id end @@ -349,7 +421,6 @@ class PostRevisor def grace_period_edit? return false if (@revised_at - @last_version_at) > SiteSetting.editing_grace_period.to_i - return false if @post.reviewable_flag.present? if new_raw = @fields[:raw] max_diff = SiteSetting.editing_grace_period_max_diff.to_i @@ -552,8 +623,7 @@ class PostRevisor if revision.modifications.empty? revision.destroy @post.last_editor_id = - PostRevision.where(post_id: @post.id).order(number: :desc).pluck_first(:user_id) || - @post.user_id + PostRevision.where(post_id: @post.id).order(number: :desc).pick(:user_id) || @post.user_id @post.version -= 1 @post.public_version -= 1 @post.save(validate: @validate_post) @@ -619,7 +689,6 @@ class PostRevisor update_topic_excerpt update_category_description - hide_welcome_topic_banner end def update_topic_excerpt @@ -641,15 +710,6 @@ class PostRevisor end end - def hide_welcome_topic_banner - return unless guardian.is_admin? - return unless @topic.id == SiteSetting.welcome_topic_id - return unless Discourse.cache.read(Site.welcome_topic_banner_cache_key(@editor.id)) - - Discourse.cache.write(Site.welcome_topic_banner_cache_key(@editor.id), false) - MessageBus.publish("/site/welcome-topic-banner", false) - end - def advance_draft_sequence @post.advance_draft_sequence end @@ -702,4 +762,17 @@ class PostRevisor def guardian @guardian ||= Guardian.new(@editor) end + + def raw_changed? + @fields.has_key?(:raw) && @fields[:raw] != cached_original_raw && @post_successfully_saved + end + + def topic_title_changed? + topic_changed? && @fields.has_key?(:title) && topic_diff.has_key?(:title) && + !@topic_changes.errored? + end + + def reviewable_content_changed? + raw_changed? || topic_title_changed? + end end diff --git a/lib/presence_channel.rb b/lib/presence_channel.rb index ff6fa3d0f07..21da97d5b4d 100644 --- a/lib/presence_channel.rb +++ b/lib/presence_channel.rb @@ -104,6 +104,7 @@ class PresenceChannel return true if user_id && config.allowed_user_ids&.include?(user_id) if user_id && config.allowed_group_ids.present? + return true if config.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) group_ids ||= GroupUser.where(user_id: user_id).pluck("group_id") return true if (group_ids & config.allowed_group_ids).present? end @@ -313,7 +314,10 @@ class PresenceChannel else raise InvalidConfig.new "Expected PresenceChannel::Config or nil. Got a #{result.class.name}" end - PresenceChannel.redis.set(redis_key_config, to_cache, ex: CONFIG_CACHE_SECONDS) + + DiscourseRedis.ignore_readonly do + PresenceChannel.redis.set(redis_key_config, to_cache, ex: CONFIG_CACHE_SECONDS) + end raise PresenceChannel::NotFound if result.nil? result diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 3ce99f7115c..f33268f2127 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -48,7 +48,7 @@ module PrettyText filename = find_file(root_path, part_name) if filename source = File.read("#{root_path}#{filename}") - source = ERB.new(source).result(binding) if filename =~ /\.erb$/ + source = ERB.new(source).result(binding) if filename =~ /\.erb\z/ transpiler = DiscourseJsProcessor::Transpiler.new transpiled = transpiler.perform(source, "#{Rails.root}/app/assets/javascripts/", part_name) @@ -64,7 +64,7 @@ module PrettyText def self.ctx_load_directory(ctx, path) root_path = "#{Rails.root}/app/assets/javascripts/" Dir["#{root_path}#{path}/**/*"].sort.each do |f| - apply_es6_file(ctx, root_path, f.sub(root_path, "").sub(/\.js(.es6)?$/, "")) + apply_es6_file(ctx, root_path, f.sub(root_path, "").sub(/\.js(.es6)?\z/, "")) end end @@ -104,9 +104,9 @@ module PrettyText apply_es6_file(ctx, root_path, "discourse-common/addon/lib/object") apply_es6_file(ctx, root_path, "discourse-common/addon/lib/deprecated") apply_es6_file(ctx, root_path, "discourse-common/addon/lib/escape") + apply_es6_file(ctx, root_path, "discourse-common/addon/lib/avatar-utils") apply_es6_file(ctx, root_path, "discourse-common/addon/utils/watched-words") apply_es6_file(ctx, root_path, "discourse/app/lib/to-markdown") - apply_es6_file(ctx, root_path, "discourse/app/lib/utilities") ctx.load("#{Rails.root}/lib/pretty_text/shims.js") ctx.eval("__setUnicode(#{Emoji.unicode_replacements_json})") @@ -116,9 +116,9 @@ module PrettyText to_load << a if File.file?(a) && a =~ /discourse-markdown/ end to_load.uniq.each do |f| - if f =~ %r{^.+assets/javascripts/} + if f =~ %r{\A.+assets/javascripts/} root = Regexp.last_match[0] - apply_es6_file(ctx, root, f.sub(root, "").sub(/\.js(\.es6)?$/, "")) + apply_es6_file(ctx, root, f.sub(root, "").sub(/\.js(\.es6)?\z/, "")) end end @@ -202,11 +202,13 @@ module PrettyText __optInput.customEmoji = #{custom_emoji.to_json}; __optInput.customEmojiTranslation = #{Plugin::CustomEmoji.translations.to_json}; __optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer; + __optInput.emojiDenyList = #{Emoji.denied.to_json}; __optInput.lookupUploadUrls = __lookupUploadUrls; - __optInput.censoredRegexp = #{WordWatcher.serializable_word_matcher_regexp(:censor).to_json}; - __optInput.watchedWordsReplace = #{WordWatcher.word_matcher_regexps(:replace).to_json}; - __optInput.watchedWordsLink = #{WordWatcher.word_matcher_regexps(:link).to_json}; + __optInput.censoredRegexp = #{WordWatcher.serializable_word_matcher_regexp(:censor, engine: :js).to_json}; + __optInput.watchedWordsReplace = #{WordWatcher.word_matcher_regexps(:replace, engine: :js).to_json}; + __optInput.watchedWordsLink = #{WordWatcher.word_matcher_regexps(:link, engine: :js).to_json}; __optInput.additionalOptions = #{Site.markdown_additional_options.to_json}; + __optInput.avatar_sizes = #{SiteSetting.avatar_sizes.to_json}; JS buffer << "__optInput.topicId = #{opts[:topic_id].to_i};\n" if opts[:topic_id] @@ -215,16 +217,7 @@ module PrettyText buffer << "__optInput.forceQuoteLink = #{opts[:force_quote_link]};\n" end - if opts[:user_id] - buffer << "__optInput.userId = #{opts[:user_id].to_i};\n" - - # NOTE: If using this for server-side cooking you will end up - # with a Hash once it is passed to a PrettyText::Helper. If - # you use that hash to instanciate a User model, you will want to do - # user.reload before accessing data on this parsed User, otherwise - # AR relations will not be loaded. - buffer << "__optInput.currentUser = #{User.find(opts[:user_id]).to_json}\n" - end + buffer << "__optInput.userId = #{opts[:user_id].to_i};\n" if opts[:user_id] opts[:hashtag_context] = opts[:hashtag_context] || "topic-composer" hashtag_types_as_js = @@ -232,10 +225,8 @@ module PrettyText .ordered_types_for_context(opts[:hashtag_context]) .map { |t| "'#{t}'" } .join(",") - hashtag_icons_as_js = - HashtagAutocompleteService.data_source_icons.map { |i| "'#{i}'" }.join(",") buffer << "__optInput.hashtagTypesInPriorityOrder = [#{hashtag_types_as_js}];\n" - buffer << "__optInput.hashtagIcons = [#{hashtag_icons_as_js}];\n" + buffer << "__optInput.hashtagIcons = #{HashtagAutocompleteService.data_source_icon_map.to_json};\n" buffer << "__textOptions = __buildOptions(__optInput);\n" buffer << ("__pt = new __PrettyText(__textOptions);") @@ -266,8 +257,10 @@ module PrettyText # leaving this here, cause it invokes v8, don't want to implement twice def self.avatar_img(avatar_template, size) protect { v8.eval(<<~JS) } + __optInput = {}; + __optInput.avatar_sizes = #{SiteSetting.avatar_sizes.to_json}; __paths = #{paths_json}; - __utils.avatarImg({size: #{size.inspect}, avatarTemplate: #{avatar_template.inspect}}, __getURL); + require("discourse-common/lib/avatar-utils").avatarImg({size: #{size.inspect}, avatarTemplate: #{avatar_template.inspect}}, __getURL); JS end @@ -319,7 +312,7 @@ module PrettyText add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions scrubber = Loofah::Scrubber.new { |node| node.remove if node.name == "script" } - loofah_fragment = Loofah.fragment(doc.to_html) + loofah_fragment = Loofah.html5_fragment(doc.to_html) loofah_fragment.scrub!(scrubber).to_html end @@ -433,13 +426,19 @@ module PrettyText # extract Youtube links doc - .css("div[data-youtube-id]") + .css("div[data-video-id]") .each do |div| - if div["data-youtube-id"].present? - links << DetectedLink.new( - "https://www.youtube.com/watch?v=#{div["data-youtube-id"]}", - false, - ) + if div["data-video-id"].present? && div["data-provider-name"].present? + base_url = + case div["data-provider-name"] + when "youtube" + "https://www.youtube.com/watch?v=" + when "vimeo" + "https://vimeo.com/" + when "tiktok" + "https://m.tiktok.com/v/" + end + links << DetectedLink.new(base_url + div["data-video-id"], false) end end @@ -458,6 +457,9 @@ module PrettyText end end + mentions = + DiscoursePluginRegistry.apply_modifier(:pretty_text_extract_mentions, mentions, cooked) + mentions.compact! mentions.uniq! mentions @@ -527,7 +529,7 @@ module PrettyText if iframe["data-original-href"].present? vimeo_url = UrlHelper.normalized_encode(iframe["data-original-href"]) else - vimeo_id = iframe["src"].split("/").last + vimeo_id = iframe["src"].split("/").last.sub("?h=", "/") vimeo_url = "https://vimeo.com/#{vimeo_id}" end iframe.replace Nokogiri::HTML5.fragment("

    #{vimeo_url}

    ") diff --git a/lib/pretty_text/helpers.rb b/lib/pretty_text/helpers.rb index 621f59d7f8e..5f48b28bbdc 100644 --- a/lib/pretty_text/helpers.rb +++ b/lib/pretty_text/helpers.rb @@ -9,13 +9,10 @@ module PrettyText # functions here are available to v8 def t(key, opts) key = "js." + key - unless opts - I18n.t(key) - else - str = I18n.t(key, Hash[opts.entries].symbolize_keys).dup - opts.each { |k, v| str.gsub!("{{#{k.to_s}}}", v.to_s) } - str - end + return I18n.t(key) if opts.blank? + str = I18n.t(key, Hash[opts.entries].symbolize_keys).dup + opts.each { |k, v| str.gsub!("{{#{k.to_s}}}", v.to_s) } + str end def avatar_template(username) @@ -102,7 +99,7 @@ module PrettyText # TODO (martin) Remove this when everything is using hashtag_lookup # after enable_experimental_hashtag_autocomplete is default. def category_tag_hashtag_lookup(text) - is_tag = text =~ /#{TAG_HASHTAG_POSTFIX}$/ + is_tag = text =~ /#{TAG_HASHTAG_POSTFIX}\z/ if !is_tag && category = Category.query_from_hashtag_slug(text) [category.url, text] @@ -128,6 +125,11 @@ module PrettyText cooking_user = User.find(cooking_user_id) end + types_in_priority_order = + types_in_priority_order.select do |type| + HashtagAutocompleteService.data_source_types.include?(type) + end + result = HashtagAutocompleteService.new(Guardian.new(cooking_user)).lookup( [slug], diff --git a/lib/pretty_text/shims.js b/lib/pretty_text/shims.js index 1c54183c922..f0d7b0849f1 100644 --- a/lib/pretty_text/shims.js +++ b/lib/pretty_text/shims.js @@ -16,15 +16,14 @@ define("I18n", ["exports"], function (exports) { exports.default = I18n; }); -// Formatting doesn't currently need any helper context define("discourse-common/lib/helpers", ["exports"], function (exports) { exports.helperContext = function () { - return {}; + return { + siteSettings: { avatar_sizes: __optInput.avatar_sizes }, + }; }; }); -__utils = require("discourse/lib/utilities"); - __emojiUnicodeReplacer = null; __setUnicode = function (replacements) { @@ -78,7 +77,7 @@ function __getURLNoCDN(url) { return url; } - if (url.includes(__paths.baseUri)) { + if (url.startsWith(`${__paths.baseUri}/`) || url === __paths.baseUri) { return url; } if (url[0] !== "/") { @@ -118,7 +117,7 @@ function __hashtagLookup(slug, cookingUserId, typesInPriorityOrder) { } function __lookupAvatar(p) { - return __utils.avatarImg( + return require("discourse-common/lib/avatar-utils").avatarImg( { size: "tiny", avatarTemplate: __helpers.avatar_template(p) }, __getURL ); diff --git a/lib/promotion.rb b/lib/promotion.rb index b817ae64414..f18b4924c5d 100644 --- a/lib/promotion.rb +++ b/lib/promotion.rb @@ -82,6 +82,7 @@ class Promotion new_value: new_level, ) end + @user.skip_email_validation = true @user.save! @user.user_profile.recook_bio @user.user_profile.save! @@ -133,17 +134,23 @@ class Promotion # Figure out what a user's trust level should be from scratch def self.recalculate(user, performed_by = nil, use_previous_trust_level: false) granted_trust_level = - TrustLevel.calculate(user, use_previous_trust_level: use_previous_trust_level) - return user.update(trust_level: granted_trust_level) if granted_trust_level.present? + TrustLevel.calculate(user, use_previous_trust_level: use_previous_trust_level) || + TrustLevel[0] - user.update_column(:trust_level, TrustLevel[0]) + # TrustLevel.calculate always returns a value, however we added extra protection just + # in case this changes + user.update_column(:trust_level, TrustLevel[granted_trust_level]) - p = Promotion.new(user) - p.review_tl0 - p.review_tl1 - p.review_tl2 - if user.trust_level == 3 && Promotion.tl3_lost?(user) - user.change_trust_level!(2, log_action_for: performed_by || Discourse.system_user) + return if user.manual_locked_trust_level.present? + + promotion = Promotion.new(user) + + promotion.review_tl0 if granted_trust_level < TrustLevel[1] + promotion.review_tl1 if granted_trust_level < TrustLevel[2] + promotion.review_tl2 if granted_trust_level < TrustLevel[3] + + if user.trust_level == TrustLevel[3] && Promotion.tl3_lost?(user) + user.change_trust_level!(TrustLevel[2], log_action_for: performed_by || Discourse.system_user) end end end diff --git a/lib/quote_rewriter.rb b/lib/quote_rewriter.rb new file mode 100644 index 00000000000..a316b81ca2d --- /dev/null +++ b/lib/quote_rewriter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class QuoteRewriter + def initialize(user_id) + @user_id = user_id + end + + def rewrite_raw_username(raw, old_username, new_username) + pattern = + Regexp.union( + /(?
    \[quote\s*=\s*["'']?.*username:)#{old_username}(?\,?[^\]]*\])/i,
    +        /(?
    \[quote\s*=\s*["'']?)#{old_username}(?\,?[^\]]*\])/i,
    +      )
    +
    +    raw.gsub(pattern, "\\k
    #{new_username}\\k")
    +  end
    +
    +  def rewrite_cooked_username(cooked, old_username, new_username, avatar_img)
    +    pattern = /(?<=\s)#{PrettyText::Helpers.format_username(old_username)}(?=:)/i
    +
    +    cooked
    +      .css("aside.quote")
    +      .each do |aside|
    +        next unless div = aside.at_css("div.title")
    +
    +        username_replaced = false
    +
    +        aside["data-username"] = new_username if aside["data-username"] == old_username
    +
    +        div.children.each do |child|
    +          if child.text?
    +            content = child.content
    +            username_replaced = content.gsub!(pattern, new_username).present?
    +            child.content = content if username_replaced
    +          end
    +        end
    +
    +        if username_replaced || quotes_correct_user?(aside)
    +          div.at_css("img.avatar")&.replace(avatar_img)
    +        end
    +      end
    +  end
    +
    +  def rewrite_raw_display_name(raw, old_display_name, new_display_name)
    +    pattern = /(?
    \[quote\s*=\s*["'']?)#{old_display_name}(?\,[^\]]*username[^\]]*\])/i
    +
    +    raw.gsub(pattern, "\\k
    #{new_display_name}\\k")
    +  end
    +
    +  def rewrite_cooked_display_name(cooked, old_display_name, new_display_name)
    +    pattern = /(?<=\s)#{PrettyText::Helpers.format_username(old_display_name)}(?=:)/i
    +
    +    cooked
    +      .css("aside.quote")
    +      .each do |aside|
    +        next unless div = aside.at_css("div.title")
    +
    +        div.children.each do |child|
    +          if child.text?
    +            content = child.content
    +            display_name_replaced = content.gsub!(pattern, new_display_name).present?
    +            child.content = content if display_name_replaced
    +          end
    +        end
    +      end
    +  end
    +
    +  private
    +
    +  attr_reader :user_id
    +
    +  def quotes_correct_user?(aside)
    +    Post.exists?(topic_id: aside["data-topic"], post_number: aside["data-post"], user_id: user_id)
    +  end
    +end
    diff --git a/lib/rate_limiter.rb b/lib/rate_limiter.rb
    index 2604d6dcff8..29479977225 100644
    --- a/lib/rate_limiter.rb
    +++ b/lib/rate_limiter.rb
    @@ -23,11 +23,6 @@ class RateLimiter
         @disabled
       end
     
    -  # Only used in test, only clears current namespace, does not clear globals
    -  def self.clear_all!
    -    Discourse.redis.delete_prefixed(RateLimiter.key_prefix)
    -  end
    -
       def self.clear_all_global!
         Discourse
           .redis
    diff --git a/lib/require_dependency_backward_compatibility.rb b/lib/require_dependency_backward_compatibility.rb
    index 5b550e45d37..b5e3e0248d7 100644
    --- a/lib/require_dependency_backward_compatibility.rb
    +++ b/lib/require_dependency_backward_compatibility.rb
    @@ -14,7 +14,7 @@ module RequireDependencyBackwardCompatibility
       def require_dependency(filename)
         name = filename.to_s
         return if name == "jobs/base"
    -    return super(name.sub(%r{^lib/}, "")) if name.start_with?("lib/")
    +    return super(name.sub(%r{\Alib/}, "")) if name.start_with?("lib/")
         super
       end
     
    diff --git a/lib/retrieve_title.rb b/lib/retrieve_title.rb
    index 826792650f4..8ea76ecec29 100644
    --- a/lib/retrieve_title.rb
    +++ b/lib/retrieve_title.rb
    @@ -9,7 +9,7 @@ module RetrieveTitle
           max_redirects: max_redirects,
           initial_https_redirect_ignore_limit: initial_https_redirect_ignore_limit,
         )
    -  rescue Net::ReadTimeout, FinalDestination::SSRFDetector::LookupFailedError
    +  rescue Net::ReadTimeout, FinalDestination::SSRFError
         # do nothing for Net::ReadTimeout errors
       end
     
    @@ -32,7 +32,7 @@ module RetrieveTitle
           # A horrible hack - YouTube uses `document.title` to populate the title
           # for some reason. For any other site than YouTube this wouldn't be worth it.
           if title == "YouTube" && html =~ /document\.title *= *"(.*)";/
    -        title = Regexp.last_match[1].sub(/ - YouTube$/, "")
    +        title = Regexp.last_match[1].sub(/ - YouTube\z/, "")
           end
     
           if !title && node = doc.at('meta[property="og:title"]')
    @@ -53,11 +53,12 @@ module RetrieveTitle
     
       def self.max_chunk_size(uri)
         # Exception for sites that leave the title until very late.
    -    if uri.host =~ /(^|\.)amazon\.(com|ca|co\.uk|es|fr|de|it|com\.au|com\.br|cn|in|co\.jp|com\.mx)$/
    +    if uri.host =~
    +         /(^|\.)amazon\.(com|ca|co\.uk|es|fr|de|it|com\.au|com\.br|cn|in|co\.jp|com\.mx)\z/
           return 500
         end
    -    return 300 if uri.host =~ /(^|\.)youtube\.com$/ || uri.host =~ /(^|\.)youtu\.be$/
    -    return 50 if uri.host =~ /(^|\.)github\.com$/
    +    return 300 if uri.host =~ /(^|\.)youtube\.com\z/ || uri.host =~ /(^|\.)youtu\.be\z/
    +    return 50 if uri.host =~ /(^|\.)github\.com\z/
     
         # default is 20k
         20
    diff --git a/lib/route_matcher.rb b/lib/route_matcher.rb
    index 6512f7942f1..2c466055556 100644
    --- a/lib/route_matcher.rb
    +++ b/lib/route_matcher.rb
    @@ -46,7 +46,7 @@ class RouteMatcher
         return true if actions.nil? # actions are unrestricted
     
         # message_bus is not a rails route, special handling
    -    return true if actions.include?("message_bus") && request.fullpath =~ %r{^/message-bus/.*/poll}
    +    return true if actions.include?("message_bus") && request.fullpath =~ %r{\A/message-bus/.*/poll}
     
         path_params = path_params_from_request(request)
         actions.include? "#{path_params[:controller]}##{path_params[:action]}"
    @@ -59,7 +59,7 @@ class RouteMatcher
     
         params.all? do |param|
           param_alias = aliases&.[](param)
    -      allowed_values = [allowed_param_values[param.to_s]].flatten
    +      allowed_values = [allowed_param_values.fetch(param.to_s, [])].flatten
     
           value = requested_params[param.to_s]
           alias_value = requested_params[param_alias.to_s]
    diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb
    index f8082f04caf..b3d98c40a68 100644
    --- a/lib/s3_helper.rb
    +++ b/lib/s3_helper.rb
    @@ -175,12 +175,9 @@ class S3Helper
               cors_rules: final_rules,
             },
           )
    -    rescue Aws::S3::Errors::AccessDenied => err
    -      # TODO (martin) Remove this warning log level once we are sure this new
    -      # ensure_cors! rule is functioning correctly.
    -      Discourse.warn_exception(
    -        err,
    -        message: "Could not PutBucketCors rules for #{@s3_bucket_name}, rules: #{final_rules}",
    +    rescue Aws::S3::Errors::AccessDenied
    +      Rails.logger.info(
    +        "Could not PutBucketCors rules for #{@s3_bucket_name}, rules: #{final_rules}",
           )
           return false
         end
    @@ -296,7 +293,7 @@ class S3Helper
       def create_multipart(key, content_type, metadata: {})
         response =
           s3_client.create_multipart_upload(
    -        acl: "private",
    +        acl: SiteSetting.s3_use_acls ? "private" : nil,
             bucket: s3_bucket_name,
             key: key,
             content_type: content_type,
    diff --git a/lib/s3_inventory.rb b/lib/s3_inventory.rb
    index 91da944b927..3b9f4391e77 100644
    --- a/lib/s3_inventory.rb
    +++ b/lib/s3_inventory.rb
    @@ -334,7 +334,7 @@ class S3Inventory
         objects = []
     
         hive_path = File.join(inventory_path, bucket_name, inventory_id, "hive")
    -    @s3_helper.list(hive_path).each { |obj| objects << obj if obj.key.match?(/symlink\.txt$/i) }
    +    @s3_helper.list(hive_path).each { |obj| objects << obj if obj.key.match?(/symlink\.txt\z/i) }
     
         objects
       rescue Aws::Errors::ServiceError => e
    diff --git a/lib/scheduler/defer.rb b/lib/scheduler/defer.rb
    index a44197216c4..0389fa2571a 100644
    --- a/lib/scheduler/defer.rb
    +++ b/lib/scheduler/defer.rb
    @@ -4,6 +4,7 @@ require "weakref"
     module Scheduler
       module Deferrable
         DEFAULT_TIMEOUT ||= 90
    +    STATS_CACHE_SIZE ||= 100
     
         def initialize
           @async = !Rails.env.test?
    @@ -13,10 +14,12 @@ module Scheduler
             )
     
           @mutex = Mutex.new
    +      @stats_mutex = Mutex.new
           @paused = false
           @thread = nil
           @reactor = nil
           @timeout = DEFAULT_TIMEOUT
    +      @stats = LruRedux::ThreadSafeCache.new(STATS_CACHE_SIZE)
         end
     
         def timeout=(t)
    @@ -27,6 +30,10 @@ module Scheduler
           @queue.size
         end
     
    +    def stats
    +      @stats_mutex.synchronize { @stats.to_a }
    +    end
    +
         def pause
           stop!
           @paused = true
    @@ -42,6 +49,11 @@ module Scheduler
         end
     
         def later(desc = nil, db = RailsMultisite::ConnectionManagement.current_db, force: true, &blk)
    +      @stats_mutex.synchronize do
    +        stats = (@stats[desc] ||= { queued: 0, finished: 0, duration: 0, errors: 0 })
    +        stats[:queued] += 1
    +      end
    +
           if @async
             start_thread if !@thread&.alive? && !@paused
             @queue.push({ key: db, task: [db, blk, desc] }, force: force)
    @@ -71,13 +83,19 @@ module Scheduler
         def start_thread
           @mutex.synchronize do
             @reactor = MessageBus::TimerThread.new if !@reactor
    -        @thread = Thread.new { do_work while true } if !@thread&.alive?
    +        @thread =
    +          Thread.new do
    +            @thread.abort_on_exception = true if Rails.env.test?
    +            do_work while true
    +          end if !@thread&.alive?
           end
         end
     
         # using non_block to match Ruby #deq
         def do_work(non_block = false)
           db, job, desc = @queue.shift(block: !non_block)[:task]
    +
    +      start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
           db ||= RailsMultisite::ConnectionManagement::DEFAULT
     
           RailsMultisite::ConnectionManagement.with_connection(db) do
    @@ -88,6 +106,10 @@ module Scheduler
                 end if !non_block
               job.call
             rescue => ex
    +          @stats_mutex.synchronize do
    +            stats = @stats[desc]
    +            stats[:errors] += 1 if stats
    +          end
               Discourse.handle_job_exception(ex, message: "Running deferred code '#{desc}'")
             ensure
               warning_job&.cancel
    @@ -97,6 +119,15 @@ module Scheduler
           Discourse.handle_job_exception(ex, message: "Processing deferred code queue")
         ensure
           ActiveRecord::Base.connection_handler.clear_active_connections!
    +      if start
    +        @stats_mutex.synchronize do
    +          stats = @stats[desc]
    +          if stats
    +            stats[:finished] += 1
    +            stats[:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
    +          end
    +        end
    +      end
         end
       end
     
    diff --git a/lib/search.rb b/lib/search.rb
    index e81b7ab0d93..05f09dbff00 100644
    --- a/lib/search.rb
    +++ b/lib/search.rb
    @@ -20,7 +20,7 @@ class Search
       end
     
       def self.per_filter
    -    50
    +    SiteSetting.search_page_size
       end
     
       def self.facets
    @@ -89,9 +89,25 @@ class Search
           Regexp.compile("[-–—―.。・()()[]{}{}【】⟨⟩、、,,،…‥〽「」『』〜~!!::??\"'|__“”‘’;/⁄/«»]")
       end
     
    +  def self.clean_term(term)
    +    term = term.to_s.dup
    +
    +    # Removes any zero-width characters from search terms
    +    term.gsub!(/[\u200B-\u200D\uFEFF]/, "")
    +
    +    # Replace curly quotes to regular quotes
    +    term.gsub!(/[\u201c\u201d]/, '"')
    +
    +    # Replace fancy apostophes to regular apostophes
    +    term.gsub!(/[\u02b9\u02bb\u02bc\u02bd\u02c8\u2018\u2019\u201b\u2032\uff07]/, "'")
    +
    +    term
    +  end
    +
       def self.prepare_data(search_data, purpose = nil)
         data = search_data.dup
         data.force_encoding("UTF-8")
    +    data = clean_term(data)
     
         if purpose != :topic
           if segment_chinese?
    @@ -128,7 +144,7 @@ class Search
         end
     
         data.gsub!(/\S+/) do |str|
    -      if str =~ %r{^["]?((https?://)[\S]+)["]?$}
    +      if str =~ %r{\A["]?((https?://)[\S]+)["]?\z}
             begin
               uri = URI.parse(Regexp.last_match[1])
               uri.query = nil
    @@ -145,9 +161,9 @@ class Search
       end
     
       def self.word_to_date(str)
    -    return Time.zone.now.beginning_of_day.days_ago(str.to_i) if str =~ /^[0-9]{1,3}$/
    +    return Time.zone.now.beginning_of_day.days_ago(str.to_i) if str =~ /\A[0-9]{1,3}\z/
     
    -    if str =~ /^([12][0-9]{3})(-([0-1]?[0-9]))?(-([0-3]?[0-9]))?$/
    +    if str =~ /\A([12][0-9]{3})(-([0-1]?[0-9]))?(-([0-3]?[0-9]))?\z/
           year = $1.to_i
           month = $2 ? $3.to_i : 1
           day = $4 ? $5.to_i : 1
    @@ -214,12 +230,7 @@ class Search
         @page = @opts[:page]
         @search_all_pms = false
     
    -    term = term.to_s.dup
    -
    -    # Removes any zero-width characters from search terms
    -    term.gsub!(/[\u200B-\u200D\uFEFF]/, "")
    -    # Replace curly quotes to regular quotes
    -    term.gsub!(/[\u201c\u201d]/, '"')
    +    term = Search.clean_term(term)
     
         @clean_term = term
         @in_title = false
    @@ -307,7 +318,7 @@ class Search
     
         # If the term is a number or url to a topic, just include that topic
         if @opts[:search_for_id] && %w[topic private_messages all_topics].include?(@results.type_filter)
    -      if @term =~ /^\d+$/
    +      if @term =~ /\A\d+\z/
             single_topic(@term.to_i)
           else
             if route = Discourse.route_for(@term)
    @@ -355,7 +366,7 @@ class Search
         Array.wrap(@custom_topic_eager_loads)
       end
     
    -  advanced_filter(/^in:personal-direct$/i) do |posts|
    +  advanced_filter(/\Ain:personal-direct\z/i) do |posts|
         if @guardian.user
           posts.joins("LEFT JOIN topic_allowed_groups tg ON posts.topic_id = tg.topic_id").where(
             <<~SQL,
    @@ -376,61 +387,61 @@ class Search
         end
       end
     
    -  advanced_filter(/^in:all-pms$/i) { |posts| posts.private_posts if @guardian.is_admin? }
    +  advanced_filter(/\Ain:all-pms\z/i) { |posts| posts.private_posts if @guardian.is_admin? }
     
    -  advanced_filter(/^in:tagged$/i) do |posts|
    +  advanced_filter(/\Ain:tagged\z/i) do |posts|
         posts.where("EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = posts.topic_id)")
       end
     
    -  advanced_filter(/^in:untagged$/i) do |posts|
    +  advanced_filter(/\Ain:untagged\z/i) do |posts|
         posts.joins(
           "LEFT JOIN topic_tags ON
             topic_tags.topic_id = posts.topic_id",
         ).where("topic_tags.id IS NULL")
       end
     
    -  advanced_filter(/^status:open$/i) do |posts|
    +  advanced_filter(/\Astatus:open\z/i) do |posts|
         posts.where("NOT topics.closed AND NOT topics.archived")
       end
     
    -  advanced_filter(/^status:closed$/i) { |posts| posts.where("topics.closed") }
    +  advanced_filter(/\Astatus:closed\z/i) { |posts| posts.where("topics.closed") }
     
    -  advanced_filter(/^status:public$/i) do |posts|
    +  advanced_filter(/\Astatus:public\z/i) do |posts|
         category_ids = Category.where(read_restricted: false).pluck(:id)
     
         posts.where("topics.category_id in (?)", category_ids)
       end
     
    -  advanced_filter(/^status:archived$/i) { |posts| posts.where("topics.archived") }
    +  advanced_filter(/\Astatus:archived\z/i) { |posts| posts.where("topics.archived") }
     
    -  advanced_filter(/^status:noreplies$/i) { |posts| posts.where("topics.posts_count = 1") }
    +  advanced_filter(/\Astatus:noreplies\z/i) { |posts| posts.where("topics.posts_count = 1") }
     
    -  advanced_filter(/^status:single_user$/i) { |posts| posts.where("topics.participant_count = 1") }
    +  advanced_filter(/\Astatus:single_user\z/i) { |posts| posts.where("topics.participant_count = 1") }
     
    -  advanced_filter(/^posts_count:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Aposts_count:(\d+)\z/i) do |posts, match|
         posts.where("topics.posts_count = ?", match.to_i)
       end
     
    -  advanced_filter(/^min_post_count:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Amin_post_count:(\d+)\z/i) do |posts, match|
         posts.where("topics.posts_count >= ?", match.to_i)
       end
     
    -  advanced_filter(/^min_posts:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Amin_posts:(\d+)\z/i) do |posts, match|
         posts.where("topics.posts_count >= ?", match.to_i)
       end
     
    -  advanced_filter(/^max_posts:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Amax_posts:(\d+)\z/i) do |posts, match|
         posts.where("topics.posts_count <= ?", match.to_i)
       end
     
    -  advanced_filter(/^in:first|^f$/i) { |posts| posts.where("posts.post_number = 1") }
    +  advanced_filter(/\Ain:first|^f\z/i) { |posts| posts.where("posts.post_number = 1") }
     
    -  advanced_filter(/^in:pinned$/i) { |posts| posts.where("topics.pinned_at IS NOT NULL") }
    +  advanced_filter(/\Ain:pinned\z/i) { |posts| posts.where("topics.pinned_at IS NOT NULL") }
     
    -  advanced_filter(/^in:wiki$/i) { |posts, match| posts.where(wiki: true) }
    +  advanced_filter(/\Ain:wiki\z/i) { |posts, match| posts.where(wiki: true) }
     
    -  advanced_filter(/^badge:(.*)$/i) do |posts, match|
    -    badge_id = Badge.where("name ilike ? OR id = ?", match, match.to_i).pluck_first(:id)
    +  advanced_filter(/\Abadge:(.*)\z/i) do |posts, match|
    +    badge_id = Badge.where("name ilike ? OR id = ?", match, match.to_i).pick(:id)
         if badge_id
           posts.where(
             "posts.user_id IN (SELECT ub.user_id FROM user_badges ub WHERE ub.badge_id = ?)",
    @@ -454,7 +465,7 @@ class Search
         )
       end
     
    -  advanced_filter(/^in:(likes)$/i) do |posts, match|
    +  advanced_filter(/\Ain:(likes)\z/i) do |posts, match|
         post_action_type_filter(posts, PostActionType.types[:like]) if @guardian.user
       end
     
    @@ -462,7 +473,7 @@ class Search
       # this at some point, as it only acts on posts at the moment. On the other
       # hand, this may not be necessary, as the user bookmark list has advanced
       # search based on a RegisteredBookmarkable's #search_query method.
    -  advanced_filter(/^in:(bookmarks)$/i) do |posts, match|
    +  advanced_filter(/\Ain:(bookmarks)\z/i) do |posts, match|
         posts.where(<<~SQL, @guardian.user.id) if @guardian.user
             posts.id IN (
               SELECT bookmarkable_id FROM bookmarks
    @@ -471,20 +482,20 @@ class Search
           SQL
       end
     
    -  advanced_filter(/^in:posted$/i) do |posts|
    +  advanced_filter(/\Ain:posted\z/i) do |posts|
         posts.where("posts.user_id = ?", @guardian.user.id) if @guardian.user
       end
     
    -  advanced_filter(/^in:(created|mine)$/i) do |posts|
    +  advanced_filter(/\Ain:(created|mine)\z/i) do |posts|
         posts.where(user_id: @guardian.user.id, post_number: 1) if @guardian.user
       end
     
    -  advanced_filter(/^created:@(.*)$/i) do |posts, match|
    -    user_id = User.where(username: match.downcase).pluck_first(:id)
    +  advanced_filter(/\Acreated:@(.*)\z/i) do |posts, match|
    +    user_id = User.where(username: match.downcase).pick(:id)
         posts.where(user_id: user_id, post_number: 1)
       end
     
    -  advanced_filter(/^in:(watching|tracking)$/i) do |posts, match|
    +  advanced_filter(/\Ain:(watching|tracking)\z/i) do |posts, match|
         if @guardian.user
           level = TopicUser.notification_levels[match.downcase.to_sym]
           posts.where(
    @@ -499,7 +510,7 @@ class Search
         end
       end
     
    -  advanced_filter(/^in:seen$/i) do |posts|
    +  advanced_filter(/\Ain:seen\z/i) do |posts|
         if @guardian.user
           posts.joins(
             "INNER JOIN post_timings ON
    @@ -511,7 +522,7 @@ class Search
         end
       end
     
    -  advanced_filter(/^in:unseen$/i) do |posts|
    +  advanced_filter(/\Ain:unseen\z/i) do |posts|
         if @guardian.user
           posts.joins(
             "LEFT JOIN post_timings ON
    @@ -523,9 +534,9 @@ class Search
         end
       end
     
    -  advanced_filter(/^with:images$/i) { |posts| posts.where("posts.image_upload_id IS NOT NULL") }
    +  advanced_filter(/\Awith:images\z/i) { |posts| posts.where("posts.image_upload_id IS NOT NULL") }
     
    -  advanced_filter(/^category:(.+)$/i) do |posts, match|
    +  advanced_filter(/\Acategory:(.+)\z/i) do |posts, match|
         exact = false
     
         if match[0] == "="
    @@ -544,7 +555,7 @@ class Search
         end
       end
     
    -  advanced_filter(/^\#([\p{L}\p{M}0-9\-:=]+)$/i) do |posts, match|
    +  advanced_filter(/\A\#([\p{L}\p{M}0-9\-:=]+)\z/i) do |posts, match|
         category_slug, subcategory_slug = match.to_s.split(":")
         next unless category_slug
     
    @@ -563,12 +574,12 @@ class Search
                 parent_category_id:
                   Category.where("lower(slug) = ?", category_slug.downcase).select(:id),
               )
    -          .pluck_first(:id)
    +          .pick(:id)
           else
             Category
               .where("lower(slug) = ?", category_slug.downcase)
               .order("case when parent_category_id is null then 0 else 1 end")
    -          .pluck_first(:id)
    +          .pick(:id)
           end
     
         if category_id
    @@ -579,7 +590,7 @@ class Search
           posts.where("topics.category_id IN (?)", category_ids)
         else
           # try a possible tag match
    -      tag_id = Tag.where_name(category_slug).pluck_first(:id)
    +      tag_id = Tag.where_name(category_slug).pick(:id)
           if (tag_id)
             posts.where(<<~SQL, tag_id)
               topics.id IN (
    @@ -614,13 +625,18 @@ class Search
         end
       end
     
    -  advanced_filter(/^group:(.+)$/i) do |posts, match|
    -    group_id =
    +  advanced_filter(/\Agroup:(.+)\z/i) do |posts, match|
    +    group_query =
           Group
             .visible_groups(@guardian.user)
             .members_visible_groups(@guardian.user)
    -        .where("name ilike ? OR (id = ? AND id > 0)", match, match.to_i)
    -        .pluck_first(:id)
    +        .where("groups.name ILIKE ? OR (groups.id = ? AND groups.id > 0)", match, match.to_i)
    +
    +    DiscoursePluginRegistry.search_groups_set_query_callbacks.each do |cb|
    +      group_query = cb.call(group_query, @term, @guardian)
    +    end
    +
    +    group_id = group_query.pick(:id)
     
         if group_id
           posts.where(
    @@ -632,14 +648,14 @@ class Search
         end
       end
     
    -  advanced_filter(/^group_messages:(.+)$/i) do |posts, match|
    +  advanced_filter(/\Agroup_messages:(.+)\z/i) do |posts, match|
         group_id =
           Group
             .visible_groups(@guardian.user)
             .members_visible_groups(@guardian.user)
             .where(has_messages: true)
             .where("name ilike ? OR (id = ? AND id > 0)", match, match.to_i)
    -        .pluck_first(:id)
    +        .pick(:id)
     
         if group_id
           posts.where(
    @@ -651,12 +667,12 @@ class Search
         end
       end
     
    -  advanced_filter(/^user:(.+)$/i) do |posts, match|
    +  advanced_filter(/\Auser:(.+)\z/i) do |posts, match|
         user_id =
           User
             .where(staged: false)
             .where("username_lower = ? OR id = ?", match.downcase, match.to_i)
    -        .pluck_first(:id)
    +        .pick(:id)
         if user_id
           posts.where("posts.user_id = ?", user_id)
         else
    @@ -664,10 +680,10 @@ class Search
         end
       end
     
    -  advanced_filter(/^\@(\S+)$/i) do |posts, match|
    +  advanced_filter(/\A\@(\S+)\z/i) do |posts, match|
         username = User.normalize_username(match)
     
    -    user_id = User.not_staged.where(username_lower: username).pluck_first(:id)
    +    user_id = User.not_staged.where(username_lower: username).pick(:id)
     
         user_id = @guardian.user&.id if !user_id && username == "me"
     
    @@ -678,7 +694,7 @@ class Search
         end
       end
     
    -  advanced_filter(/^before:(.*)$/i) do |posts, match|
    +  advanced_filter(/\Abefore:(.*)\z/i) do |posts, match|
         if date = Search.word_to_date(match)
           posts.where("posts.created_at < ?", date)
         else
    @@ -686,7 +702,7 @@ class Search
         end
       end
     
    -  advanced_filter(/^after:(.*)$/i) do |posts, match|
    +  advanced_filter(/\Aafter:(.*)\z/i) do |posts, match|
         if date = Search.word_to_date(match)
           posts.where("posts.created_at > ?", date)
         else
    @@ -694,15 +710,15 @@ class Search
         end
       end
     
    -  advanced_filter(/^tags?:([\p{L}\p{M}0-9,\-_+]+)$/i) do |posts, match|
    +  advanced_filter(/\Atags?:([\p{L}\p{M}0-9,\-_+]+)\z/i) do |posts, match|
         search_tags(posts, match, positive: true)
       end
     
    -  advanced_filter(/^\-tags?:([\p{L}\p{M}0-9,\-_+]+)$/i) do |posts, match|
    +  advanced_filter(/\A\-tags?:([\p{L}\p{M}0-9,\-_+]+)\z/i) do |posts, match|
         search_tags(posts, match, positive: false)
       end
     
    -  advanced_filter(/^filetypes?:([a-zA-Z0-9,\-_]+)$/i) do |posts, match|
    +  advanced_filter(/\Afiletypes?:([a-zA-Z0-9,\-_]+)\z/i) do |posts, match|
         file_extensions = match.split(",").map(&:downcase)
         posts.where(
           "posts.id IN (
    @@ -721,11 +737,11 @@ class Search
         )
       end
     
    -  advanced_filter(/^min_views:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Amin_views:(\d+)\z/i) do |posts, match|
         posts.where("topics.views >= ?", match.to_i)
       end
     
    -  advanced_filter(/^max_views:(\d+)$/i) do |posts, match|
    +  advanced_filter(/\Amax_views:(\d+)\z/i) do |posts, match|
         posts.where("topics.views <= ?", match.to_i)
       end
     
    @@ -784,38 +800,38 @@ class Search
             if word == "l"
               @order = :latest
               nil
    -        elsif word =~ /^order:\w+$/i
    +        elsif word =~ /\Aorder:\w+\z/i
               @order = word.downcase.gsub("order:", "").to_sym
               nil
    -        elsif word =~ /^in:title$/i || word == "t"
    +        elsif word =~ /\Ain:title\z/i || word == "t"
               @in_title = true
               nil
    -        elsif word =~ /^topic:(\d+)$/i
    +        elsif word =~ /\Atopic:(\d+)\z/i
               topic_id = $1.to_i
               if topic_id > 1
                 topic = Topic.find_by(id: topic_id)
                 @search_context = topic if @guardian.can_see?(topic)
               end
               nil
    -        elsif word =~ /^in:all$/i
    +        elsif word =~ /\Ain:all\z/i
               @search_all_topics = true
               nil
    -        elsif word =~ /^in:personal$/i
    +        elsif word =~ /\Ain:personal\z/i
               @search_pms = true
               nil
    -        elsif word =~ /^in:messages$/i
    +        elsif word =~ /\Ain:messages\z/i
               @search_pms = true
               nil
    -        elsif word =~ /^in:personal-direct$/i
    +        elsif word =~ /\Ain:personal-direct\z/i
               @search_pms = true
               nil
    -        elsif word =~ /^in:all-pms$/i
    +        elsif word =~ /\Ain:all-pms\z/i
               @search_all_pms = true
               nil
    -        elsif word =~ /^group_messages:(.+)$/i
    +        elsif word =~ /\Agroup_messages:(.+)\z/i
               @search_pms = true
               nil
    -        elsif word =~ /^personal_messages:(.+)$/i
    +        elsif word =~ /\Apersonal_messages:(.+)\z/i
               if user = User.find_by_username($1)
                 @search_pms = true
                 @search_context = user
    @@ -944,11 +960,17 @@ class Search
       end
     
       def groups_search
    -    groups =
    -      Group
    -        .visible_groups(@guardian.user, "name ASC", include_everyone: false)
    -        .where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%")
    -        .limit(limit)
    +    group_query =
    +      Group.visible_groups(@guardian.user, "groups.name ASC", include_everyone: false).where(
    +        "groups.name ILIKE :term OR groups.full_name ILIKE :term",
    +        term: "%#{@term}%",
    +      )
    +
    +    DiscoursePluginRegistry.search_groups_set_query_callbacks.each do |cb|
    +      group_query = cb.call(group_query, @term, @guardian)
    +    end
    +
    +    groups = group_query.limit(limit)
     
         groups.each { |group| @results.add(group) }
       end
    @@ -1102,12 +1124,24 @@ class Search
           else
             posts = posts.reorder("posts.created_at DESC")
           end
    +    elsif @order == :oldest
    +      if aggregate_search
    +        posts = posts.order("MAX(posts.created_at) ASC")
    +      else
    +        posts = posts.reorder("posts.created_at ASC")
    +      end
         elsif @order == :latest_topic
           if aggregate_search
             posts = posts.order("MAX(topics.created_at) DESC")
           else
             posts = posts.order("topics.created_at DESC")
           end
    +    elsif @order == :oldest_topic
    +      if aggregate_search
    +        posts = posts.order("MAX(topics.created_at) ASC")
    +      else
    +        posts = posts.order("topics.created_at ASC")
    +      end
         elsif @order == :views
           if aggregate_search
             posts = posts.order("MAX(topics.views) DESC")
    @@ -1121,13 +1155,13 @@ class Search
             posts = posts.order("posts.like_count DESC")
           end
         elsif !is_topic_search
    -      rank = <<~SQL
    -      TS_RANK_CD(
    -        post_search_data.search_data,
    -        #{@term.blank? ? "" : ts_query(weight_filter: weights)},
    -        #{SiteSetting.search_ranking_normalization}|32
    -      )
    -      SQL
    +      exact_rank = nil
    +
    +      if SiteSetting.prioritize_exact_search_title_match
    +        exact_rank = ts_rank_cd(weight_filter: "A", prefix_match: false)
    +      end
    +
    +      rank = ts_rank_cd(weight_filter: weights)
     
           if type_filter != "private_messages"
             category_search_priority = <<~SQL
    @@ -1142,22 +1176,48 @@ class Search
             )
             SQL
     
    +        rank_sort_priorities = [["topics.archived", 0.85], ["topics.closed", 0.9]]
    +
    +        rank_sort_priorities =
    +          DiscoursePluginRegistry.apply_modifier(
    +            :search_rank_sort_priorities,
    +            rank_sort_priorities,
    +            self,
    +          )
    +
             category_priority_weights = <<~SQL
    -        (
    -          CASE categories.search_priority
    -          WHEN #{Searchable::PRIORITIES[:low]}
    -          THEN #{SiteSetting.category_search_priority_low_weight}
    -          WHEN #{Searchable::PRIORITIES[:high]}
    -          THEN #{SiteSetting.category_search_priority_high_weight}
    -          ELSE
    -            CASE WHEN topics.closed
    -            THEN 0.9
    -            ELSE 1
    +          (
    +            CASE categories.search_priority
    +              WHEN #{Searchable::PRIORITIES[:low]}
    +              THEN #{SiteSetting.category_search_priority_low_weight.to_f}
    +              WHEN #{Searchable::PRIORITIES[:high]}
    +              THEN #{SiteSetting.category_search_priority_high_weight.to_f}
    +              ELSE 1.0
                 END
    -          END
    -        )
    +            *
    +            CASE
    +              #{rank_sort_priorities.sort_by { |_, pri| -pri }.map { |k, v| "WHEN #{k} THEN #{v}" }.join("\n")}
    +              ELSE 1.0
    +            END
    +          )
             SQL
     
    +        posts =
    +          if aggregate_search
    +            posts.order("MAX(#{category_search_priority}) DESC")
    +          else
    +            posts.order("#{category_search_priority} DESC")
    +          end
    +
    +        if @term.present? && exact_rank
    +          posts =
    +            if aggregate_search
    +              posts.order("MAX(#{exact_rank} * #{category_priority_weights}) DESC")
    +            else
    +              posts.order("#{exact_rank} * #{category_priority_weights} DESC")
    +            end
    +        end
    +
             data_ranking =
               if @term.blank?
                 "(#{category_priority_weights})"
    @@ -1167,9 +1227,9 @@ class Search
     
             posts =
               if aggregate_search
    -            posts.order("MAX(#{category_search_priority}) DESC", "MAX(#{data_ranking}) DESC")
    +            posts.order("MAX(#{data_ranking}) DESC")
               else
    -            posts.order("#{category_search_priority} DESC", "#{data_ranking} DESC")
    +            posts.order("#{data_ranking} DESC")
               end
           end
     
    @@ -1199,6 +1259,17 @@ class Search
         posts.limit(limit)
       end
     
    +  def ts_rank_cd(weight_filter:, prefix_match: true)
    +    <<~SQL
    +      TS_RANK_CD(
    +        #{SiteSetting.search_ranking_weights.present? ? "'#{SiteSetting.search_ranking_weights}'," : ""}
    +        post_search_data.search_data,
    +        #{@term.blank? ? "" : ts_query(weight_filter: weight_filter, prefix_match: prefix_match)},
    +        #{SiteSetting.search_ranking_normalization}|32
    +      )
    +      SQL
    +  end
    +
       def categories_ignored(posts)
         posts.where(<<~SQL, Searchable::PRIORITIES[:ignore])
         (categories.search_priority IS NULL OR categories.search_priority IS NOT NULL AND categories.search_priority <> ?)
    @@ -1213,37 +1284,42 @@ class Search
         self.class.default_ts_config
       end
     
    -  def self.ts_query(term:, ts_config: nil, joiner: nil, weight_filter: nil)
    -    to_tsquery(ts_config: ts_config, term: set_tsquery_weight_filter(term, weight_filter))
    +  def self.ts_query(term:, ts_config: nil, joiner: nil, weight_filter: nil, prefix_match: true)
    +    to_tsquery(
    +      ts_config: ts_config,
    +      term: set_tsquery_weight_filter(term, weight_filter, prefix_match: prefix_match),
    +    )
       end
     
       def self.to_tsquery(ts_config: nil, term:, joiner: nil)
         ts_config = ActiveRecord::Base.connection.quote(ts_config) if ts_config
         escaped_term = wrap_unaccent("'#{escape_string(term)}'")
         tsquery = "TO_TSQUERY(#{ts_config || default_ts_config}, #{escaped_term})"
    +    # PG 14 and up default to using the followed by operator
    +    # this restores the old behavior
    +    tsquery = "REPLACE(#{tsquery}::text, '<->', '&')::tsquery"
         tsquery = "REPLACE(#{tsquery}::text, '&', '#{escape_string(joiner)}')::tsquery" if joiner
         tsquery
       end
     
    -  def self.set_tsquery_weight_filter(term, weight_filter)
    -    "'#{self.escape_string(term)}':*#{weight_filter}"
    +  def self.set_tsquery_weight_filter(term, weight_filter, prefix_match: true)
    +    "'#{self.escape_string(term)}':#{prefix_match ? "*" : ""}#{weight_filter}"
       end
     
       def self.escape_string(term)
    -    # HACK: The ’ and other similar characters have to be "unaccented" before
    -    # it is escaped or the resulting tsqueries will be invalid
    -    if SiteSetting.search_ignore_accents
    -      term = term.gsub(/[\u02b9\u02bb\u02bc\u02bd\u02c8\u2018\u2019\u201b\u2032\uff07]/, "'")
    -    end
    -
         PG::Connection.escape_string(term).gsub('\\', '\\\\\\')
       end
     
    -  def ts_query(ts_config = nil, weight_filter: nil)
    +  def ts_query(ts_config = nil, weight_filter: nil, prefix_match: true)
         @ts_query_cache ||= {}
         @ts_query_cache[
    -      "#{ts_config || default_ts_config} #{@term} #{weight_filter}"
    -    ] ||= Search.ts_query(term: @term, ts_config: ts_config, weight_filter: weight_filter)
    +      "#{ts_config || default_ts_config} #{@term} #{weight_filter} #{prefix_match}"
    +    ] ||= Search.ts_query(
    +      term: @term,
    +      ts_config: ts_config,
    +      weight_filter: weight_filter,
    +      prefix_match: prefix_match,
    +    )
       end
     
       def wrap_rows(query)
    @@ -1251,8 +1327,6 @@ class Search
       end
     
       def aggregate_post_sql(opts)
    -    default_opts = { type_filter: opts[:type_filter] }
    -
         min_id =
           if SiteSetting.search_recent_regular_posts_offset_post_id > 0
             if %w[all_topics private_message].include?(opts[:type_filter])
    diff --git a/lib/search/grouped_search_results.rb b/lib/search/grouped_search_results.rb
    index f3470963eb5..0fbe7fbb636 100644
    --- a/lib/search/grouped_search_results.rb
    +++ b/lib/search/grouped_search_results.rb
    @@ -24,6 +24,7 @@ class Search
           :search_context,
           :more_full_page_results,
           :error,
    +      :use_pg_headlines_for_excerpt,
         )
     
         attr_accessor :search_log_id
    @@ -36,7 +37,8 @@ class Search
           search_context:,
           blurb_length: nil,
           blurb_term: nil,
    -      is_header_search: false
    +      is_header_search: false,
    +      use_pg_headlines_for_excerpt: SiteSetting.use_pg_headlines_for_excerpt
         )
           @type_filter = type_filter
           @term = term
    @@ -50,6 +52,7 @@ class Search
           @groups = []
           @error = nil
           @is_header_search = is_header_search
    +      @use_pg_headlines_for_excerpt = use_pg_headlines_for_excerpt
         end
     
         def error=(error)
    @@ -71,9 +74,9 @@ class Search
         def blurb(post)
           opts = { term: @blurb_term, blurb_length: @blurb_length }
     
    -      if post.post_search_data.version > SearchIndexer::MIN_POST_REINDEX_VERSION &&
    +      if post.post_search_data.version >= SearchIndexer::MIN_POST_BLURB_INDEX_VERSION &&
                !Search.segment_chinese? && !Search.segment_japanese?
    -        if SiteSetting.use_pg_headlines_for_excerpt
    +        if use_pg_headlines_for_excerpt
               scrubbed_headline = post.headline.gsub(SCRUB_HEADLINE_REGEXP, '\1')
               prefix_omission = scrubbed_headline.start_with?(post.leading_raw_data) ? "" : OMISSION
               postfix_omission = scrubbed_headline.end_with?(post.trailing_raw_data) ? "" : OMISSION
    diff --git a/lib/seed_data/categories.rb b/lib/seed_data/categories.rb
    index dd050d86983..e2ca27a13e4 100644
    --- a/lib/seed_data/categories.rb
    +++ b/lib/seed_data/categories.rb
    @@ -138,9 +138,9 @@ module SeedData
             SiteSetting.set(site_setting_name, category.id)
     
             if sidebar
    -          sidebar_categories = SiteSetting.default_sidebar_categories.split("|")
    +          sidebar_categories = SiteSetting.default_navigation_menu_categories.split("|")
               sidebar_categories << category.id
    -          SiteSetting.set("default_sidebar_categories", sidebar_categories.join("|"))
    +          SiteSetting.set("default_navigation_menu_categories", sidebar_categories.join("|"))
             end
     
             SiteSetting.set("default_composer_category", category.id) if default_composer_category
    diff --git a/lib/seed_data/topics.rb b/lib/seed_data/topics.rb
    index d98106579d9..d800b117baf 100644
    --- a/lib/seed_data/topics.rb
    +++ b/lib/seed_data/topics.rb
    @@ -10,18 +10,28 @@ module SeedData
           @locale = locale
         end
     
    -    def create(site_setting_names: nil, include_welcome_topics: true)
    +    def create(site_setting_names: nil, include_welcome_topics: true, include_legal_topics: false)
           I18n.with_locale(@locale) do
    -        topics(site_setting_names, include_welcome_topics).each { |params| create_topic(**params) }
    +        topics(
    +          site_setting_names: site_setting_names,
    +          include_welcome_topics: include_welcome_topics,
    +          include_legal_topics: include_legal_topics || SiteSetting.company_name.present?,
    +        ).each { |params| create_topic(**params) }
           end
         end
     
         def update(site_setting_names: nil, skip_changed: false)
           I18n.with_locale(@locale) do
    -        topics(site_setting_names).each do |params|
    -          params.except!(:category, :after_create)
    -          params[:skip_changed] = skip_changed
    -          update_topic(**params)
    +        topics(site_setting_names: site_setting_names).each do |params|
    +          update_topic(**params.except(:category, :after_create), skip_changed: skip_changed)
    +        end
    +      end
    +    end
    +
    +    def delete(site_setting_names: nil, skip_changed: false)
    +      I18n.with_locale(@locale) do
    +        topics(site_setting_names: site_setting_names).each do |params|
    +          delete_topic(**params.slice(:site_setting_name), skip_changed: skip_changed)
             end
           end
         end
    @@ -41,12 +51,14 @@ module SeedData
     
         private
     
    -    def topics(site_setting_names = nil, include_welcome_topics = true)
    +    def topics(site_setting_names: nil, include_welcome_topics: true, include_legal_topics: true)
           staff_category = Category.find_by(id: SiteSetting.staff_category_id)
     
    -      topics = [
    -        # Terms of Service
    -        {
    +      topics = []
    +
    +      # Terms of Service
    +      if include_legal_topics
    +        topics << {
               site_setting_name: "tos_topic_id",
               title: I18n.t("tos_topic.title"),
               raw:
    @@ -60,32 +72,54 @@ module SeedData
                 ),
               category: staff_category,
               static_first_reply: true,
    -        },
    -        # FAQ/Guidelines
    -        {
    -          site_setting_name: "guidelines_topic_id",
    -          title: I18n.t("guidelines_topic.title"),
    -          raw: I18n.t("guidelines_topic.body", base_path: Discourse.base_path),
    -          category: staff_category,
    -          static_first_reply: true,
    -        },
    -        # Privacy Policy
    -        {
    +        }
    +      end
    +
    +      # FAQ/Guidelines
    +      topics << {
    +        site_setting_name: "guidelines_topic_id",
    +        title: I18n.t("guidelines_topic.title"),
    +        raw: I18n.t("guidelines_topic.body", base_path: Discourse.base_path),
    +        category: staff_category,
    +        static_first_reply: true,
    +      }
    +
    +      # Privacy Policy
    +      if include_legal_topics
    +        topics << {
               site_setting_name: "privacy_topic_id",
               title: I18n.t("privacy_topic.title"),
               raw: I18n.t("privacy_topic.body"),
               category: staff_category,
               static_first_reply: true,
    -        },
    -      ]
    +        }
    +      end
     
           if include_welcome_topics
             # Welcome Topic
             if general_category = Category.find_by(id: SiteSetting.general_category_id)
    +          site_info_quote =
    +            if SiteSetting.title.present? && SiteSetting.site_description.present?
    +              <<~RAW
    +              > ## #{SiteSetting.title}
    +              >
    +              > #{SiteSetting.site_description}
    +              RAW
    +            else
    +              ""
    +            end
    +
               topics << {
                 site_setting_name: "welcome_topic_id",
    -            title: I18n.t("discourse_welcome_topic.title"),
    -            raw: I18n.t("discourse_welcome_topic.body", base_path: Discourse.base_path),
    +            title: I18n.t("discourse_welcome_topic.title", site_title: SiteSetting.title),
    +            raw:
    +              I18n.t(
    +                "discourse_welcome_topic.body",
    +                base_path: Discourse.base_path,
    +                site_title: SiteSetting.title,
    +                site_description: SiteSetting.site_description,
    +                site_info_quote: site_info_quote,
    +              ),
                 category: general_category,
                 after_create: proc { |post| post.topic.update_pinned(true, true) },
               }
    @@ -146,27 +180,42 @@ module SeedData
         end
     
         def update_topic(site_setting_name:, title:, raw:, static_first_reply: false, skip_changed:)
    -      post = find_post(site_setting_name)
    +      post = find_post(site_setting_name, deleted: true)
           return if !post
     
           if !skip_changed || unchanged?(post)
    -        changes = { title: title, raw: raw }
    -        post.revise(Discourse.system_user, changes, skip_validations: true)
    +        if post.trashed?
    +          PostDestroyer.new(Discourse.system_user, post).recover
    +          post.reload
    +        end
    +
    +        post.revise(Discourse.system_user, { title: title, raw: raw }, skip_validations: true)
           end
     
           if static_first_reply && (reply = first_reply(post)) && (!skip_changed || unchanged?(reply))
    -        changes = { raw: first_reply_raw(title) }
    -        reply.revise(Discourse.system_user, changes, skip_validations: true)
    +        reply.revise(Discourse.system_user, { raw: first_reply_raw(title) }, skip_validations: true)
           end
         end
     
    -    def find_post(site_setting_name)
    +    def delete_topic(site_setting_name:, skip_changed:)
    +      post = find_post(site_setting_name)
    +      return if !post
    +
    +      PostDestroyer.new(Discourse.system_user, post).destroy if !skip_changed || unchanged?(post)
    +    end
    +
    +    def find_post(site_setting_name, deleted: false)
           topic_id = SiteSetting.get(site_setting_name)
    -      Post.find_by(topic_id: topic_id, post_number: 1) if topic_id > 0
    +      return if topic_id < 1
    +
    +      posts = Post.where(topic_id: topic_id, post_number: 1)
    +      posts = posts.with_deleted if deleted
    +      posts.first
         end
     
         def unchanged?(post)
    -      post.last_editor_id == Discourse::SYSTEM_USER_ID
    +      post.last_editor_id == Discourse::SYSTEM_USER_ID &&
    +        (!post.deleted_by_id || post.deleted_by_id == Discourse::SYSTEM_USER_ID)
         end
     
         def setting_value(site_setting_key)
    diff --git a/lib/shrink_uploaded_image.rb b/lib/shrink_uploaded_image.rb
    index d1a7f14f012..f46a3b72073 100644
    --- a/lib/shrink_uploaded_image.rb
    +++ b/lib/shrink_uploaded_image.rb
    @@ -98,7 +98,7 @@ class ShrinkUploadedImage
           elsif !post.topic || post.topic.trashed?
             log "A deleted topic"
           elsif post.cooked.include?(original_upload.sha1)
    -        if post.raw.include?("#{Discourse.base_url.sub(%r{^https?://}i, "")}/t/")
    +        if post.raw.include?("#{Discourse.base_url.sub(%r{\Ahttps?://}i, "")}/t/")
               log "Updating a topic onebox"
             else
               log "Updating an external onebox"
    diff --git a/lib/site_icon_manager.rb b/lib/site_icon_manager.rb
    index 198d1c7faaa..2ecfec40521 100644
    --- a/lib/site_icon_manager.rb
    +++ b/lib/site_icon_manager.rb
    @@ -61,6 +61,10 @@ module SiteIconManager
     
       WATCHED_SETTINGS = ICONS.keys + %i[logo logo_small]
     
    +  def self.clear_cache!
    +    @cache.clear
    +  end
    +
       def self.ensure_optimized!
         unless @disabled
           ICONS.each do |name, info|
    diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb
    index c42230f30e1..929902f11ac 100644
    --- a/lib/site_setting_extension.rb
    +++ b/lib/site_setting_extension.rb
    @@ -115,55 +115,12 @@ module SiteSettingExtension
         @plugins ||= {}
       end
     
    -  def setting(name_arg, default = nil, opts = {})
    -    name = name_arg.to_sym
    -
    -    if name == :default_locale
    -      raise Discourse::InvalidParameters.new(
    -              "Other settings depend on default locale, you can not configure it like this",
    -            )
    -    end
    -
    -    shadowed_val = nil
    -
    -    mutex.synchronize do
    -      defaults.load_setting(name, default, opts.delete(:locale_default))
    -
    -      categories[name] = opts[:category] || :uncategorized
    -
    -      hidden_settings << name if opts[:hidden]
    -
    -      if GlobalSetting.respond_to?(name)
    -        val = GlobalSetting.public_send(name)
    -
    -        unless val.nil? || (val == "")
    -          shadowed_val = val
    -          hidden_settings << name
    -          shadowed_settings << name
    -        end
    +  def load_settings(file, plugin: nil)
    +    SiteSettings::YamlLoader
    +      .new(file)
    +      .load do |category, name, default, opts|
    +        setting(name, default, opts.merge(category: category, plugin: plugin))
           end
    -
    -      refresh_settings << name if opts[:refresh]
    -
    -      client_settings << name.to_sym if opts[:client]
    -
    -      previews[name] = opts[:preview] if opts[:preview]
    -
    -      secret_settings << name if opts[:secret]
    -
    -      plugins[name] = opts[:plugin] if opts[:plugin]
    -
    -      type_supervisor.load_setting(
    -        name,
    -        opts.extract!(*SiteSettings::TypeSupervisor::CONSUMED_OPTS),
    -      )
    -
    -      if !shadowed_val.nil?
    -        setup_shadowed_methods(name, shadowed_val)
    -      else
    -        setup_methods(name)
    -      end
    -    end
       end
     
       def settings_hash
    @@ -224,6 +181,9 @@ module SiteSettingExtension
     
         defaults
           .all(default_locale)
    +      .reject do |setting_name, _|
    +        plugins[name] && !Discourse.plugins_by_name[plugins[name]].configurable?
    +      end
           .reject { |setting_name, _| !include_hidden && hidden_settings.include?(setting_name) }
           .map do |s, v|
             type_hash = type_supervisor.type_hash(s)
    @@ -323,7 +283,6 @@ module SiteSettingExtension
     
       def process_message(message)
         begin
    -      @last_message_processed = message.global_id
           MessageBus.on_connect.call(message.site_id)
           refresh!
         ensure
    @@ -331,10 +290,6 @@ module SiteSettingExtension
         end
       end
     
    -  def diags
    -    { last_message_processed: @last_message_processed }
    -  end
    -
       def process_id
         @process_id ||= SecureRandom.uuid
       end
    @@ -549,6 +504,11 @@ module SiteSettingExtension
           end
         else
           define_singleton_method clean_name do
    +        if plugins[name]
    +          plugin = Discourse.plugins_by_name[plugins[name]]
    +          return false if !plugin.configurable? && plugin.enabled_site_setting == name
    +        end
    +
             if (c = current[name]).nil?
               refresh!
               current[name]
    @@ -567,6 +527,13 @@ module SiteSettingExtension
           end
         end
     
    +    # Same logic as above for group_list settings, with the caveat that normal
    +    # list settings are not necessarily integers, so we just want to handle the splitting.
    +    if type_supervisor.get_type(name) == :list &&
    +         %w[simple compact].include?(type_supervisor.get_list_type(name))
    +      define_singleton_method("#{clean_name}_map") { self.public_send(clean_name).to_s.split("|") }
    +    end
    +
         define_singleton_method "#{clean_name}?" do
           self.public_send clean_name
         end
    @@ -596,6 +563,57 @@ module SiteSettingExtension
     
       private
     
    +  def setting(name_arg, default = nil, opts = {})
    +    name = name_arg.to_sym
    +
    +    if name == :default_locale
    +      raise Discourse::InvalidParameters.new(
    +              "Other settings depend on default locale, you can not configure it like this",
    +            )
    +    end
    +
    +    shadowed_val = nil
    +
    +    mutex.synchronize do
    +      defaults.load_setting(name, default, opts.delete(:locale_default))
    +
    +      categories[name] = opts[:category] || :uncategorized
    +
    +      hidden_settings << name if opts[:hidden]
    +
    +      if GlobalSetting.respond_to?(name)
    +        val = GlobalSetting.public_send(name)
    +
    +        unless val.nil? || (val == "")
    +          shadowed_val = val
    +          hidden_settings << name
    +          shadowed_settings << name
    +        end
    +      end
    +
    +      refresh_settings << name if opts[:refresh]
    +
    +      client_settings << name.to_sym if opts[:client]
    +
    +      previews[name] = opts[:preview] if opts[:preview]
    +
    +      secret_settings << name if opts[:secret]
    +
    +      plugins[name] = opts[:plugin] if opts[:plugin]
    +
    +      type_supervisor.load_setting(
    +        name,
    +        opts.extract!(*SiteSettings::TypeSupervisor::CONSUMED_OPTS),
    +      )
    +
    +      if !shadowed_val.nil?
    +        setup_shadowed_methods(name, shadowed_val)
    +      else
    +        setup_methods(name)
    +      end
    +    end
    +  end
    +
       def default_uploads
         @default_uploads ||= {}
     
    diff --git a/lib/site_settings/defaults_provider.rb b/lib/site_settings/defaults_provider.rb
    index 1c75093aa81..6c8354110ac 100644
    --- a/lib/site_settings/defaults_provider.rb
    +++ b/lib/site_settings/defaults_provider.rb
    @@ -31,18 +31,18 @@ class SiteSettings::DefaultsProvider
       end
     
       def all(locale = nil)
    -    if locale
    -      @defaults[DEFAULT_LOCALE.to_sym].merge(@defaults[locale.to_sym] || {})
    -    else
    -      @defaults[DEFAULT_LOCALE.to_sym].dup
    -    end
    +    result =
    +      if locale
    +        @defaults[DEFAULT_LOCALE.to_sym].merge(@defaults[locale.to_sym] || {})
    +      else
    +        @defaults[DEFAULT_LOCALE.to_sym].dup
    +      end
    +
    +    DiscoursePluginRegistry.apply_modifier(:site_setting_defaults, result)
       end
     
       def get(name, locale = DEFAULT_LOCALE)
    -    value = @defaults.dig(locale.to_sym, name.to_sym)
    -    return value unless value.nil?
    -
    -    @defaults.dig(DEFAULT_LOCALE.to_sym, name.to_sym)
    +    all(locale)[name.to_sym]
       end
       alias [] get
     
    diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb
    index acff911c84d..db4bfa054a1 100644
    --- a/lib/site_settings/type_supervisor.rb
    +++ b/lib/site_settings/type_supervisor.rb
    @@ -54,6 +54,7 @@ class SiteSettings::TypeSupervisor
             simple_list: 23,
             emoji_list: 24,
             html_deprecated: 25,
    +        tag_group_list: 26,
           )
       end
     
    @@ -195,6 +196,10 @@ class SiteSettings::TypeSupervisor
         self.class.types[@types[name.to_sym]]
       end
     
    +  def get_list_type(name)
    +    @list_type[name.to_sym]
    +  end
    +
       private
     
       def normalize_input(name, val)
    diff --git a/lib/site_settings/validations.rb b/lib/site_settings/validations.rb
    index fdc36ff57e4..4a25bc9eda4 100644
    --- a/lib/site_settings/validations.rb
    +++ b/lib/site_settings/validations.rb
    @@ -96,7 +96,7 @@ module SiteSettings::Validations
         validate_default_categories(category_ids, default_categories_selected)
       end
     
    -  def validate_default_categories_regular(new_val)
    +  def validate_default_categories_normal(new_val)
         category_ids = validate_category_ids(new_val)
     
         default_categories_selected = [
    @@ -168,11 +168,15 @@ module SiteSettings::Validations
       end
     
       def validate_secure_uploads(new_val)
    -    if new_val == "t" && !SiteSetting.Upload.enable_s3_uploads
    +    if new_val == "t" && (!SiteSetting.Upload.enable_s3_uploads || !SiteSetting.s3_use_acls)
           validate_error :secure_uploads_requirements
         end
       end
     
    +  def validate_s3_use_acls(new_val)
    +    validate_error :s3_use_acls_requirements if new_val == "f" && SiteSetting.secure_uploads
    +  end
    +
       def validate_enable_page_publishing(new_val)
         validate_error :page_publishing_requirements if new_val == "t" && SiteSetting.secure_uploads?
       end
    @@ -243,7 +247,7 @@ module SiteSettings::Validations
     
       def validate_cors_origins(new_val)
         return if new_val.blank?
    -    return unless new_val.split("|").any?(%r{/$})
    +    return if new_val.split("|").none?(%r{/\z})
         validate_error :cors_origins_should_not_have_trailing_slash
       end
     
    diff --git a/lib/site_settings/yaml_loader.rb b/lib/site_settings/yaml_loader.rb
    index cfd44db56a1..6d303d726c1 100644
    --- a/lib/site_settings/yaml_loader.rb
    +++ b/lib/site_settings/yaml_loader.rb
    @@ -38,10 +38,6 @@ class SiteSettings::YamlLoader
       private
     
       def load_yaml(path)
    -    if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1.0")
    -      YAML.load_file(path, aliases: true)
    -    else
    -      YAML.load_file(path)
    -    end
    +    YAML.load_file(path, aliases: true)
       end
     end
    diff --git a/lib/stylesheet/compiler.rb b/lib/stylesheet/compiler.rb
    index 356a518d172..d395c388d00 100644
    --- a/lib/stylesheet/compiler.rb
    +++ b/lib/stylesheet/compiler.rb
    @@ -55,18 +55,15 @@ module Stylesheet
               style: :compressed,
               source_map_file: source_map_file,
               source_map_contents: true,
    -          theme_id: options[:theme_id],
    -          theme: options[:theme],
    -          theme_field: options[:theme_field],
    -          color_scheme_id: options[:color_scheme_id],
               load_paths: load_paths,
    +          validate_source_map_path: false,
             )
     
           result = engine.render
     
           if options[:rtl]
    -        require "r2"
    -        [R2.r2(result), nil]
    +        require "rtlcss"
    +        [Rtlcss.flip_css(result), nil]
           else
             source_map = engine.source_map
             source_map.force_encoding("UTF-8")
    diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb
    index 217bed7737e..7f71831ba87 100644
    --- a/lib/stylesheet/manager.rb
    +++ b/lib/stylesheet/manager.rb
    @@ -11,7 +11,7 @@ class Stylesheet::Manager
     
       CACHE_PATH ||= "tmp/stylesheet-cache"
       MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}"
    -  THEME_REGEX ||= /_theme$/
    +  THEME_REGEX ||= /_theme\z/
       COLOR_SCHEME_STYLESHEET ||= "color_definitions"
     
       @@lock = Mutex.new
    @@ -43,13 +43,21 @@ class Stylesheet::Manager
       end
     
       def self.precompile_css
    -    targets = %i[desktop mobile desktop_rtl mobile_rtl admin wizard]
    +    targets = %i[desktop mobile admin wizard desktop_rtl mobile_rtl admin_rtl wizard_rtl]
    +
         targets +=
           Discourse.find_plugin_css_assets(
             include_disabled: true,
             mobile_view: true,
             desktop_view: true,
           )
    +    targets +=
    +      Discourse.find_plugin_css_assets(
    +        include_disabled: true,
    +        mobile_view: true,
    +        desktop_view: true,
    +        rtl: true,
    +      )
     
         targets.each do |target|
           $stderr.puts "precompile target: #{target}"
    diff --git a/lib/stylesheet/manager/builder.rb b/lib/stylesheet/manager/builder.rb
    index 47dbae0650c..5f4fbabbaa0 100644
    --- a/lib/stylesheet/manager/builder.rb
    +++ b/lib/stylesheet/manager/builder.rb
    @@ -35,11 +35,11 @@ class Stylesheet::Manager::Builder
           end
         end
     
    -    rtl = @target.to_s =~ /_rtl$/
    +    rtl = @target.to_s.end_with?("_rtl")
         css, source_map =
           with_load_paths do |load_paths|
             Stylesheet::Compiler.compile_asset(
    -          @target,
    +          @target.to_s.gsub(/_rtl\z/, "").to_sym,
               rtl: rtl,
               theme_id: theme&.id,
               theme_variables: theme&.scss_variables.to_s,
    @@ -47,7 +47,7 @@ class Stylesheet::Manager::Builder
               color_scheme_id: @color_scheme&.id,
               load_paths: load_paths,
             )
    -      rescue SassC::SyntaxError => e
    +      rescue SassC::SyntaxError, SassC::NotRenderedError => e
             if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s)
               # no special errors for theme, handled in theme editor
               ["", nil]
    diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb
    index a2c8189e83b..9740758d076 100644
    --- a/lib/stylesheet/watcher.rb
    +++ b/lib/stylesheet/watcher.rb
    @@ -21,7 +21,9 @@ module Stylesheet
           @default_paths = ["app/assets/stylesheets"]
           Discourse.plugins.each do |plugin|
             if plugin.path.to_s.include?(Rails.root.to_s)
    -          @default_paths << File.dirname(plugin.path).sub(Rails.root.to_s, "").sub(%r{^/}, "")
    +          path = File.dirname(plugin.path).sub(Rails.root.to_s, "").sub(%r{\A/}, "")
    +          path << "/assets/stylesheets"
    +          @default_paths << path if File.exist?(path)
             else
               # if plugin doesn’t seem to be in our app, consider it as outside of the app
               # and ignore it
    @@ -41,7 +43,7 @@ module Stylesheet
             end
           end
     
    -      listener_opts = { ignore: /xxxx/, only: /\.(css|scss)$/ }
    +      listener_opts = { ignore: [/node_modules/], only: /\.s?css\z/ }
           listener_opts[:force_polling] = true if ENV["FORCE_POLLING"]
     
           Thread.new do
    diff --git a/lib/summarization/base.rb b/lib/summarization/base.rb
    new file mode 100644
    index 00000000000..53fbba5ec1e
    --- /dev/null
    +++ b/lib/summarization/base.rb
    @@ -0,0 +1,93 @@
    +# frozen_string_literal: true
    +
    +# Base class that defines the interface that every summarization
    +# strategy must implement.
    +# Above each method, you'll find an explanation of what
    +# it does and what it should return.
    +
    +module Summarization
    +  class Base
    +    class << self
    +      def available_strategies
    +        DiscoursePluginRegistry.summarization_strategies
    +      end
    +
    +      def find_strategy(strategy_model)
    +        available_strategies.detect { |s| s.model == strategy_model }
    +      end
    +
    +      def selected_strategy
    +        return if SiteSetting.summarization_strategy.blank?
    +
    +        find_strategy(SiteSetting.summarization_strategy)
    +      end
    +
    +      def can_see_summary?(target, user)
    +        return false if SiteSetting.summarization_strategy.blank?
    +
    +        has_cached_summary = SummarySection.exists?(target: target, meta_section_id: nil)
    +        return has_cached_summary if user.nil?
    +
    +        has_cached_summary || can_request_summary_for?(user)
    +      end
    +
    +      def can_request_summary_for?(user)
    +        return false unless user
    +
    +        user_group_ids = user.group_ids
    +
    +        SiteSetting.custom_summarization_allowed_groups_map.any? do |group_id|
    +          user_group_ids.include?(group_id)
    +        end
    +      end
    +    end
    +
    +    # Some strategies could require other conditions to work correctly,
    +    # like site settings.
    +    # This method gets called when admins attempt to select it,
    +    # checking if we met those conditions.
    +    def correctly_configured?
    +      raise NotImplemented
    +    end
    +
    +    # Strategy name to display to admins in the available strategies dropdown.
    +    def display_name
    +      raise NotImplemented
    +    end
    +
    +    # If we don't meet the conditions to enable this strategy,
    +    # we'll display this hint as an error to admins.
    +    def configuration_hint
    +      raise NotImplemented
    +    end
    +
    +    # The idea behind this method is "give me a collection of texts,
    +    # and I'll handle the summarization to the best of my capabilities.".
    +    # It's important to emphasize the "collection of texts" part, which implies
    +    # it's not tied to any model and expects the "content" to be a hash instead.
    +    #
    +    # @param content { Hash } - Includes the content to summarize, plus additional
    +    # context to help the strategy produce a better result. Keys present in the content hash:
    +    #  - resource_path (optional): Helps the strategy build links to the content in the summary (e.g. "/t/-/:topic_id/POST_NUMBER")
    +    #  - content_title (optional): Provides guidance about what the content is about.
    +    #  - contents (required): Array of hashes with content to summarize (e.g. [{ poster: "asd", id: 1, text: "This is a text" }])
    +    #    All keys are required.
    +    #
    +    # @returns { Hash } - The summarized content, plus chunks if the content couldn't be summarized in one pass. Example:
    +    #   {
    +    #     summary: "This is the final summary",
    +    #     chunks: [
    +    #       { ids: [topic.first_post.post_number], summary: "this is the first chunk" },
    +    #       { ids: [post_1.post_number, post_2.post_number], summary: "this is the second chunk" },
    +    #     ],
    +    #   }
    +    def summarize(content)
    +      raise NotImplemented
    +    end
    +
    +    # Returns the string we'll store in the selected strategy site setting.
    +    def model
    +      raise NotImplemented
    +    end
    +  end
    +end
    diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
    index afb62d54574..0fae05743d4 100644
    --- a/lib/svg_sprite.rb
    +++ b/lib/svg_sprite.rb
    @@ -6,6 +6,7 @@ module SvgSprite
           %w[
             adjust
             address-book
    +        align-left
             ambulance
             anchor
             angle-double-down
    @@ -34,6 +35,7 @@ module SvgSprite
             book-reader
             bookmark
             briefcase
    +        bullseye
             calendar-alt
             caret-down
             caret-left
    @@ -45,11 +47,13 @@ module SvgSprite
             check
             check-circle
             check-square
    +        chevron-circle-down
             chevron-down
             chevron-left
             chevron-right
             chevron-up
             circle
    +        cloud-upload-alt
             code
             cog
             columns
    @@ -68,6 +72,7 @@ module SvgSprite
             discourse-emojis
             discourse-expand
             discourse-other-tab
    +        discourse-threads
             download
             ellipsis-h
             ellipsis-v
    @@ -77,6 +82,7 @@ module SvgSprite
             exclamation-circle
             exclamation-triangle
             external-link-alt
    +        eye
             fab-android
             fab-apple
             fab-chrome
    @@ -134,6 +140,7 @@ module SvgSprite
             gift
             globe
             globe-americas
    +        grip-lines
             hand-point-right
             hands-helping
             heart
    @@ -142,6 +149,7 @@ module SvgSprite
             hourglass-start
             id-card
             image
    +        images
             inbox
             info-circle
             italic
    @@ -180,6 +188,8 @@ module SvgSprite
             reply
             rocket
             search
    +        search-plus
    +        search-minus
             share
             shield-alt
             sign-in-alt
    @@ -197,6 +207,7 @@ module SvgSprite
             tag
             tags
             tasks
    +        th
             thermometer-three-quarters
             thumbs-down
             thumbs-up
    @@ -238,53 +249,94 @@ module SvgSprite
         badge_icons
       end
     
    -  def self.custom_svg_sprites(theme_id)
    -    get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(",")}") do
    -      plugin_paths = []
    -      Discourse
    -        .plugins
    -        .map { |plugin| File.dirname(plugin.path) }
    -        .each { |path| plugin_paths << "#{path}/svg-icons/*.svg" }
    -
    -      custom_sprite_paths = Dir.glob(plugin_paths)
    -
    -      custom_sprites =
    -        custom_sprite_paths.map do |path|
    -          if File.exist?(path)
    -            { filename: "#{File.basename(path, ".svg")}", sprite: File.read(path) }
    -          end
    +  def self.symbols_for(svg_filename, sprite, strict:)
    +    if strict
    +      Nokogiri.XML(sprite) { |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS }
    +    else
    +      Nokogiri.XML(sprite)
    +    end.css("symbol")
    +      .filter_map do |sym|
    +        icon_id = prepare_symbol(sym, svg_filename)
    +        if icon_id.present?
    +          sym.attributes["id"].value = icon_id
    +          sym.css("title").each(&:remove)
    +          [icon_id, sym.to_xml]
             end
    +      end
    +      .to_h
    +  end
     
    -      if theme_id.present?
    -        ThemeField
    -          .where(
    +  def self.core_svgs
    +    @core_svgs ||=
    +      CORE_SVG_SPRITES.reduce({}) do |symbols, path|
    +        symbols.merge!(symbols_for(File.basename(path, ".svg"), File.read(path), strict: true))
    +      end
    +  end
    +
    +  # Just used in tests
    +  def self.clear_plugin_svg_sprite_cache!
    +    @plugin_svgs = nil
    +  end
    +
    +  def self.plugin_svgs
    +    @plugin_svgs ||=
    +      begin
    +        plugin_paths = []
    +        Discourse
    +          .plugins
    +          .map { |plugin| File.dirname(plugin.path) }
    +          .each { |path| plugin_paths << "#{path}/svg-icons/*.svg" }
    +
    +        custom_sprite_paths = Dir.glob(plugin_paths)
    +
    +        custom_sprite_paths.reduce({}) do |symbols, path|
    +          symbols.merge!(symbols_for(File.basename(path, ".svg"), File.read(path), strict: true))
    +        end
    +      end
    +  end
    +
    +  def self.theme_svgs(theme_id)
    +    if theme_id.present?
    +      theme_ids = Theme.transform_ids(theme_id)
    +
    +      get_set_cache("theme_svg_sprites_#{theme_ids.join(",")}") do
    +        theme_field_uploads =
    +          ThemeField.where(
                 type_id: ThemeField.types[:theme_upload_var],
                 name: THEME_SPRITE_VAR_NAME,
    -            theme_id: Theme.transform_ids(theme_id),
    +            theme_id: theme_ids,
    +          ).pluck(:upload_id)
    +
    +        theme_sprites = ThemeSvgSprite.where(theme_id: theme_ids).pluck(:upload_id, :sprite)
    +        missing_sprites = (theme_field_uploads - theme_sprites.map(&:first))
    +
    +        if missing_sprites.present?
    +          Rails.logger.warn(
    +            "Missing ThemeSvgSprites for theme #{theme_id}, uploads #{missing_sprites.join(", ")}",
               )
    -          .pluck(:upload_id, :theme_id)
    -          .each do |upload_id, child_theme_id|
    -            begin
    -              upload = Upload.find(upload_id)
    -              custom_sprites << {
    -                filename: "theme_#{theme_id}_#{upload_id}.svg",
    -                sprite: upload.content,
    -              }
    -            rescue => e
    -              name =
    -                begin
    -                  Theme.find(child_theme_id).name
    -                rescue StandardError
    -                  nil
    -                end
    -              Discourse.warn_exception(e, message: "#{name} theme contains a corrupt svg upload")
    -            end
    +        end
    +
    +        theme_sprites.reduce({}) do |symbols, (upload_id, sprite)|
    +          begin
    +            symbols.merge!(symbols_for("theme_#{theme_id}_#{upload_id}.svg", sprite, strict: false))
    +          rescue => e
    +            Rails.logger.warn(
    +              "Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}",
    +            )
               end
    +
    +          symbols
    +        end
           end
    -      custom_sprites
    +    else
    +      {}
         end
       end
     
    +  def self.custom_svgs(theme_id)
    +    plugin_svgs.merge(theme_svgs(theme_id))
    +  end
    +
       def self.all_icons(theme_id = nil)
         get_set_cache("icons_#{Theme.transform_ids(theme_id).join(",")}") do
           Set
    @@ -316,42 +368,10 @@ module SvgSprite
         cache&.clear
       end
     
    -  def self.sprite_sources(theme_id)
    -    sprites = []
    -
    -    CORE_SVG_SPRITES.each do |path|
    -      if File.exist?(path)
    -        sprites << { filename: "#{File.basename(path, ".svg")}", sprite: File.read(path) }
    -      end
    -    end
    -
    -    sprites = sprites + custom_svg_sprites(theme_id) if theme_id.present?
    -
    -    sprites
    -  end
    -
    -  def self.core_svgs
    -    @core_svgs ||=
    -      begin
    -        symbols = {}
    -
    -        CORE_SVG_SPRITES.each do |filename|
    -          svg_filename = "#{File.basename(filename, ".svg")}"
    -
    -          Nokogiri
    -            .XML(File.open(filename)) do |config|
    -              config.options = Nokogiri::XML::ParseOptions::NOBLANKS
    -            end
    -            .css("symbol")
    -            .each do |sym|
    -              icon_id = prepare_symbol(sym, svg_filename)
    -              sym.attributes["id"].value = icon_id
    -              symbols[icon_id] = sym.to_xml
    -            end
    -        end
    -
    -        symbols
    -      end
    +  def self.svgs_for(theme_id)
    +    svgs = core_svgs
    +    svgs = svgs.merge(custom_svgs(theme_id)) if theme_id.present?
    +    svgs
       end
     
       def self.bundle(theme_id = nil)
    @@ -367,34 +387,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
     " \
             "".dup
     
    -    core_svgs.each { |icon_id, sym| svg_subset << sym if icons.include?(icon_id) }
    -
    -    custom_svg_sprites(theme_id).each do |item|
    -      begin
    -        svg_file =
    -          Nokogiri.XML(item[:sprite]) do |config|
    -            config.options = Nokogiri::XML::ParseOptions::NOBLANKS
    -          end
    -      rescue => e
    -        Rails.logger.warn(
    -          "Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}",
    -        )
    -      end
    -
    -      next if !svg_file
    -
    -      svg_file
    -        .css("symbol")
    -        .each do |sym|
    -          icon_id = prepare_symbol(sym, item[:filename])
    -
    -          if icons.include? icon_id
    -            sym.attributes["id"].value = icon_id
    -            sym.css("title").each(&:remove)
    -            svg_subset << sym.to_xml
    -          end
    -        end
    -    end
    +    svg_subset << core_svgs.slice(*icons).values.join
    +    svg_subset << custom_svgs(theme_id).values.join
     
         svg_subset << ""
       end
    @@ -402,44 +396,16 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
       def self.search(searched_icon)
         searched_icon = process(searched_icon.dup)
     
    -    sprite_sources(SiteSetting.default_theme_id).each do |item|
    -      svg_file = Nokogiri.XML(item[:sprite])
    -
    -      svg_file
    -        .css("symbol")
    -        .each do |sym|
    -          icon_id = prepare_symbol(sym, item[:filename])
    -
    -          if searched_icon == icon_id
    -            sym.attributes["id"].value = icon_id
    -            sym.css("title").each(&:remove)
    -            return sym.to_xml
    -          end
    -        end
    -    end
    -
    -    false
    +    svgs_for(SiteSetting.default_theme_id)[searched_icon] || false
       end
     
    -  def self.icon_picker_search(keyword)
    -    results = Set.new
    +  def self.icon_picker_search(keyword, only_available = false)
    +    icons = all_icons(SiteSetting.default_theme_id) if only_available
     
    -    sprite_sources(SiteSetting.default_theme_id).each do |item|
    -      svg_file = Nokogiri.XML(item[:sprite])
    -
    -      svg_file
    -        .css("symbol")
    -        .each do |sym|
    -          icon_id = prepare_symbol(sym, item[:filename])
    -          if keyword.empty? || icon_id.include?(keyword)
    -            sym.attributes["id"].value = icon_id
    -            sym.css("title").each(&:remove)
    -            results.add(id: icon_id, symbol: sym.to_xml)
    -          end
    -        end
    -    end
    -
    -    results.sort_by { |icon| icon[:id] }
    +    symbols = svgs_for(SiteSetting.default_theme_id)
    +    symbols.slice!(*icons) if only_available
    +    symbols.reject! { |icon_id, sym| !icon_id.include?(keyword) } unless keyword.empty?
    +    symbols.sort_by(&:first).map { |icon_id, symbol| { id: icon_id, symbol: symbol } }
       end
     
       # For use in no_ember .html.erb layouts
    @@ -522,14 +488,7 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL
     
       def self.custom_icons(theme_id)
         # Automatically register icons in sprites added via themes or plugins
    -    icons = []
    -    custom_svg_sprites(theme_id).each do |item|
    -      svg_file = Nokogiri.XML(item[:sprite])
    -      svg_file
    -        .css("symbol")
    -        .each { |sym| icons << sym.attributes["id"].value if sym.attributes["id"].present? }
    -    end
    -    icons
    +    custom_svgs(theme_id).keys
       end
     
       def self.process(icon_name)
    diff --git a/lib/system_message.rb b/lib/system_message.rb
    index 53ce825c800..14abbe4e0a3 100644
    --- a/lib/system_message.rb
    +++ b/lib/system_message.rb
    @@ -73,7 +73,12 @@ class SystemMessage
     
         post = I18n.with_locale(@recipient.effective_locale) { creator.create }
     
    -    DiscourseEvent.trigger(:system_message_sent, post: post, message_type: type)
    +    DiscourseEvent.trigger(
    +      :system_message_sent,
    +      post: post,
    +      message_type: type,
    +      recipient: @recipient,
    +    )
     
         raise StandardError, creator.errors.full_messages.join(" ") if creator.errors.present?
     
    @@ -88,6 +93,8 @@ class SystemMessage
         {
           site_name: SiteSetting.title,
           username: @recipient.username,
    +      name: @recipient.name,
    +      name_or_username: @recipient.name.presence || @recipient.username,
           user_preferences_url: "#{@recipient.full_url}/preferences",
           new_user_tips:
             I18n.with_locale(@recipient.effective_locale) do
    diff --git a/lib/tasks/annotate.rake b/lib/tasks/annotate.rake
    index 677c7d6d1f4..57bc0129ac3 100644
    --- a/lib/tasks/annotate.rake
    +++ b/lib/tasks/annotate.rake
    @@ -17,7 +17,7 @@ desc "ensure the asynchronously-created post_search_data index is present"
     task "annotate:ensure_all_indexes" => :environment do |task, args|
       # One of the indexes on post_search_data is created by a sidekiq job
       # We need to do some acrobatics to create it on-demand
    -  SeedData::Topics.with_default_locale.create(include_welcome_topics: true)
    +  SeedData::Topics.with_default_locale.create
       SiteSetting.search_enable_recent_regular_posts_offset_size = 1
       Jobs::CreateRecentPostSearchIndexes.new.execute([])
     end
    diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
    index f00ab3068e9..3078ee427ea 100644
    --- a/lib/tasks/assets.rake
    +++ b/lib/tasks/assets.rake
    @@ -277,8 +277,12 @@ task "assets:precompile" => "assets:precompile:before" do
           puts "Downloading MaxMindDB..."
           mmdb_thread =
             Thread.new do
    +          name = "unknown"
               begin
    -            geolite_dbs.each { |db| DiscourseIpInfo.mmdb_download(db) }
    +            geolite_dbs.each do |db|
    +              name = db
    +              DiscourseIpInfo.mmdb_download(db)
    +            end
     
                 if GlobalSetting.maxmind_backup_path.present?
                   copy_maxmind(DiscourseIpInfo.path, GlobalSetting.maxmind_backup_path)
    @@ -307,7 +311,8 @@ task "assets:precompile" => "assets:precompile:before" do
           concurrent? do |proc|
             manifest
               .files
    -          .select { |k, v| k =~ /\.js$/ }
    +          .select { |k, v| k =~ /\.js\z/ }
    +          .reject { |k, v| k =~ %r{/workbox-.*'/} }
               .each do |file, info|
                 path = "#{assets_path}/#{file}"
                 _file = (d = File.dirname(file)) == "." ? "_#{file}" : "#{d}/_#{File.basename(file)}"
    diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake
    index 1e33a62166b..703af6834ed 100644
    --- a/lib/tasks/categories.rake
    +++ b/lib/tasks/categories.rake
    @@ -24,6 +24,7 @@ task "categories:move_topics", %i[from_category to_category] => [:environment] d
     
         puts "Updating category stats..."
         Category.update_stats
    +    CategoryTagStat.update_topic_counts
       end
     
       puts "", "Done!", ""
    diff --git a/lib/tasks/cdn.rake b/lib/tasks/cdn.rake
    index ee2b0c14ee0..559144bf9d2 100644
    --- a/lib/tasks/cdn.rake
    +++ b/lib/tasks/cdn.rake
    @@ -10,7 +10,7 @@ task "assets:prestage" => :environment do |t|
       def get_assets(path)
         Dir
           .glob("#{Rails.root}/public/assets/#{path}*")
    -      .map { |f| "/assets/#{path}#{f.split("/")[-1]}" if f =~ /[a-f0-9]{16}\.(css|js)$/ }
    +      .map { |f| "/assets/#{path}#{f.split("/")[-1]}" if f =~ /[a-f0-9]{16}\.(css|js)\z/ }
           .compact
       end
     
    diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake
    index 403fd6749cb..3170506fd1a 100644
    --- a/lib/tasks/db.rake
    +++ b/lib/tasks/db.rake
    @@ -109,7 +109,7 @@ class SeedHelper
       def self.filter
         # Allows a plugin to exclude any specified seed data files from running
         if DiscoursePluginRegistry.seedfu_filter.any?
    -      /^(?!.*(#{DiscoursePluginRegistry.seedfu_filter.to_a.join("|")})).*$/
    +      /\A(?!.*(#{DiscoursePluginRegistry.seedfu_filter.to_a.join("|")})).*\z/
         else
           nil
         end
    @@ -575,3 +575,18 @@ task "db:status:json" do
         puts({ status: "ok" }.to_json)
       end
     end
    +
    +desc "Grow notification id column to a big int in case of overflow"
    +task "db:resize:notification_id" => :environment do
    +  sql = <<~SQL
    +    SELECT table_name, column_name FROM INFORMATION_SCHEMA.columns
    +    WHERE (column_name like '%notification_id' OR column_name = 'id' and table_name = 'notifications') AND data_type = 'integer'
    +  SQL
    +
    +  DB
    +    .query(sql)
    +    .each do |row|
    +      puts "Changing #{row.table_name}(#{row.column_name}) to a bigint"
    +      DB.exec("ALTER table #{row.table_name} ALTER COLUMN #{row.column_name} TYPE BIGINT")
    +    end
    +end
    diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake
    index 0e3c40e0de8..1dd110f1309 100644
    --- a/lib/tasks/docker.rake
    +++ b/lib/tasks/docker.rake
    @@ -11,6 +11,7 @@
     # => SKIP_PLUGINS              set to 1 to skip plugin tests (rspec and qunit)
     # => SKIP_INSTALL_PLUGINS      comma separated list of plugins you want to skip installing
     # => INSTALL_OFFICIAL_PLUGINS  set to 1 to install all core plugins before running tests
    +# => RUN_SYSTEM_TESTS          set to 1 to run the system tests as well
     # => RUBY_ONLY                 set to 1 to skip all qunit tests
     # => JS_ONLY                   set to 1 to skip all rspec tests
     # => SINGLE_PLUGIN             set to plugin name to only run plugin-specific rspec tests (you'll probably want to SKIP_CORE as well)
    @@ -211,6 +212,11 @@ task "docker:test" do
               else
                 @good &&= run_or_fail("bundle exec rspec #{params.join(" ")}".strip)
               end
    +
    +          if ENV["RUN_SYSTEM_TESTS"]
    +            @good &&= run_or_fail("bin/ember-cli --build")
    +            @good &&= run_or_fail("timeout --verbose 1800 bundle exec rspec spec/system")
    +          end
             end
     
             unless ENV["SKIP_PLUGINS"]
    @@ -220,6 +226,13 @@ task "docker:test" do
                 fail_fast = "RSPEC_FAILFAST=1" unless ENV["SKIP_FAILFAST"]
                 @good &&= run_or_fail("#{fail_fast} bundle exec rake plugin:spec")
               end
    +
    +          if ENV["RUN_SYSTEM_TESTS"]
    +            @good &&=
    +              run_or_fail(
    +                "LOAD_PLUGINS=1 timeout --verbose 1600 bundle exec rspec plugins/*/spec/system".strip,
    +              )
    +          end
             end
           end
     
    diff --git a/lib/tasks/documentation.rake b/lib/tasks/documentation.rake
    new file mode 100644
    index 00000000000..d1e4c767db1
    --- /dev/null
    +++ b/lib/tasks/documentation.rake
    @@ -0,0 +1,43 @@
    +# frozen_string_literal: true
    +
    +require "fileutils"
    +
    +task "documentation" do
    +  generate_chat_documentation
    +end
    +
    +def generate_chat_documentation
    +  destination = File.join(Rails.root, "documentation/chat/frontend/")
    +  config = File.join(Rails.root, ".jsdoc")
    +  files = %w[
    +    plugins/chat/assets/javascripts/discourse/lib/collection.js
    +    plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js
    +    plugins/chat/assets/javascripts/discourse/services/chat-api.js
    +  ]
    +  `yarn --silent jsdoc --readme plugins/chat/README.md -c #{config} #{files.join(" ")} -d #{destination}`
    +
    +  # unnecessary files
    +  %w[
    +    documentation/chat/frontend/scripts/prism.min.js
    +    documentation/chat/frontend/scripts/prism.js
    +    documentation/chat/frontend/styles/vendor/prism-default.css
    +    documentation/chat/frontend/styles/vendor/prism-okaidia.css
    +    documentation/chat/frontend/styles/vendor/prism-tomorrow-night.css
    +  ].each { |file| FileUtils.rm(file) }
    +
    +  require "open3"
    +  require "yard"
    +  YARD::Templates::Engine.register_template_path(
    +    File.join(Rails.root, "documentation", "yard-custom-template"),
    +  )
    +  files = %w[
    +    plugins/chat/app/services/base.rb
    +    plugins/chat/app/services/update_user_last_read.rb
    +    plugins/chat/app/services/trash_channel.rb
    +    plugins/chat/app/services/update_channel.rb
    +    plugins/chat/app/services/update_channel_status.rb
    +  ]
    +  cmd =
    +    "bundle exec yardoc -p documentation/yard-custom-template -t default -r plugins/chat/README.md --output-dir documentation/chat/backend #{files.join(" ")}"
    +  Open3.popen3(cmd) { |_, stderr| puts stderr.read }
    +end
    diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake
    index c9042d8dd38..bfa4313071e 100644
    --- a/lib/tasks/emails.rake
    +++ b/lib/tasks/emails.rake
    @@ -207,3 +207,56 @@ task "emails:test", [:email] => [:environment] do |_, args|
           Consider changing it to 'no' before performing any further troubleshooting.
         TEXT
     end
    +
    +desc "run this to fix users associated to emails mirrored from a mailman mailing list"
    +task "emails:fix_mailman_users" => :environment do
    +  if !SiteSetting.enable_staged_users
    +    puts "Please enable staged users first"
    +    exit 1
    +  end
    +
    +  def find_or_create_user(email, name)
    +    user = nil
    +
    +    User.transaction do
    +      unless user = User.find_by_email(email)
    +        username = UserNameSuggester.sanitize_username(name) if name.present?
    +        username = UserNameSuggester.suggest(username.presence || email)
    +        name = name.presence || User.suggest_name(email)
    +
    +        begin
    +          user = User.create!(email: email, username: username, name: name, staged: true)
    +        rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
    +        end
    +      end
    +    end
    +
    +    user
    +  end
    +
    +  IncomingEmail
    +    .includes(:user, :post)
    +    .where("raw LIKE '%X-Mailman-Version: %'")
    +    .find_each do |ie|
    +      next unless ie.post.present?
    +
    +      mail = Mail.new(ie.raw)
    +      email, name = Email::Receiver.extract_email_address_and_name_from_mailman(mail)
    +
    +      if email.blank? || email == ie.user.email
    +        putc "."
    +      elsif new_owner = find_or_create_user(email, name)
    +        PostOwnerChanger.new(
    +          post_ids: [ie.post_id],
    +          topic_id: ie.post.topic_id,
    +          new_owner: new_owner,
    +          acting_user: Discourse.system_user,
    +          skip_revision: true,
    +        ).change_owner!
    +        putc "#"
    +      else
    +        putc "X"
    +      end
    +    end
    +  nil
    +end
    diff --git a/lib/tasks/emoji.rake b/lib/tasks/emoji.rake
    index fe049fc550d..4a123d18464 100644
    --- a/lib/tasks/emoji.rake
    +++ b/lib/tasks/emoji.rake
    @@ -277,6 +277,8 @@ end
     
     desc "update emoji images"
     task "emoji:update" do
    +  abort("This task can't be run on production.") if Rails.env.production?
    +
       copy_emoji_db
     
       json_db = File.read(File.join(GENERATED_PATH, "db.json"))
    @@ -459,7 +461,7 @@ end
     def codepoints_to_code(codepoints, fitzpatrick_scale)
       codepoints = codepoints.map { |c| c.to_s(16).rjust(4, "0") }.join("_").downcase
     
    -  codepoints.gsub!(/_fe0f$/, "") if !fitzpatrick_scale
    +  codepoints.gsub!(/_fe0f\z/, "") if !fitzpatrick_scale
     
       codepoints
     end
    @@ -473,7 +475,7 @@ def confirm_overwrite(path)
       STDIN.gets.chomp
     end
     
    -class TestEmojiUpdate < MiniTest::Test
    +class TestEmojiUpdate < Minitest::Test
       def self.run_and_summarize
         puts "Runnings tests..."
         reporter = Minitest::SummaryReporter.new
    diff --git a/lib/tasks/hashtags.rake b/lib/tasks/hashtags.rake
    new file mode 100644
    index 00000000000..f2c8a2ddd7b
    --- /dev/null
    +++ b/lib/tasks/hashtags.rake
    @@ -0,0 +1,15 @@
    +# frozen_string_literal: true
    +
    +desc "Mark posts with the old hashtag cooked format (pre enable_experimental_hashtag_autocomplete) for rebake"
    +task "hashtags:mark_old_format_for_rebake" => :environment do
    +  # See Post#rebake_old, which is called via the PeriodicalUpdates job
    +  # on a schedule.
    +  puts "Finding posts matching old format, this could take some time..."
    +  posts_to_rebake = Post.where("cooked like '%class=\"hashtag\"%'")
    +  puts(
    +    "[!] You are about to mark #{posts_to_rebake.count} posts containing hashtags in the old format to rebake. [CTRL+c] to cancel, [ENTER] to continue",
    +  )
    +  STDIN.gets.chomp if !Rails.env.test?
    +  posts_to_rebake.update_all(baked_version: 0)
    +  puts "Done, rebakes will happen when periodical updates job runs."
    +end
    diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
    index ed6bdc25ce6..84f4d011529 100644
    --- a/lib/tasks/import.rake
    +++ b/lib/tasks/import.rake
    @@ -22,6 +22,7 @@ task "import:ensure_consistency" => :environment do
       update_users
       update_groups
       update_tag_stats
    +  update_topic_users
       create_category_definitions
     
       log "Done!"
    @@ -29,6 +30,10 @@ end
     
     MS_SPEND_CREATING_POST ||= 5000
     
    +# -- TODO: We need to check the queries are actually adding/updating the necessary
    +# data, post migration. The ON CONFLICT DO NOTHING may cause the clauses to be ignored
    +# when we actually need them to run.
    +
     def insert_post_timings
       log "Inserting post timings..."
     
    @@ -161,7 +166,9 @@ def insert_user_options
                       notification_level_when_replying,
                       like_notification_frequency,
                       skip_new_user_tips,
    -                  hide_profile_and_presence
    +                  hide_profile_and_presence,
    +                  sidebar_link_to_filtered_list,
    +                  sidebar_show_count_of_new_items
                     )
                  SELECT u.id
                       , #{SiteSetting.default_email_mailing_list_mode}
    @@ -183,6 +190,8 @@ def insert_user_options
                       , #{SiteSetting.default_other_like_notification_frequency}
                       , #{SiteSetting.default_other_skip_new_user_tips}
                       , #{SiteSetting.default_hide_profile_and_presence}
    +                  , #{SiteSetting.default_sidebar_link_to_filtered_list}
    +                  , #{SiteSetting.default_sidebar_show_count_of_new_items}
                    FROM users u
               LEFT JOIN user_options uo ON uo.user_id = u.id
                   WHERE uo.user_id IS NULL
    @@ -415,6 +424,29 @@ def update_tag_stats
       Tag.ensure_consistency!
     end
     
    +def update_topic_users
    +  log "Updating topic users..."
    +
    +  DB.exec <<-SQL
    +    WITH X AS (
    +        SELECT p.topic_id
    +             , p.user_id
    +          FROM posts p
    +          JOIN topics t ON t.id = p.topic_id
    +         WHERE p.deleted_at IS NULL
    +           AND t.deleted_at IS NULL
    +           AND NOT p.hidden
    +           AND t.visible
    +    )
    +    UPDATE topic_users tu
    +       SET posted = 't'
    +      FROM X
    +     WHERE tu.topic_id = X.topic_id
    +       AND tu.user_id = X.user_id
    +       AND posted = 'f'
    +  SQL
    +end
    +
     def create_category_definitions
       log "Creating category definitions"
       Category.ensure_consistency!
    diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake
    index 2f701589f73..46fbe2d7e92 100644
    --- a/lib/tasks/javascript.rake
    +++ b/lib/tasks/javascript.rake
    @@ -87,32 +87,6 @@ def dependencies
           source: "@discourse/moment-timezone-names-translations/locales/.",
           destination: "moment-timezone-names-locale",
         },
    -    { source: "workbox-sw/build/.", destination: "workbox", public: true, skip_versioning: true },
    -    {
    -      source: "workbox-routing/build/.",
    -      destination: "workbox",
    -      public: true,
    -      skip_versioning: true,
    -    },
    -    { source: "workbox-core/build/.", destination: "workbox", public: true, skip_versioning: true },
    -    {
    -      source: "workbox-strategies/build/.",
    -      destination: "workbox",
    -      public: true,
    -      skip_versioning: true,
    -    },
    -    {
    -      source: "workbox-expiration/build/.",
    -      destination: "workbox",
    -      public: true,
    -      skip_versioning: true,
    -    },
    -    {
    -      source: "workbox-cacheable-response/build/.",
    -      destination: "workbox",
    -      skip_versioning: true,
    -      public: true,
    -    },
         {
           source: "squoosh/codecs/mozjpeg/enc/mozjpeg_enc.js",
           destination: "squoosh",
    @@ -163,6 +137,16 @@ task "javascript:update_constants" => :environment do
         export const SEARCH_PRIORITIES = #{Searchable::PRIORITIES.to_json};
     
         export const SEARCH_PHRASE_REGEXP = '#{Search::PHRASE_MATCH_REGEXP_PATTERN}';
    +
    +    export const SIDEBAR_URL = {
    +      max_icon_length: #{SidebarUrl::MAX_ICON_LENGTH},
    +      max_name_length: #{SidebarUrl::MAX_NAME_LENGTH},
    +      max_value_length: #{SidebarUrl::MAX_VALUE_LENGTH}
    +    }
    +
    +    export const SIDEBAR_SECTION = {
    +      max_title_length: #{SidebarSection::MAX_TITLE_LENGTH},
    +    }
       JS
     
       pretty_notifications = Notification.types.map { |n| "  #{n[0]}: #{n[1]}," }.join("\n")
    @@ -182,6 +166,32 @@ task "javascript:update_constants" => :environment do
         export const replacements = #{Emoji.unicode_replacements_json};
       JS
     
    +  langs = []
    +  Dir
    +    .glob("vendor/assets/javascripts/highlightjs/languages/*.min.js")
    +    .each { |f| langs << File.basename(f, ".min.js") }
    +  bundle = HighlightJs.bundle(langs)
    +
    +  ctx = MiniRacer::Context.new
    +  hljs_aliases = ctx.eval(<<~JS)
    +    #{bundle}
    +
    +    let aliases = {};
    +    hljs.listLanguages().forEach((lang) => {
    +      if (hljs.getLanguage(lang).aliases) {
    +        aliases[lang] = hljs.getLanguage(lang).aliases;
    +      }
    +    });
    +
    +    aliases;
    +  JS
    +
    +  write_template("pretty-text/addon/highlightjs-aliases.js", task_name, <<~JS)
    +    export const HLJS_ALIASES = #{hljs_aliases.to_json};
    +  JS
    +
    +  ctx.dispose
    +
       write_template("pretty-text/addon/emoji/version.js", task_name, <<~JS)
         export const IMAGE_VERSION = "#{Emoji::EMOJI_VERSION}";
       JS
    @@ -196,7 +206,7 @@ task "javascript:update_constants" => :environment do
     
       emoji_sections = groups_json.map { |group| html_for_section(group) }
     
    -  components_dir = "discourse/app/templates/components"
    +  components_dir = "discourse/app/components"
       write_hbs_template("#{components_dir}/emoji-group-buttons.hbs", task_name, emoji_buttons.join)
       write_hbs_template("#{components_dir}/emoji-group-sections.hbs", task_name, emoji_sections.join)
     end
    @@ -213,10 +223,10 @@ task "javascript:update" => "clean_up" do
       dependencies.each do |f|
         src = "#{library_src}/#{f[:source]}"
     
    -    unless f[:destination]
    -      filename = f[:source].split("/").last
    -    else
    +    if f[:destination]
           filename = f[:destination]
    +    else
    +      filename = f[:source].split("/").last
         end
     
         if src.include? "highlightjs"
    @@ -257,6 +267,7 @@ task "javascript:update" => "clean_up" do
             mode-html
             mode-scss
             mode-sql
    +        mode-yaml
             theme-chrome
             theme-chaos
             worker-html
    @@ -267,11 +278,7 @@ task "javascript:update" => "clean_up" do
     
         STDERR.puts "New dependency added: #{dest}" unless File.exist?(dest)
     
    -    if f[:uglify]
    -      File.write(dest, Uglifier.new.compile(File.read(src)))
    -    else
    -      FileUtils.cp_r(src, dest)
    -    end
    +    FileUtils.cp_r(src, dest)
       end
     
       write_template("discourse/app/lib/public-js-versions.js", "update", <<~JS)
    diff --git a/lib/tasks/maxminddb.rake b/lib/tasks/maxminddb.rake
    index ffb6a06514d..eb66f64a495 100644
    --- a/lib/tasks/maxminddb.rake
    +++ b/lib/tasks/maxminddb.rake
    @@ -1,9 +1,7 @@
     # frozen_string_literal: true
     
     desc "downloads MaxMind's GeoLite2-City database"
    -task "maxminddb:get" do
    -  require "discourse_ip_info"
    -
    +task "maxminddb:get" => "environment" do
       puts "Downloading MaxMindDb's GeoLite2-City..."
       DiscourseIpInfo.mmdb_download("GeoLite2-City")
     
    diff --git a/lib/tasks/plugin.rake b/lib/tasks/plugin.rake
    index 32255f2f301..9711141902a 100644
    --- a/lib/tasks/plugin.rake
    +++ b/lib/tasks/plugin.rake
    @@ -4,7 +4,7 @@ directory "plugins"
     
     desc "install all official plugins (use GIT_WRITE=1 to pull with write access)"
     task "plugin:install_all_official" do
    -  skip = Set.new(%w[customer-flair lazy-yt poll])
    +  skip = Set.new(%w[customer-flair poll])
     
       map = { "Canned Replies" => "https://github.com/discourse/discourse-canned-replies" }
     
    @@ -86,7 +86,7 @@ task "plugin:update", :plugin do |t, args|
     
       upstream_branch =
         `git -C '#{plugin_path}' for-each-ref --format='%(upstream:short)' $(git -C '#{plugin_path}' symbolic-ref -q HEAD)`.strip
    -  has_origin_main = `git -C '#{plugin_path}' branch -a`.match?(%r{remotes/origin/main$})
    +  has_origin_main = `git -C '#{plugin_path}' branch -a`.match?(%r{remotes/origin/main\z})
       has_local_main = `git -C '#{plugin_path}' show-ref refs/heads/main`.present?
     
       if upstream_branch == "origin/master" && has_origin_main
    @@ -168,34 +168,38 @@ task "plugin:install_gems", :plugin do |t, args|
       puts "Done"
     end
     
    -def spec(plugin, parallel: false)
    +def spec(plugin, parallel: false, argv: nil)
       params = []
       params << "--profile" if !parallel
       params << "--fail-fast" if ENV["RSPEC_FAILFAST"]
       params << "--seed #{ENV["RSPEC_SEED"]}" if Integer(ENV["RSPEC_SEED"], exception: false)
    +  params << argv if argv
     
    -  ruby = `which ruby`.strip
       # 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(" ")}"
    +
    +    Rake::FileUtilsExt.verbose(!parallel) do
    +      sh("LOAD_PLUGINS=1 #{cmd} #{files.join(" ")} #{params.join(" ")}")
    +    end
       else
         abort "No specs found."
       end
     end
     
     desc "run plugin specs"
    -task "plugin:spec", :plugin do |t, args|
    +task "plugin:spec", %i[plugin argv] do |_, args|
       args.with_defaults(plugin: "*")
    -  spec(args[:plugin])
    +  spec(args[:plugin], argv: args[:argv])
     end
     
     desc "run plugin specs in parallel"
    -task "plugin:turbo_spec", :plugin do |t, args|
    +task "plugin:turbo_spec", %i[plugin argv] do |_, args|
       args.with_defaults(plugin: "*")
    -  spec(args[:plugin], parallel: true)
    +  spec(args[:plugin], parallel: true, argv: args[:argv])
     end
     
     desc "run plugin qunit tests"
    @@ -205,14 +209,17 @@ task "plugin:qunit", %i[plugin timeout] do |t, args|
       rake = "#{Rails.root}/bin/rake"
     
       cmd = "LOAD_PLUGINS=1 "
    -  cmd += "QUNIT_SKIP_CORE=1 "
     
    -  if args[:plugin] == "*"
    -    puts "Running qunit tests for all plugins"
    -  else
    -    puts "Running qunit tests for #{args[:plugin]}"
    -    cmd += "QUNIT_SINGLE_PLUGIN='#{args[:plugin]}' "
    -  end
    +  target =
    +    if args[:plugin] == "*"
    +      puts "Running qunit tests for all plugins"
    +      "plugins"
    +    else
    +      puts "Running qunit tests for #{args[:plugin]}"
    +      args[:plugin]
    +    end
    +
    +  cmd += "TARGET='#{target}' "
     
       cmd += "#{rake} qunit:test"
       cmd += "[#{args[:timeout]}]" if args[:timeout]
    diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake
    index 15a018db3f5..a1191c427c6 100644
    --- a/lib/tasks/populate.rake
    +++ b/lib/tasks/populate.rake
    @@ -54,3 +54,8 @@ desc "Add replies to a topic"
     task "replies:populate", %i[topic_id count] => ["db:load_config"] do |_, args|
       DiscourseDev::Post.add_replies!(args)
     end
    +
    +desc "Creates sample email logs"
    +task "email_logs:populate" => ["db:load_config"] do |_, args|
    +  DiscourseDev::EmailLog.populate!
    +end
    diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake
    index 9fd1f480f3c..16f2e84f16a 100644
    --- a/lib/tasks/posts.rake
    +++ b/lib/tasks/posts.rake
    @@ -543,7 +543,7 @@ def recover_uploads_from_index(path)
         .pluck(:post_id, :value)
         .each do |post_id, uploads|
           uploads = JSON.parse(uploads)
    -      raw = Post.where(id: post_id).pluck_first(:raw)
    +      raw = Post.where(id: post_id).pick(:raw)
           uploads.map! do |upload|
             orig = upload
             if raw.scan(upload).length == 0
    diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake
    index 16cc88ad44c..e03f103e057 100644
    --- a/lib/tasks/qunit.rake
    +++ b/lib/tasks/qunit.rake
    @@ -69,6 +69,7 @@ task "qunit:test", %i[timeout qunit_path filter] do |_, args|
           theme_name
           theme_url
           theme_id
    +      target
         ].each { |arg| options[arg] = ENV[arg.upcase] if ENV[arg.upcase].present? }
     
         options["report_requests"] = "1" if report_requests
    @@ -90,7 +91,7 @@ task "qunit:test", %i[timeout qunit_path filter] do |_, args|
           Net::HTTP.get(uri)
         rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Net::ReadTimeout, EOFError
           sleep 1
    -      retry unless elapsed() > 60
    +      retry if elapsed() <= 60
           puts "Timed out. Can not connect to forked server!"
           exit 1
         end
    diff --git a/lib/tasks/release_note.rake b/lib/tasks/release_note.rake
    index 8f665d6eba8..9f96eb4c4b3 100644
    --- a/lib/tasks/release_note.rake
    +++ b/lib/tasks/release_note.rake
    @@ -3,12 +3,12 @@
     DATE_REGEX ||= /\A\d{4}-\d{2}-\d{2}/
     
     CHANGE_TYPES ||= [
    -  { pattern: /^FEATURE:/, heading: "New Features" },
    -  { pattern: /^FIX:/, heading: "Bug Fixes" },
    -  { pattern: /^UX:/, heading: "UX Changes" },
    -  { pattern: /^SECURITY:/, heading: "Security Changes" },
    -  { pattern: /^PERF:/, heading: "Performance" },
    -  { pattern: /^A11Y:/, heading: "Accessibility" },
    +  { pattern: /\AFEATURE:/, heading: "New Features" },
    +  { pattern: /\AFIX:/, heading: "Bug Fixes" },
    +  { pattern: /\AUX:/, heading: "UX Changes" },
    +  { pattern: /\ASECURITY:/, heading: "Security Changes" },
    +  { pattern: /\APERF:/, heading: "Performance" },
    +  { pattern: /\AA11Y:/, heading: "Accessibility" },
     ]
     
     desc "generate a release note from the important commits"
    @@ -54,7 +54,7 @@ task "release_note:plugins:generate", :from, :to, :plugin_glob, :org do |t, args
         end
     
         puts "### #{name}\n\n"
    -    CHANGE_TYPES.each { |ct| print_changes_plugin(ct[:heading], changes[ct]) }
    +    CHANGE_TYPES.each { |ct| print_changes(ct[:heading], changes[ct], "####") }
       end
     
       puts "(No changes found in #{no_changes_repos.join(", ")})"
    @@ -83,7 +83,7 @@ def find_changes(repo, from, to)
       CHANGE_TYPES.each { |ct| changes[ct] = Set.new }
     
       out.each_line do |comment|
    -    next if comment =~ /^\s*Revert/
    +    next if comment =~ /\A\s*Revert/
         split_comments(comment).each do |line|
           ct = CHANGE_TYPES.find { |t| line =~ t[:pattern] }
           changes[ct] << better(line) if ct
    @@ -100,14 +100,6 @@ def print_changes(heading, changes, importance)
       puts changes.to_a, ""
     end
     
    -def print_changes_plugin(heading, changes)
    -  return if changes.length == 0
    -
    -  puts "[details=\"#{heading}\"]\n", ""
    -  puts changes.to_a, ""
    -  puts "[/details]\n", ""
    -end
    -
     def better(line)
       line = remove_prefix(line)
       line = escape_brackets(line)
    @@ -122,7 +114,7 @@ def better(line)
     end
     
     def remove_prefix(line)
    -  line.gsub(/^(FIX|FEATURE|UX|SECURITY|PERF|A11Y):/, "").strip
    +  line.gsub(/\A(FIX|FEATURE|UX|SECURITY|PERF|A11Y):/, "").strip
     end
     
     def escape_brackets(line)
    @@ -130,7 +122,7 @@ def escape_brackets(line)
     end
     
     def remove_pull_request(line)
    -  line.gsub(/ \(\#\d+\)$/, "")
    +  line.gsub(/ \(\#\d+\)\z/, "")
     end
     
     def split_comments(text)
    diff --git a/lib/tasks/s3.rake b/lib/tasks/s3.rake
    index 56e5859dafe..7f5a7a4cad2 100644
    --- a/lib/tasks/s3.rake
    +++ b/lib/tasks/s3.rake
    @@ -28,7 +28,7 @@ def upload(path, remote_path, content_type, content_encoding = nil)
       options = {
         cache_control: "max-age=31556952, public, immutable",
         content_type: content_type,
    -    acl: "public-read",
    +    acl: SiteSetting.s3_use_acls ? "public-read" : nil,
       }
     
       options[:content_encoding] = content_encoding if content_encoding
    @@ -104,6 +104,11 @@ end
     task "s3:correct_acl" => :environment do
       ensure_s3_configured!
     
    +  if !SiteSetting.s3_use_acls
    +    $stderr.puts "Not correcting ACLs as the site is configured to not use ACLs"
    +    return
    +  end
    +
       puts "ensuring public-read is set on every upload and optimized image"
     
       i = 0
    @@ -158,7 +163,7 @@ task "s3:correct_cachecontrol" => :environment do
             object = Discourse.store.s3_helper.object(key)
             object.copy_from(
               copy_source: "#{object.bucket_name}/#{object.key}",
    -          acl: "public-read",
    +          acl: SiteSetting.s3_use_acls ? "public-read" : nil,
               cache_control: cache_control,
               content_type: object.content_type,
               content_disposition: object.content_disposition,
    diff --git a/lib/tasks/svg_icons.rake b/lib/tasks/svg_icons.rake
    index 5549b4379e2..7f443447091 100644
    --- a/lib/tasks/svg_icons.rake
    +++ b/lib/tasks/svg_icons.rake
    @@ -19,10 +19,10 @@ task "svgicons:update" do
       dependencies.each do |f|
         src = "#{library_src}/#{f[:source]}/."
     
    -    unless f[:destination]
    -      filename = f[:source].split("/").last
    -    else
    +    if f[:destination]
           filename = f[:destination]
    +    else
    +      filename = f[:source].split("/").last
         end
     
         dest = "#{vendor_svgs}/#{filename}"
    diff --git a/lib/tasks/svg_sprites.rake b/lib/tasks/svg_sprites.rake
    new file mode 100644
    index 00000000000..09f3d7364ed
    --- /dev/null
    +++ b/lib/tasks/svg_sprites.rake
    @@ -0,0 +1,5 @@
    +# frozen_string_literal: true
    +
    +task "svg_sprites:refetch" => [:environment] do |_, args|
    +  ThemeSvgSprite.refetch!
    +end
    diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake
    index 3e27bd3387b..351ba4b5550 100644
    --- a/lib/tasks/themes.rake
    +++ b/lib/tasks/themes.rake
    @@ -62,7 +62,7 @@ def update_themes
         .includes(:remote_theme)
         .where(enabled: true, auto_update: true)
         .find_each do |theme|
    -      begin
    +      theme.transaction do
             remote_theme = theme.remote_theme
             next if remote_theme.blank? || remote_theme.remote_url.blank?
     
    diff --git a/lib/tasks/turbo.rake b/lib/tasks/turbo.rake
    index 06d6e14b704..8440252d864 100644
    --- a/lib/tasks/turbo.rake
    +++ b/lib/tasks/turbo.rake
    @@ -3,5 +3,9 @@
     task "turbo:spec" => :test do |t|
       require "./lib/turbo_tests"
     
    -  TurboTests::Runner.run(formatters: [{ name: "progress", outputs: ["-"] }], files: ["spec"])
    +  TurboTests::Runner.run(
    +    formatters: [{ name: "progress", outputs: ["-"] }],
    +    files: TurboTests::Runner.default_spec_folders,
    +    use_runtime_info: true,
    +  )
     end
    diff --git a/lib/tasks/typepad.thor b/lib/tasks/typepad.thor
    index 649f12db523..bbef98037e6 100644
    --- a/lib/tasks/typepad.thor
    +++ b/lib/tasks/typepad.thor
    @@ -34,7 +34,7 @@ class Typepad < Thor
         File.open(options[:file]).each_line do |l|
           l = l.scrub
     
    -      if l =~ /^--------$/
    +      if l =~ /\A--------\z/
             parsed_entry = process_entry(input)
             if parsed_entry
               puts "Parsed #{parsed_entry[:title]}"
    @@ -119,7 +119,7 @@ class Typepad < Thor
       def parse_meta_data(section)
         result = {}
         section.split(/\n/).each do |l|
    -      if l =~ /^([A-Z\ ]+)\: (.*)$/
    +      if l =~ /\A([A-Z\ ]+)\: (.*)\z/
             key, value = Regexp.last_match[1], Regexp.last_match[2]
             clean_type!(key)
             value.strip!
    @@ -134,7 +134,7 @@ class Typepad < Thor
     
       def parse_section(section)
         section.strip!
    -    if section =~ /^([^:]+):/
    +    if section =~ /\A([^:]+):/
           type = clean_type!(Regexp.last_match[1])
           value = section.split("\n")[1..-1].join("\n")
           value.strip!
    @@ -195,8 +195,8 @@ class Typepad < Thor
     
             comment[:name] = comment[:author]
             if comment[:author]
    -          comment[:author].gsub!(/^[_\.]+/, '')
    -          comment[:author].gsub!(/[_\.]+$/, '')
    +          comment[:author].gsub!(/\A[_\.]+/, '')
    +          comment[:author].gsub!(/[_\.]+\z/, '')
     
               if comment[:author].size < 12
                 comment[:author].gsub!(/ /, '_')
    diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake
    index 4c0dc03de3b..3b2328b8281 100644
    --- a/lib/tasks/uploads.rake
    +++ b/lib/tasks/uploads.rake
    @@ -35,7 +35,7 @@ def gather_uploads
         .where("url !~ ?", "^\/uploads\/#{current_db}")
         .find_each do |upload|
           begin
    -        old_db = upload.url[%r{^/uploads/([^/]+)/}, 1]
    +        old_db = upload.url[%r{\A/uploads/([^/]+)/}, 1]
             from = upload.url.dup
             to = upload.url.sub("/uploads/#{old_db}/", "/uploads/#{current_db}/")
             source = "#{public_directory}#{from}"
    @@ -119,7 +119,6 @@ def create_migration
         s3_options: FileStore::ToS3Migration.s3_options_from_env,
         dry_run: !!ENV["DRY_RUN"],
         migrate_to_multisite: !!ENV["MIGRATE_TO_MULTISITE"],
    -    skip_etag_verify: !!ENV["SKIP_ETAG_VERIFY"],
       )
     end
     
    @@ -321,8 +320,8 @@ def regenerate_missing_optimized
         scope.find_each do |optimized_image|
           upload = optimized_image.upload
     
    -      next unless optimized_image.url =~ %r{^/[^/]}
    -      next unless upload.url =~ %r{^/[^/]}
    +      next unless optimized_image.url =~ %r{\A/[^/]}
    +      next unless upload.url =~ %r{\A/[^/]}
     
           thumbnail = "#{public_directory}#{optimized_image.url}"
           original = "#{public_directory}#{upload.url}"
    @@ -385,7 +384,7 @@ end
     
     task "uploads:stop_migration" => :environment do
       SiteSetting.migrate_to_new_scheme = false
    -  puts "Migration stoped!"
    +  puts "Migration stopped!"
     end
     
     task "uploads:analyze", %i[cache_path limit] => :environment do |_, args|
    diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake
    index a5c9c8103c4..dbbcad0d245 100644
    --- a/lib/tasks/users.rake
    +++ b/lib/tasks/users.rake
    @@ -155,6 +155,7 @@ task "users:disable_2fa", [:username] => [:environment] do |_, args|
       username = args[:username]
       user = find_user(username)
       UserSecondFactor.where(user_id: user.id, method: UserSecondFactor.methods[:totp]).each(&:destroy!)
    +  UserSecurityKey.where(user_id: user.id).destroy_all
       puts "2FA disabled for #{username}"
     end
     
    diff --git a/lib/tasks/version_bump.rake b/lib/tasks/version_bump.rake
    new file mode 100644
    index 00000000000..76cc7d1db8c
    --- /dev/null
    +++ b/lib/tasks/version_bump.rake
    @@ -0,0 +1,425 @@
    +# frozen_string_literal: true
    +
    +def dry_run?
    +  !!ENV["DRY_RUN"]
    +end
    +
    +def test_mode?
    +  ENV["RUNNING_VERSION_BUMP_IN_RSPEC_TESTS"] == "1"
    +end
    +
    +class PlannedTag
    +  attr_reader :name, :message
    +
    +  def initialize(name:, message:)
    +    @name = name
    +    @message = message
    +  end
    +end
    +
    +class PlannedCommit
    +  attr_reader :version, :tags, :ref
    +
    +  def initialize(version:, tags: [])
    +    @version = version
    +    @tags = tags
    +  end
    +
    +  def perform!
    +    write_version(@version)
    +    git "add", "lib/version.rb"
    +    git "commit", "-m", "Bump version to v#{@version}"
    +    @ref = git("rev-parse", "HEAD").strip
    +  end
    +end
    +
    +def read_version_rb
    +  File.read("lib/version.rb")
    +end
    +
    +def parse_current_version
    +  version = read_version_rb[/STRING = "(.*)"/, 1]
    +  raise "Unable to parse current version" if version.nil?
    +  puts "Parsed current version: #{version.inspect}"
    +  version
    +end
    +
    +def write_version(version)
    +  File.write("lib/version.rb", read_version_rb.sub(/STRING = ".*"/, "STRING = \"#{version}\""))
    +end
    +
    +def git(*args, allow_failure: false, silent: false)
    +  puts "> git #{args.inspect}" unless silent
    +  stdout, stderr, status = Open3.capture3({ "LEFTHOOK" => "0" }, "git", *args)
    +  if !status.success? && !allow_failure
    +    raise "Command failed: git #{args.inspect}\n#{stdout.indent(2)}\n#{stderr.indent(2)}"
    +  end
    +  stdout
    +end
    +
    +def ref_exists?(ref)
    +  git "rev-parse", "--verify", ref
    +  true
    +rescue StandardError
    +  false
    +end
    +
    +def confirm(msg)
    +  loop do
    +    print "#{msg} (yes/no)..."
    +    break if test_mode?
    +
    +    response = $stdin.gets.strip
    +
    +    case response.downcase
    +    when "no"
    +      raise "Aborted"
    +    when "yes"
    +      break
    +    else
    +      puts "unknown response: #{response}"
    +    end
    +  end
    +end
    +
    +def make_commits(commits:, branch:, base:)
    +  raise "You have other staged changes. Aborting." if !git("diff", "--cached").empty?
    +
    +  git "branch", "-D", branch if ref_exists?(branch)
    +  git "checkout", "-b", branch
    +
    +  commits.each(&:perform!)
    +
    +  git("push", "-f", "--set-upstream", "origin", branch)
    +
    +  make_pr(
    +    base: base,
    +    branch: branch,
    +    title:
    +      "Version bump#{"s" if commits.length > 1} for #{base}: #{commits.map { |c| "v#{c.version}" }.join(", ")}",
    +  )
    +end
    +
    +def make_pr(base:, branch:, title:)
    +  params = { expand: 1, title: title, body: <<~MD }
    +      > :warning: This PR should not be merged via the GitHub web interface
    +      > 
    +      > It should only be merged (via fast-forward) using the associated `bin/rake version_bump:*` task.
    +    MD
    +
    +  if !test_mode?
    +    system(
    +      "open",
    +      "https://github.com/discourse/discourse/compare/#{base}...#{branch}?#{params.to_query}",
    +      exception: true,
    +    )
    +  end
    +
    +  puts "Do not merge the PR via the GitHub web interface. Get it approved, then come back here to continue."
    +end
    +
    +def fastforward(base:, branch:)
    +  if dry_run?
    +    puts "[DRY RUN] Skipping fastforward of #{base}"
    +    return
    +  end
    +
    +  confirm "Ready to merge? This will fast-forward #{base} to #{branch}"
    +  begin
    +    git "push", "origin", "#{branch}:#{base}"
    +  rescue => e
    +    raise <<~MSG
    +      #{e.message}
    +      Error occured during fastforward. Maybe another commit was added to `#{base}` since the PR was created, or maybe the PR wasn't approved.
    +      Don't worry, this is not unusual. To update the branch and try again, rerun this script again. The existing PR and approval will be reused.
    +    MSG
    +  end
    +  puts "Merge successful"
    +end
    +
    +def stage_tags(commits)
    +  puts "Staging tags locally..."
    +  commits.each do |commit|
    +    commit.tags.each { |tag| git "tag", "-f", "-a", tag.name, "-m", tag.message, commit.ref }
    +  end
    +end
    +
    +def push_tags(commits)
    +  tag_names = commits.flat_map { |commit| commit.tags.map(&:name) }
    +
    +  if dry_run?
    +    puts "[DRY RUN] Skipping pushing tags to origin (#{tag_names.join(", ")})"
    +    return
    +  end
    +
    +  confirm "Ready to push tags #{tag_names.join(", ")} to origin?"
    +  tag_names.each { |tag_name| git "push", "-f", "origin", "refs/tags/#{tag_name}" }
    +end
    +
    +def with_clean_worktree(origin_branch)
    +  origin_url = git("remote", "get-url", "origin").strip
    +
    +  if !test_mode? && !origin_url.include?("discourse/discourse")
    +    raise "Expected 'origin' remote to point to discourse/discourse (got #{origin_url})"
    +  end
    +
    +  git "fetch", "origin", origin_branch
    +  path = "#{Rails.root}/tmp/version-bump-worktree-#{SecureRandom.hex}"
    +  begin
    +    FileUtils.mkdir_p(path)
    +    git "worktree", "add", path, "origin/#{origin_branch}"
    +    Dir.chdir(path) { yield } # rubocop:disable Discourse/NoChdir
    +  ensure
    +    puts "Cleaning up temporary worktree..."
    +    git "worktree", "remove", "--force", path, silent: true, allow_failure: true
    +    FileUtils.rm_rf(path)
    +  end
    +end
    +
    +desc "Stage commits for a beta version bump (e.g. beta1.dev -> beta1 -> beta2.dev). A PR will be created for approval, then the script will prompt to perform the release"
    +task "version_bump:beta" do
    +  branch = "version_bump/beta"
    +  base = "main"
    +
    +  with_clean_worktree(base) do
    +    current_version = parse_current_version
    +
    +    commits =
    +      if current_version.start_with?("3.1")
    +        # Legacy strategy - no `-dev` suffix
    +        next_version = current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" }
    +
    +        [
    +          PlannedCommit.new(
    +            version: next_version,
    +            tags: [
    +              PlannedTag.new(name: "beta", message: "latest beta release"),
    +              PlannedTag.new(name: "latest-release", message: "latest release"),
    +              PlannedTag.new(name: "v#{next_version}", message: "version #{next_version}"),
    +            ],
    +          ),
    +        ]
    +      else
    +        raise "Expected current version to end in -dev" if !current_version.end_with?("-dev")
    +
    +        beta_release_version = current_version.sub("-dev", "")
    +        next_dev_version = current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" }
    +
    +        [
    +          PlannedCommit.new(
    +            version: beta_release_version,
    +            tags: [
    +              PlannedTag.new(name: "beta", message: "latest beta release"),
    +              PlannedTag.new(name: "latest-release", message: "latest release"),
    +              PlannedTag.new(
    +                name: "v#{beta_release_version}",
    +                message: "version #{beta_release_version}",
    +              ),
    +            ],
    +          ),
    +          PlannedCommit.new(version: next_dev_version),
    +        ]
    +      end
    +
    +    make_commits(commits: commits, branch: branch, base: base)
    +    fastforward(base: base, branch: branch)
    +    stage_tags(commits)
    +    push_tags(commits)
    +  end
    +
    +  puts "Done!"
    +end
    +
    +desc "Stage commits for minor stable version bump (e.g. 3.1.1 -> 3.1.2). A PR will be created for approval, then the script will prompt to perform the release"
    +task "version_bump:minor_stable" do
    +  base = "stable"
    +  branch = "version_bump/stable"
    +
    +  with_clean_worktree(base) do
    +    current_version = parse_current_version
    +    if current_version !~ /^(\d+)\.(\d+)\.(\d+)$/
    +      raise "Expected current stable version to be in the form X.Y.Z. It was #{current_version}"
    +    end
    +
    +    new_version = current_version.sub(/\.(\d+)\z/) { ".#{$1.to_i + 1}" }
    +
    +    commits = [
    +      PlannedCommit.new(
    +        version: new_version,
    +        tags: [PlannedTag.new(name: "v#{new_version}", message: "version #{new_version}")],
    +      ),
    +    ]
    +
    +    make_commits(commits: commits, branch: branch, base: base)
    +    fastforward(base: base, branch: branch)
    +    stage_tags(commits)
    +    push_tags(commits)
    +  end
    +
    +  puts "Done!"
    +end
    +
    +desc "Stage commits for a major version bump (e.g. 3.1.0.beta6-dev -> 3.1.0.beta6 -> 3.1.0 -> 3.2.0.beta1-dev). A PR will be created for approval, then the script will merge to `main`. Should be passed a version number for the next stable version (e.g. 3.2.0)"
    +task "version_bump:major_stable_prepare", [:next_major_version_number] do |t, args|
    +  unless args[:next_major_version_number] =~ /\A\d+\.\d+\.\d+\z/
    +    raise "Expected next_major_version number to be in the form X.Y.Z"
    +  end
    +
    +  base = "main"
    +  branch = "version_bump/beta"
    +
    +  with_clean_worktree(base) do
    +    current_version = parse_current_version
    +
    +    # special case for moving away from the 'legacy' release system where we don't use the `-dev` suffix
    +    is_31_release = args[:next_major_version_number] == "3.2.0"
    +
    +    if !current_version.end_with?("-dev") && !is_31_release
    +      raise "Expected current version to end in -dev"
    +    end
    +
    +    beta_release_version =
    +      if is_31_release
    +        # The 3.1.0 beta series didn't use the -dev suffix
    +        current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" }
    +      else
    +        current_version.sub("-dev", "")
    +      end
    +
    +    next_dev_version = args[:next_major_version_number] + ".beta1-dev"
    +
    +    final_beta_release =
    +      PlannedCommit.new(
    +        version: beta_release_version,
    +        tags: [
    +          PlannedTag.new(name: "beta", message: "latest beta release"),
    +          PlannedTag.new(name: "latest-release", message: "latest release"),
    +          PlannedTag.new(
    +            name: "v#{beta_release_version}",
    +            message: "version #{beta_release_version}",
    +          ),
    +        ],
    +      )
    +
    +    commits = [final_beta_release, PlannedCommit.new(version: next_dev_version)]
    +
    +    make_commits(commits: commits, branch: branch, base: base)
    +    fastforward(base: base, branch: branch)
    +    stage_tags(commits)
    +    push_tags(commits)
    +
    +    puts <<~MSG
    +      The #{base} branch is now ready for a stable release.
    +      Now run this command to merge the release into the stable branch:
    +        bin/rake "version_bump:major_stable_merge[v#{beta_release_version}]"
    +    MSG
    +  end
    +end
    +
    +desc <<~DESC
    +  Stage the merge of a stable version bump into the stable branch.
    +  A PR will be created for approval, then the script will merge to `stable`.
    +  Should be passed the ref of the beta release which should be promoted to stable
    +  (output from the version_bump:major_stable_prepare rake task)
    +  e.g.:
    +    bin/rake "version_bump:major_stable_merge[v3.1.0.beta12]"
    +DESC
    +task "version_bump:major_stable_merge", [:version_bump_ref] do |t, args|
    +  merge_ref = args[:version_bump_ref]
    +  raise "Must pass version_bump_ref" unless merge_ref.present?
    +
    +  git "fetch", "origin", merge_ref
    +  raise "Unknown version_bump_ref: #{merge_ref.inspect}" unless ref_exists?(merge_ref)
    +
    +  base = "stable"
    +  branch = "version_bump/stable"
    +
    +  with_clean_worktree(base) do
    +    git "branch", "-D", branch if ref_exists?(branch)
    +    git "checkout", "-b", branch
    +
    +    git "merge", "--no-commit", merge_ref, allow_failure: true
    +
    +    out, status = Open3.capture2e "git diff --binary #{merge_ref} | patch -p1 -R"
    +    raise "Error applying diff\n#{out}}" unless status.success?
    +
    +    git "add", "."
    +
    +    merged_version = parse_current_version
    +    git "commit", "-m", "Merge v#{merged_version} into #{base}"
    +
    +    diff_to_base = git("diff", merge_ref).strip
    +    raise "There are diffs remaining to #{merge_ref}" unless diff_to_base.empty?
    +
    +    stable_release_version = merged_version.sub(/\.beta\d+\z/, "")
    +    stable_release_commit =
    +      PlannedCommit.new(
    +        version: stable_release_version,
    +        tags: [
    +          PlannedTag.new(
    +            name: "v#{stable_release_version}",
    +            message: "version #{stable_release_version}",
    +          ),
    +        ],
    +      )
    +    stable_release_commit.perform!
    +
    +    git("push", "-f", "--set-upstream", "origin", branch)
    +
    +    make_pr(base: base, branch: branch, title: "Merge v#{merged_version} into #{base}")
    +    fastforward(base: base, branch: branch)
    +    stage_tags([stable_release_commit])
    +    push_tags([stable_release_commit])
    +  end
    +end
    +
    +desc <<~DESC
    +  squash-merge many security fixes into a single branch for review/merge.
    +  Pass a list of comma-separated branches in the SECURITY_FIX_REFS env var, including the remote name.
    +  Pass the name of the destination branch as the argument of the rake task.
    +  e.g.
    +    SECURITY_FIX_REFS='privatemirror/mybranch1,privatemirror/mybranch2' bin/rake "version_bump:stage_security_fixes[main]"
    +DESC
    +task "version_bump:stage_security_fixes", [:base] do |t, args|
    +  base = args[:base]
    +  raise "Unknown base: #{base.inspect}" unless %w[stable main].include?(base)
    +
    +  fix_refs = ENV["SECURITY_FIX_REFS"]&.split(",").map(&:strip)
    +  raise "No branches specified in SECURITY_FIX_REFS env" if fix_refs.nil? || fix_refs.empty?
    +
    +  fix_refs.each do |ref|
    +    if !ref.include?("/")
    +      raise "Ref #{ref} did not specify an origin. Please specify the origin, e.g. privatemirror/mybranch"
    +    end
    +  end
    +
    +  puts "Staging security fixes for #{base} branch: #{fix_refs.inspect}"
    +
    +  branch = "security/#{base}-security-fixes"
    +
    +  with_clean_worktree(base) do
    +    git "branch", "-D", branch if ref_exists?(branch)
    +    git "checkout", "-b", branch
    +
    +    fix_refs.each do |ref|
    +      origin, origin_branch = ref.split("/", 2)
    +      git "fetch", origin, origin_branch
    +
    +      first_commit_on_branch = git("log", "--format=%H", "origin/#{base}..#{ref}").lines.last.strip
    +      author = git("log", "-n", "1", "--format=%an <%ae>", first_commit_on_branch).strip
    +      message = git("log", "-n", "1", "--format=%B", first_commit_on_branch).strip
    +
    +      git "merge", "--squash", ref
    +      git "commit", "--author", author, "-m", message
    +    end
    +
    +    puts "Finished merging commits into a locally-staged #{branch} branch. Git log is:"
    +    puts git("log", "origin/#{base}..#{branch}")
    +
    +    confirm "Check the log above. Ready to push this branch to the origin and create a PR?"
    +    git("push", "-f", "--set-upstream", "origin", branch)
    +
    +    make_pr(base: base, branch: branch, title: "Security fixes for #{base}")
    +    fastforward(base: base, branch: branch)
    +  end
    +end
    diff --git a/lib/theme_javascript_compiler.rb b/lib/theme_javascript_compiler.rb
    index 65d10d96fe6..c4d15e67558 100644
    --- a/lib/theme_javascript_compiler.rb
    +++ b/lib/theme_javascript_compiler.rb
    @@ -97,8 +97,14 @@ class ThemeJavascriptCompiler
         tree.transform_keys! do |filename|
           if filename.ends_with? ".js.es6"
             filename.sub(/\.js\.es6\z/, ".js")
    -      elsif filename.ends_with? ".raw.hbs"
    -        filename.sub(/\.raw\.hbs\z/, ".hbr")
    +      elsif filename.include? "/templates/"
    +        filename = filename.sub(/\.raw\.hbs\z/, ".hbr") if filename.ends_with? ".raw.hbs"
    +
    +        if filename.ends_with? ".hbr"
    +          filename.sub(%r{/templates/}, "/raw-templates/")
    +        else
    +          filename
    +        end
           else
             filename
           end
    @@ -168,7 +174,7 @@ class ThemeJavascriptCompiler
           elsif extension == "hbs"
             append_ember_template(module_name, content)
           elsif extension == "hbr"
    -        append_raw_template(module_name.sub("discourse/templates/", ""), content)
    +        append_raw_template(module_name.sub("discourse/raw-templates/", ""), content)
           else
             append_js_error(filename, "unknown file extension '#{extension}' (#{filename})")
           end
    @@ -200,7 +206,7 @@ class ThemeJavascriptCompiler
       end
     
       def raw_template_name(name)
    -    name = name.sub(/\.(raw|hbr)$/, "")
    +    name = name.sub(/\.(raw|hbr)\z/, "")
         name.inspect
       end
     
    @@ -228,7 +234,7 @@ class ThemeJavascriptCompiler
     
       def append_module(script, name, include_variables: true)
         original_filename = name
    -    name = "discourse/theme-#{@theme_id}/#{name.gsub(%r{^discourse/}, "")}"
    +    name = "discourse/theme-#{@theme_id}/#{name.gsub(%r{\Adiscourse/}, "")}"
     
         script = "#{theme_settings}#{script}" if include_variables
         transpiler = DiscourseJsProcessor::Transpiler.new
    diff --git a/lib/theme_settings_manager.rb b/lib/theme_settings_manager.rb
    index 1c353059f64..7a5f987ab8e 100644
    --- a/lib/theme_settings_manager.rb
    +++ b/lib/theme_settings_manager.rb
    @@ -184,7 +184,7 @@ class ThemeSettingsManager
     
       class Upload < self
         def value
    -      cdn_url(super)
    +      has_record? ? cdn_url(db_record.value) : default
         end
     
         def default
    diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb
    index 084c71584a3..24245291c54 100644
    --- a/lib/topic_creator.rb
    +++ b/lib/topic_creator.rb
    @@ -28,18 +28,9 @@ class TopicCreator
         category = find_category
         if category.present? && guardian.can_tag?(topic)
           tags = @opts[:tags].presence || []
    -      existing_tags = tags.present? ? Tag.where(name: tags) : []
    -      valid_tags = guardian.can_create_tag? ? tags : existing_tags
     
    -      # all add to topic.errors
    -      DiscourseTagging.validate_min_required_tags_for_category(
    -        guardian,
    -        topic,
    -        category,
    -        valid_tags,
    -      )
    -      DiscourseTagging.validate_required_tags_from_group(guardian, topic, category, existing_tags)
    -      DiscourseTagging.validate_category_restricted_tags(guardian, topic, category, valid_tags)
    +      # adds topic.errors
    +      DiscourseTagging.validate_category_tags(guardian, topic, category, tags)
         end
     
         DiscourseEvent.trigger(:after_validate_topic, topic, self)
    @@ -181,7 +172,7 @@ class TopicCreator
     
             return Category.find(SiteSetting.shared_drafts_category) if @opts[:shared_draft]
     
    -        if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/)
    +        if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /\A\d+\z/)
               Category.find_by(id: @opts[:category])
             end
           end
    diff --git a/lib/topic_query.rb b/lib/topic_query.rb
    index a2d1d1a8ae7..3409f754586 100644
    --- a/lib/topic_query.rb
    +++ b/lib/topic_query.rb
    @@ -14,7 +14,7 @@ class TopicQuery
       def self.validators
         @validators ||=
           begin
    -        int = lambda { |x| Integer === x || (String === x && x.match?(/^-?[0-9]+$/)) }
    +        int = lambda { |x| Integer === x || (String === x && x.match?(/\A-?[0-9]+\z/)) }
             zero_up_to_max_int = lambda { |x| int.call(x) && x.to_i.between?(0, PG_MAX_INT) }
             array_or_string = lambda { |x| Array === x || String === x }
     
    @@ -204,7 +204,8 @@ class TopicQuery
       end
     
       # Return a list of suggested topics for a topic
    -  def list_suggested_for(topic, pm_params: nil)
    +  # The include_random param was added so plugins can generate a suggested topics list without the random topics
    +  def list_suggested_for(topic, pm_params: nil, include_random: true)
         # Don't suggest messages unless we have a user, and private messages are
         # enabled.
         if topic.private_message? &&
    @@ -216,6 +217,13 @@ class TopicQuery
     
         pm_params = pm_params || get_pm_params(topic)
     
    +    if DiscoursePluginRegistry.list_suggested_for_providers.any?
    +      DiscoursePluginRegistry.list_suggested_for_providers.each do |provider|
    +        suggested = provider.call(topic, pm_params, self)
    +        builder.add_results(suggested[:result]) if suggested && !suggested[:result].blank?
    +      end
    +    end
    +
         # When logged in we start with different results
         if @user
           if topic.private_message?
    @@ -227,23 +235,33 @@ class TopicQuery
               builder.add_results(unread_messages(pm_params.merge(count: builder.results_left)))
             end
           else
    -        builder.add_results(
    -          unread_results(
    -            topic: topic,
    -            per_page: builder.results_left,
    -            max_age: SiteSetting.suggested_topics_unread_max_days_old,
    -          ),
    -          :high,
    -        )
    +        if @user.new_new_view_enabled?
    +          builder.add_results(
    +            new_and_unread_results(
    +              topic:,
    +              per_page: builder.results_left,
    +              max_age: SiteSetting.suggested_topics_unread_max_days_old,
    +            ),
    +          )
    +        else
    +          builder.add_results(
    +            unread_results(
    +              topic: topic,
    +              per_page: builder.results_left,
    +              max_age: SiteSetting.suggested_topics_unread_max_days_old,
    +            ),
    +            :high,
    +          )
     
    -        unless builder.full?
    -          builder.add_results(new_results(topic: topic, per_page: builder.category_results_left))
    +          unless builder.full?
    +            builder.add_results(new_results(topic: topic, per_page: builder.category_results_left))
    +          end
             end
           end
         end
     
         if !topic.private_message?
    -      unless builder.full?
    +      if include_random && !builder.full?
             builder.add_results(
               random_suggested(topic, builder.results_left, builder.excluded_topic_ids),
             )
    @@ -260,6 +278,24 @@ class TopicQuery
         create_list(:latest, {}, latest_results)
       end
     
    +  def list_filter
    +    topics_filter =
    +      TopicsFilter.new(
    +        guardian: @guardian,
    +        scope: latest_results(include_muted: false, skip_ordering: true),
    +      )
    +
    +    results = topics_filter.filter_from_query_string(@options[:q])
    +
    +    if !topics_filter.topic_notification_levels.include?(NotificationLevels.all[:muted])
    +      results = remove_muted_topics(results, @user)
    +    end
    +
    +    results = apply_ordering(results) if results.order_values.empty?
    +
    +    create_list(:filter, {}, results)
    +  end
    +
       def list_read
         create_list(:read, unordered: true) do |topics|
           topics.where("tu.last_visited_at IS NOT NULL").order("tu.last_visited_at DESC")
    @@ -267,7 +303,11 @@ class TopicQuery
       end
     
       def list_new
    -    create_list(:new, { unordered: true }, new_results)
    +    if @user&.new_new_view_enabled?
    +      create_list(:new, { unordered: true }, new_and_unread_results)
    +    else
    +      create_list(:new, { unordered: true }, new_results)
    +    end
       end
     
       def list_unread
    @@ -430,7 +470,7 @@ class TopicQuery
           (pinned_topics + unpinned_topics)[0...limit] if limit
         else
           offset = (page * per_page) - pinned_topics.length
    -      offset = 0 unless offset > 0
    +      offset = 0 if offset <= 0
           unpinned_topics.offset(offset).to_a
         end
       end
    @@ -439,6 +479,8 @@ class TopicQuery
         options[:filter] ||= filter
         topics ||= default_results(options)
         topics = yield(topics) if block_given?
    +    topics =
    +      DiscoursePluginRegistry.apply_modifier(:topic_query_create_list_topics, topics, options, self)
     
         options = options.merge(@options)
         if %w[activity default].include?(options[:order] || "activity") && !options[:unordered] &&
    @@ -451,7 +493,8 @@ class TopicQuery
         if options[:preload_posters]
           user_ids = []
           topics.each do |ft|
    -        user_ids << ft.user_id << ft.last_post_user_id << ft.featured_user_ids << ft.allowed_user_ids
    +        user_ids << ft.user_id << ft.last_post_user_id << ft.featured_user_ids <<
    +          ft.allowed_user_ids
           end
     
           user_lookup = UserLookup.new(user_ids)
    @@ -465,7 +508,13 @@ class TopicQuery
         end
     
         topics.each do |t|
    -      t.allowed_user_ids = filter == :private_messages ? t.allowed_users.map { |u| u.id } : []
    +      if filter == :private_messages
    +        t.allowed_user_ids = t.allowed_users.map { |u| u.id }
    +        t.allowed_group_ids = t.allowed_groups.map { |g| g.id }
    +      else
    +        t.allowed_user_ids = []
    +        t.allowed_group_ids = []
    +      end
         end
     
         list = TopicList.new(filter, @user, topics, options.merge(@options))
    @@ -507,21 +556,7 @@ class TopicQuery
             whisperer: @user&.whisperer?,
           ).order("CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END")
     
    -    if @user
    -      # micro optimisation so we don't load up all of user stats which we do not need
    -      unread_at =
    -        DB.query_single("select first_unread_at from user_stats where user_id = ?", @user.id).first
    -
    -      if max_age = options[:max_age]
    -        max_age_date = max_age.days.ago
    -        unread_at ||= max_age_date
    -        unread_at = unread_at > max_age_date ? unread_at : max_age_date
    -      end
    -
    -      # perf note, in the past we tried doing this in a subquery but performance was
    -      # terrible, also tried with a join and it was bad
    -      result = result.where("topics.updated_at >= ?", unread_at)
    -    end
    +    result = apply_max_age_limit(result, options)
     
         self.class.results_filter_callbacks.each do |filter_callback|
           result = filter_callback.call(:unread, result, @user, options)
    @@ -548,6 +583,29 @@ class TopicQuery
         suggested_ordering(result, options)
       end
     
    +  def new_and_unread_results(options = {})
    +    base = default_results(options.reverse_merge(unordered: true))
    +
    +    new_results =
    +      TopicQuery.new_filter(
    +        base,
    +        treat_as_new_topic_start_date: @user.user_option.treat_as_new_topic_start_date,
    +      )
    +
    +    new_results = remove_muted(new_results, @user, options)
    +    new_results = remove_dismissed(new_results, @user)
    +
    +    unread_results =
    +      apply_max_age_limit(TopicQuery.unread_filter(base, whisperer: @user&.whisperer?), options)
    +
    +    base.joins_values.concat(new_results.joins_values, unread_results.joins_values)
    +    base.joins_values.uniq!
    +    results = base.merge(new_results.or(unread_results))
    +
    +    results = results.order("CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END")
    +    suggested_ordering(results, options)
    +  end
    +
       protected
     
       def per_page_setting
    @@ -582,7 +640,7 @@ class TopicQuery
         result.where("topics.category_id != ?", drafts_category_id)
       end
     
    -  def apply_ordering(result, options)
    +  def apply_ordering(result, options = {})
         sort_column = SORTABLE_MAPPING[options[:order]] || "default"
         sort_dir = (options[:ascending] == "true") ? "ASC" : "DESC"
     
    @@ -630,8 +688,7 @@ class TopicQuery
         category_id = category_id_or_slug.to_i
     
         if category_id == 0
    -      category_id =
    -        Category.where(slug: category_id_or_slug, parent_category_id: nil).pluck_first(:id)
    +      category_id = Category.where(slug: category_id_or_slug, parent_category_id: nil).pick(:id)
         end
     
         category_id
    @@ -642,12 +699,16 @@ class TopicQuery
         options.reverse_merge!(@options)
         options.reverse_merge!(per_page: per_page_setting) unless options[:limit] == false
     
    -    # Whether to return visible topics
    -    options[:visible] = true if @user.nil? || @user.regular?
    -    options[:visible] = false if @user && @user.id == options[:filtered_to_user]
    +    # Whether to include unlisted (visible = false) topics
    +    viewing_own_topics = @user && @user.id == options[:filtered_to_user]
    +
    +    if options[:visible].nil?
    +      options[:visible] = true if @user.nil? || @user.regular?
    +      options[:visible] = false if @guardian.can_see_unlisted_topics? || viewing_own_topics
    +    end
     
         # Start with a list of all topics
    -    result = Topic.unscoped.includes(:category)
    +    result = Topic.includes(:category)
     
         if @user
           result =
    @@ -665,7 +726,10 @@ class TopicQuery
             result = result.where("topics.category_id IN (?)", Category.subcategory_ids(category_id))
             if !SiteSetting.show_category_definitions_in_topic_lists
               result =
    -            result.where("categories.topic_id <> topics.id OR topics.category_id = ?", category_id)
    +            result.where(
    +              "categories.topic_id IS DISTINCT FROM topics.id OR topics.category_id = ?",
    +              category_id,
    +            )
             end
           end
           result = result.references(:categories)
    @@ -674,7 +738,7 @@ class TopicQuery
             filter = (options[:filter] || options[:f])
             # category default sort order
             sort_order, sort_ascending =
    -          Category.where(id: category_id).pluck_first(:sort_order, :sort_ascending)
    +          Category.where(id: category_id).pick(:sort_order, :sort_ascending)
             if sort_order && (filter.blank? || %i[latest unseen].include?(filter))
               options[:order] = sort_order
               options[:ascending] = !!sort_ascending ? "true" : "false"
    @@ -686,67 +750,17 @@ class TopicQuery
         end
     
         if SiteSetting.tagging_enabled
    -      result = result.preload(:tags)
    -
    -      tags_arg = @options[:tags]
    -
    -      if tags_arg && tags_arg.size > 0
    -        tags_arg = tags_arg.split if String === tags_arg
    -
    -        tags_arg =
    -          tags_arg.map do |t|
    -            if String === t
    -              t.downcase
    -            else
    -              t
    -            end
    -          end
    -
    -        tags_query = tags_arg[0].is_a?(String) ? Tag.where_name(tags_arg) : Tag.where(id: tags_arg)
    -        tags = tags_query.select(:id, :target_tag_id).map { |t| t.target_tag_id || t.id }.uniq
    -
    -        if ActiveModel::Type::Boolean.new.cast(@options[:match_all_tags])
    -          # ALL of the given tags:
    -          if tags_arg.length == tags.length
    -            tags.each_with_index do |tag, index|
    -              sql_alias = ["t", index].join
    -              result =
    -                result.joins(
    -                  "INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag}",
    -                )
    -            end
    -          else
    -            result = result.none # don't return any results unless all tags exist in the database
    -          end
    -        else
    -          # ANY of the given tags:
    -          result = result.joins(:tags).where("tags.id in (?)", tags)
    -        end
    -
    -        # TODO: this is very side-effecty and should be changed
    -        # It is done cause further up we expect normalized tags
    -        @options[:tags] = tags
    -      elsif @options[:no_tags]
    -        # the following will do: ("topics"."id" NOT IN (SELECT DISTINCT "topic_tags"."topic_id" FROM "topic_tags"))
    -        result = result.where.not(id: TopicTag.distinct.pluck(:topic_id))
    -      end
    -
    -      if @options[:exclude_tag].present? &&
    -           !DiscourseTagging.hidden_tag_names(@guardian).include?(@options[:exclude_tag])
    -        result = result.where(<<~SQL, name: @options[:exclude_tag])
    -          topics.id NOT IN (
    -            SELECT topic_tags.topic_id
    -            FROM topic_tags
    -            INNER JOIN tags ON tags.id = topic_tags.tag_id
    -            WHERE tags.name = :name
    -          )
    -          SQL
    -      end
    +      result = result.includes(:tags)
    +      result = filter_by_tags(result)
         end
     
    -    result = apply_ordering(result, options)
    +    result = apply_ordering(result, options) if !options[:skip_ordering]
     
    -    all_listable_topics = @guardian.filter_allowed_categories(Topic.unscoped.listable_topics)
    +    all_listable_topics =
    +      @guardian.filter_allowed_categories(
    +        Topic.unscoped.listable_topics,
    +        category_id_column: "categories.id",
    +      )
     
         if options[:include_pms] || options[:include_all_pms]
           all_pm_topics =
    @@ -805,8 +819,6 @@ class TopicQuery
             )
         end
     
    -    require_deleted_clause = true
    -
         if before = options[:before]
           if (before = before.to_i) > 0
             result = result.where("topics.created_at < ?", before.to_i.days.ago)
    @@ -820,24 +832,11 @@ class TopicQuery
         end
     
         if status = options[:status]
    -      case status
    -      when "open"
    -        result = result.where("NOT topics.closed AND NOT topics.archived")
    -      when "closed"
    -        result = result.where("topics.closed")
    -      when "archived"
    -        result = result.where("topics.archived")
    -      when "listed"
    -        result = result.where("topics.visible")
    -      when "unlisted"
    -        result = result.where("NOT topics.visible")
    -      when "deleted"
    -        category = Category.find_by(id: options[:category])
    -        if @guardian.can_see_deleted_topics?(category)
    -          result = result.where("topics.deleted_at IS NOT NULL")
    -          require_deleted_clause = false
    -        end
    -      end
    +      result =
    +        TopicsFilter.new(scope: result, guardian: @guardian).filter_status(
    +          status: options[:status],
    +          category_id: options[:category],
    +        )
         end
     
         if (filter = (options[:filter] || options[:f])) && @user
    @@ -860,7 +859,6 @@ class TopicQuery
           result = TopicQuery.tracked_filter(result, @user.id) if filter == "tracked"
         end
     
    -    result = result.where("topics.deleted_at IS NULL") if require_deleted_clause
         result = result.where("topics.posts_count <= ?", options[:max_posts]) if options[
           :max_posts
         ].present?
    @@ -874,7 +872,11 @@ class TopicQuery
       end
     
       def remove_muted(list, user, options)
    -    list = remove_muted_topics(list, user) unless options && options[:state] == "muted"
    +    if options && (options[:include_muted].nil? || options[:include_muted]) &&
    +         options[:state] != "muted"
    +      list = remove_muted_topics(list, user)
    +    end
    +
         list = remove_muted_categories(list, user, exclude: options[:category])
         TopicQuery.remove_muted_tags(list, user, options)
       end
    @@ -895,24 +897,42 @@ class TopicQuery
         category_id = get_category_id(opts[:exclude]) if opts
     
         if user
    +      watched_tag_ids =
    +        if user.watched_precedence_over_muted
    +          TagUser
    +            .where(user: user)
    +            .where("notification_level >= ?", TopicUser.notification_levels[:watching])
    +            .pluck(:tag_id)
    +        else
    +          []
    +        end
    +
    +      # OR watched_topic_tags.id IS NOT NULL",
           list =
    -        list
    -          .references("cu")
    -          .joins(
    -            "LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id}",
    +        list.references("cu").joins(
    +          "LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id}",
    +        )
    +      if watched_tag_ids.present?
    +        list =
    +          list.joins(
    +            "LEFT JOIN topic_tags watched_topic_tags ON watched_topic_tags.topic_id = topics.id AND #{DB.sql_fragment("watched_topic_tags.tag_id IN (?)", watched_tag_ids)}",
               )
    -          .where(
    -            "topics.category_id = :category_id
    +      end
    +
    +      list =
    +        list.where(
    +          "topics.category_id = :category_id
                     OR
                     (COALESCE(category_users.notification_level, :default) <> :muted AND (topics.category_id IS NULL OR topics.category_id NOT IN(:indirectly_muted_category_ids)))
    +                #{watched_tag_ids.present? ? "OR watched_topic_tags.id IS NOT NULL" : ""}
                     OR tu.notification_level > :regular",
    -            category_id: category_id || -1,
    -            default: CategoryUser.default_notification_level,
    -            indirectly_muted_category_ids:
    -              CategoryUser.indirectly_muted_category_ids(user).presence || [-1],
    -            muted: CategoryUser.notification_levels[:muted],
    -            regular: TopicUser.notification_levels[:regular],
    -          )
    +          category_id: category_id || -1,
    +          default: CategoryUser.default_notification_level,
    +          indirectly_muted_category_ids:
    +            CategoryUser.indirectly_muted_category_ids(user).presence || [-1],
    +          muted: CategoryUser.notification_levels[:muted],
    +          regular: TopicUser.notification_levels[:regular],
    +        )
         elsif SiteSetting.mute_all_categories_by_default
           category_ids = [
             SiteSetting.default_categories_watching.split("|"),
    @@ -922,12 +942,12 @@ class TopicQuery
           ].flatten.map(&:to_i)
           category_ids << category_id if category_id.present? && category_ids.exclude?(category_id)
     
    -      list = list.where("topics.category_id IN (?)", category_ids) if category_ids.present?
    +      list = list.where("categories.id IN (?)", category_ids) if category_ids.present?
         else
           category_ids = SiteSetting.default_categories_muted.split("|").map(&:to_i)
           category_ids -= [category_id] if category_id.present? && category_ids.include?(category_id)
     
    -      list = list.where("topics.category_id NOT IN (?)", category_ids) if category_ids.present?
    +      list = list.where("categories.id NOT IN (?)", category_ids) if category_ids.present?
         end
     
         list
    @@ -961,6 +981,19 @@ class TopicQuery
           end
         end
     
    +    query_params = { tag_ids: muted_tag_ids }
    +
    +    if user && !opts[:skip_categories]
    +      query_params[:regular] = CategoryUser.notification_levels[:regular]
    +
    +      query_params[:watching_or_infinite] = if user.watched_precedence_over_muted ||
    +           SiteSetting.watched_precedence_over_muted
    +        CategoryUser.notification_levels[:watching]
    +      else
    +        99
    +      end
    +    end
    +
         if SiteSetting.remove_muted_tags_from_latest == "always"
           list =
             list.where(
    @@ -969,8 +1002,9 @@ class TopicQuery
               SELECT 1
                 FROM topic_tags tt
                WHERE tt.tag_id IN (:tag_ids)
    -             AND tt.topic_id = topics.id)",
    -          tag_ids: muted_tag_ids,
    +             AND tt.topic_id = topics.id
    +             #{user && !opts[:skip_categories] ? "AND COALESCE(category_users.notification_level, :regular) < :watching_or_infinite" : ""})",
    +          query_params,
             )
         else
           list =
    @@ -979,10 +1013,11 @@ class TopicQuery
             EXISTS (
               SELECT 1
                 FROM topic_tags tt
    -           WHERE tt.tag_id NOT IN (:tag_ids)
    -             AND tt.topic_id = topics.id
    +           WHERE (tt.tag_id NOT IN (:tag_ids)
    +             AND tt.topic_id = topics.id)
    +             #{user && !opts[:skip_categories] ? "OR COALESCE(category_users.notification_level, :regular) >= :watching_or_infinite" : ""}
             ) OR NOT EXISTS (SELECT 1 FROM topic_tags tt WHERE tt.topic_id = topics.id)",
    -          tag_ids: muted_tag_ids,
    +          query_params,
             )
         end
       end
    @@ -1019,7 +1054,7 @@ class TopicQuery
               :first_unread_pm_at,
             )
           else
    -        UserStat.where(user_id: @user.id).pluck_first(:first_unread_pm_at)
    +        UserStat.where(user_id: @user.id).pick(:first_unread_pm_at)
           end
     
         query = query.where("topics.updated_at >= ?", first_unread_pm_at) if first_unread_pm_at
    @@ -1164,4 +1199,73 @@ class TopicQuery
         col_name = whisperer ? "highest_staff_post_number" : "highest_post_number"
         list.where("tu.last_read_post_number IS NULL OR tu.last_read_post_number < topics.#{col_name}")
       end
    +
    +  def apply_max_age_limit(results, options)
    +    if @user
    +      # micro optimisation so we don't load up all of user stats which we do not need
    +      unread_at =
    +        DB.query_single("select first_unread_at from user_stats where user_id = ?", @user.id).first
    +
    +      if max_age = options[:max_age]
    +        max_age_date = max_age.days.ago
    +        unread_at ||= max_age_date
    +        unread_at = unread_at > max_age_date ? unread_at : max_age_date
    +      end
    +
    +      # perf note, in the past we tried doing this in a subquery but performance was
    +      # terrible, also tried with a join and it was bad
    +      results = results.where("topics.updated_at >= ?", unread_at)
    +    end
    +    results
    +  end
    +
    +  def filter_by_tags(result)
    +    tags_arg = @options[:tags]
    +
    +    if tags_arg && tags_arg.size > 0
    +      tags_arg = tags_arg.split if String === tags_arg
    +      tags_query = tags_arg[0].is_a?(String) ? Tag.where_name(tags_arg) : Tag.where(id: tags_arg)
    +      tags = tags_query.select(:id, :target_tag_id).map { |t| t.target_tag_id || t.id }.uniq
    +
    +      if ActiveModel::Type::Boolean.new.cast(@options[:match_all_tags])
    +        # ALL of the given tags:
    +        if tags_arg.length == tags.length
    +          tags.each_with_index do |tag, index|
    +            sql_alias = ["t", index].join
    +
    +            result =
    +              result.joins(
    +                "INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag}",
    +              )
    +          end
    +        else
    +          result = result.none # don't return any results unless all tags exist in the database
    +        end
    +      else
    +        # ANY of the given tags:
    +        result = result.joins(:tags).where("tags.id in (?)", tags)
    +      end
    +
    +      # TODO: this is very side-effecty and should be changed
    +      # It is done cause further up we expect normalized tags
    +      @options[:tags] = tags
    +    elsif @options[:no_tags]
    +      # the following will do: ("topics"."id" NOT IN (SELECT DISTINCT "topic_tags"."topic_id" FROM "topic_tags"))
    +      result = result.where.not(id: TopicTag.distinct.select(:topic_id))
    +    end
    +
    +    if @options[:exclude_tag].present? &&
    +         !DiscourseTagging.hidden_tag_names(@guardian).include?(@options[:exclude_tag])
    +      result = result.where(<<~SQL, name: @options[:exclude_tag])
    +        topics.id NOT IN (
    +          SELECT topic_tags.topic_id
    +          FROM topic_tags
    +          INNER JOIN tags ON tags.id = topic_tags.tag_id
    +          WHERE tags.name = :name
    +        )
    +        SQL
    +    end
    +
    +    result
    +  end
     end
    diff --git a/lib/topic_query/private_message_lists.rb b/lib/topic_query/private_message_lists.rb
    index 0c4874ba2a8..53bd63c64c3 100644
    --- a/lib/topic_query/private_message_lists.rb
    +++ b/lib/topic_query/private_message_lists.rb
    @@ -3,15 +3,20 @@
     class TopicQuery
       module PrivateMessageLists
         def list_private_messages(user, &blk)
    -      list = private_messages_for(user, :user)
    +      list = user_personal_private_messages(user)
           list = not_archived(list, user)
           list = have_posts_from_others(list, user)
     
           create_list(:private_messages, {}, list, &blk)
         end
     
    -    def list_private_messages_direct_and_groups(user, &blk)
    -      list = private_messages_for(user, :all)
    +    def list_private_messages_direct_and_groups(user, groups_messages_notification_level: nil, &blk)
    +      list =
    +        user_personal_and_groups_private_messages(
    +          user,
    +          groups_messages_notification_level: groups_messages_notification_level,
    +        )
    +
           list = not_archived(list, user)
           list = not_archived_in_groups(list)
           list = have_posts_from_others(list, user)
    @@ -20,14 +25,16 @@ class TopicQuery
         end
     
         def list_private_messages_archive(user)
    -      list = private_messages_for(user, :user)
    +      list = user_personal_private_messages(user)
    +
           list =
             list.joins(:user_archived_messages).where("user_archived_messages.user_id = ?", user.id)
    +
           create_list(:private_messages, {}, list)
         end
     
         def list_private_messages_sent(user)
    -      list = private_messages_for(user, :user)
    +      list = user_personal_private_messages(user)
     
           list = list.where(<<~SQL, user.id)
           EXISTS (
    @@ -42,7 +49,7 @@ class TopicQuery
     
         def list_private_messages_new(user, type = :user)
           list = filter_private_message_new(user, type)
    -      list = TopicQuery.remove_muted_tags(list, user)
    +      list = TopicQuery.remove_muted_tags(list, user, skip_categories: true)
           list = remove_dismissed(list, user)
     
           create_list(:private_messages, {}, list)
    @@ -54,7 +61,7 @@ class TopicQuery
         end
     
         def list_private_messages_group(user)
    -      list = private_messages_for(user, :group)
    +      list = user_groups_private_messages(user)
     
           list = list.joins(<<~SQL)
           LEFT JOIN group_archived_messages gm
    @@ -64,11 +71,11 @@ class TopicQuery
           list = list.where("gm.id IS NULL")
           publish_read_state = !!group.publish_read_state
           list = append_read_state(list, group) if publish_read_state
    -      create_list(:private_messages, { publish_read_state: publish_read_state }, list)
    +      create_list(:private_messages, { publish_read_state: publish_read_state, group: group }, list)
         end
     
         def list_private_messages_group_archive(user)
    -      list = private_messages_for(user, :group)
    +      list = user_groups_private_messages(user)
     
           list = list.joins(<<~SQL)
           INNER JOIN group_archived_messages gm
    @@ -77,7 +84,7 @@ class TopicQuery
     
           publish_read_state = !!group.publish_read_state
           list = append_read_state(list, group) if publish_read_state
    -      create_list(:private_messages, { publish_read_state: publish_read_state }, list)
    +      create_list(:private_messages, { publish_read_state: publish_read_state, group: group }, list)
         end
     
         def list_private_messages_group_new(user)
    @@ -85,18 +92,18 @@ class TopicQuery
           list = remove_dismissed(list, user)
           publish_read_state = !!group.publish_read_state
           list = append_read_state(list, group) if publish_read_state
    -      create_list(:private_messages, { publish_read_state: publish_read_state }, list)
    +      create_list(:private_messages, { publish_read_state: publish_read_state, group: group }, list)
         end
     
         def list_private_messages_group_unread(user)
           list = filter_private_messages_unread(user, :group)
           publish_read_state = !!group.publish_read_state
           list = append_read_state(list, group) if publish_read_state
    -      create_list(:private_messages, { publish_read_state: publish_read_state }, list)
    +      create_list(:private_messages, { publish_read_state: publish_read_state, group: group }, list)
         end
     
         def list_private_messages_warnings(user)
    -      list = private_messages_for(user, :user)
    +      list = user_personal_private_messages(user)
           list = list.where("topics.subtype = ?", TopicSubtype.moderator_warning)
           # Exclude official warnings that the user created, instead of received
           list = list.where("topics.user_id <> ?", user.id)
    @@ -104,79 +111,24 @@ class TopicQuery
         end
     
         def private_messages_for(user, type)
    -      options = @options
    -      options.reverse_merge!(per_page: per_page_setting)
    -
    -      result = Topic.includes(:allowed_users)
    -      result = result.includes(:tags) if SiteSetting.tagging_enabled
    -
           if type == :group
    -        result =
    -          result.joins(
    -            "INNER JOIN topic_allowed_groups tag ON tag.topic_id = topics.id AND tag.group_id IN (SELECT id FROM groups WHERE LOWER(name) = '#{PG::Connection.escape_string(@options[:group_name].downcase)}')",
    -          )
    -
    -        unless user.admin?
    -          result =
    -            result.joins(
    -              "INNER JOIN group_users gu ON gu.group_id = tag.group_id AND gu.user_id = #{user.id.to_i}",
    -            )
    -        end
    +        user_groups_private_messages(user)
           elsif type == :user
    -        result =
    -          result.where(
    -            "topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = ?)",
    -            user.id.to_i,
    -          )
    +        user_personal_private_messages(user)
           elsif type == :all
    -        group_ids = group_with_messages_ids(user)
    -
    -        result =
    -          if group_ids.present?
    -            result.where(<<~SQL, user.id.to_i, group_ids)
    -            topics.id IN (
    -              SELECT topic_id
    -              FROM topic_allowed_users
    -              WHERE user_id = ?
    -              UNION ALL
    -              SELECT topic_id FROM topic_allowed_groups
    -              WHERE group_id IN (?)
    -            )
    -          SQL
    -          else
    -            result.joins(<<~SQL)
    -          INNER JOIN topic_allowed_users tau
    -            ON tau.topic_id = topics.id
    -            AND tau.user_id = #{user.id.to_i}
    -          SQL
    -          end
    +        user_personal_and_groups_private_messages(user)
           end
    -
    -      result =
    -        result
    -          .joins(
    -            "LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{user.id.to_i})",
    -          )
    -          .order("topics.bumped_at DESC")
    -          .private_messages
    -
    -      result = result.limit(options[:per_page]) unless options[:limit] == false
    -      result = result.visible if options[:visible] || @user.nil? || @user.regular?
    -
    -      if options[:page]
    -        offset = options[:page].to_i * options[:per_page]
    -        result = result.offset(offset) if offset > 0
    -      end
    -      result
         end
     
         def list_private_messages_tag(user)
    -      list = private_messages_for(user, :all)
    +      list = user_personal_and_groups_private_messages(user)
    +
           list =
             list.joins(
               "JOIN topic_tags tt ON tt.topic_id = topics.id
                             JOIN tags t ON t.id = tt.tag_id AND t.name = '#{@options[:tags][0]}'",
             )
    +
           create_list(:private_messages, {}, list)
         end
     
    @@ -188,7 +140,7 @@ class TopicQuery
             when :user
               user_first_unread_pm_at(user)
             when :group
    -          GroupUser.where(user: user, group: group).pluck_first(:first_unread_pm_at)
    +          GroupUser.where(user: user, group: group).pick(:first_unread_pm_at)
             else
               user_first_unread_pm_at = user_first_unread_pm_at(user)
     
    @@ -284,7 +236,7 @@ class TopicQuery
         end
     
         def user_first_unread_pm_at(user)
    -      UserStat.where(user: user).pluck_first(:first_unread_pm_at)
    +      UserStat.where(user: user).pick(:first_unread_pm_at)
         end
     
         def group_with_messages_ids(user)
    @@ -296,5 +248,99 @@ class TopicQuery
     
           @group_with_messages_ids[user.id] = user.groups.where(has_messages: true).pluck(:id)
         end
    +
    +    private
    +
    +    def private_messages_default_scope(user)
    +      options = @options
    +      options.reverse_merge!(per_page: per_page_setting)
    +
    +      result =
    +        Topic
    +          .private_messages
    +          .includes(:allowed_users)
    +          .includes(:allowed_groups)
    +          .joins(
    +            "LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{user.id.to_i})",
    +          )
    +          .order("topics.bumped_at DESC")
    +
    +      result = result.includes(:tags) if SiteSetting.tagging_enabled
    +      result = result.limit(options[:per_page]) unless options[:limit] == false
    +      result = result.visible if options[:visible] || @user.nil? || @user.regular?
    +
    +      if options[:page]
    +        offset = options[:page].to_i * options[:per_page]
    +        result = result.offset(offset) if offset > 0
    +      end
    +
    +      result
    +    end
    +
    +    def user_groups_private_messages(user)
    +      result = private_messages_default_scope(user)
    +
    +      result =
    +        result.joins(
    +          "INNER JOIN topic_allowed_groups tag ON tag.topic_id = topics.id AND tag.group_id IN (SELECT id FROM groups WHERE LOWER(name) = '#{PG::Connection.escape_string(@options[:group_name].downcase)}')",
    +        )
    +
    +      unless user.admin?
    +        result =
    +          result.joins(
    +            "INNER JOIN group_users gu ON gu.group_id = tag.group_id AND gu.user_id = #{user.id.to_i}",
    +          )
    +      end
    +
    +      result
    +    end
    +
    +    def user_personal_private_messages(user)
    +      result = private_messages_default_scope(user)
    +
    +      result.where(
    +        "topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = ?)",
    +        user.id.to_i,
    +      )
    +    end
    +
    +    def user_personal_and_groups_private_messages(user, groups_messages_notification_level: nil)
    +      result = private_messages_default_scope(user)
    +      group_ids = group_with_messages_ids(user)
    +
    +      topic_allowed_groups_scope =
    +        if groups_messages_notification_level.present? &&
    +             notification_level =
    +               NotificationLevels.topic_levels[groups_messages_notification_level]
    +          <<~SQL
    +          SELECT topic_allowed_groups.topic_id
    +          FROM topic_allowed_groups
    +          INNER JOIN topic_users ON topic_users.topic_id = topic_allowed_groups.topic_id AND topic_users.user_id = :user_id
    +          WHERE group_id IN (:group_ids)
    +          AND topic_users.notification_level >= #{notification_level.to_i}
    +          SQL
    +        else
    +          "SELECT topic_id FROM topic_allowed_groups WHERE group_id IN (:group_ids)"
    +        end
    +
    +      result =
    +        if group_ids.present?
    +          result.where(<<~SQL, user_id: user.id.to_i, group_ids: group_ids)
    +          topics.id IN (
    +            SELECT topic_id
    +            FROM topic_allowed_users
    +            WHERE user_id = :user_id
    +            UNION ALL
    +            #{topic_allowed_groups_scope}
    +          )
    +          SQL
    +        else
    +          result.joins(<<~SQL)
    +          INNER JOIN topic_allowed_users tau
    +          ON tau.topic_id = topics.id
    +          AND tau.user_id = #{user.id.to_i}
    +          SQL
    +        end
    +    end
       end
     end
    diff --git a/lib/topic_query_params.rb b/lib/topic_query_params.rb
    index 291886604d6..4195c3709aa 100644
    --- a/lib/topic_query_params.rb
    +++ b/lib/topic_query_params.rb
    @@ -3,7 +3,7 @@
     module TopicQueryParams
       def build_topic_list_options
         options = {}
    -    params[:tags] = [params[:tag_id]] if params[:tag_id].present? && guardian.can_tag_pms?
    +    params[:tags] = [params[:tag_id], *Array(params[:tags])].uniq if params[:tag_id].present?
     
         TopicQuery.public_valid_options.each do |key|
           if params.key?(key)
    @@ -18,18 +18,6 @@ module TopicQueryParams
           :no_subcategories
         ].present?
     
    -    if hide_welcome_topic?
    -      options[:except_topic_ids] ||= []
    -      options[:except_topic_ids] << SiteSetting.welcome_topic_id
    -    end
    -
         options
       end
    -
    -  private
    -
    -  def hide_welcome_topic?
    -    return false if !SiteSetting.bootstrap_mode_enabled
    -    Site.welcome_topic_exists_and_is_not_edited?
    -  end
     end
    diff --git a/lib/topic_retriever.rb b/lib/topic_retriever.rb
    index 6186ce58680..94da883250d 100644
    --- a/lib/topic_retriever.rb
    +++ b/lib/topic_retriever.rb
    @@ -4,7 +4,6 @@ class TopicRetriever
       def initialize(embed_url, opts = nil)
         @embed_url = embed_url
         @opts = opts || {}
    -    @author_username = @opts[:author_username]
       end
     
       def retrieve
    @@ -35,21 +34,18 @@ class TopicRetriever
         # It's possible another process or job found the embed already. So if that happened bail out.
         return if TopicEmbed.where(embed_url: @embed_url).exists?
     
    -    fetch_http
    -  end
    -
    -  def fetch_http
    -    if @author_username.nil?
    -      username =
    -        SiteSetting.embed_by_username.presence || SiteSetting.site_contact_username.presence ||
    -          Discourse.system_user.username
    -    else
    -      username = @author_username
    +    if @opts[:author_username].present?
    +      Discourse.deprecate(
    +        "discourse_username parameter has been deprecated. Prefer passing author name using a  tag.",
    +        since: "3.1.0.beta1",
    +        drop_from: "3.2",
    +      )
         end
     
    -    user = User.where(username_lower: username.downcase).first
    -    return if user.blank?
    +    username =
    +      @opts[:author_username].presence || SiteSetting.embed_by_username.presence ||
    +        SiteSetting.site_contact_username.presence || Discourse.system_user.username
     
    -    TopicEmbed.import_remote(@embed_url, user: user)
    +    TopicEmbed.import_remote(@embed_url, user: User.find_by(username_lower: username.downcase))
       end
     end
    diff --git a/lib/topic_view.rb b/lib/topic_view.rb
    index f0811049513..f00b64a4771 100644
    --- a/lib/topic_view.rb
    +++ b/lib/topic_view.rb
    @@ -31,8 +31,6 @@ class TopicView
         :personal_message,
         :can_review_topic,
         :page,
    -    :mentioned_users,
    -    :mentions,
       )
       alias queued_posts_enabled? queued_posts_enabled
     
    @@ -144,9 +142,6 @@ class TopicView
           end
         end
     
    -    parse_mentions
    -    load_mentioned_users
    -
         TopicView.preload(self)
     
         @draft_key = @topic.draft_key
    @@ -249,9 +244,9 @@ class TopicView
           if @topic.category_id != SiteSetting.uncategorized_category_id && @topic.category_id &&
                @topic.category
             title += " - #{@topic.category.name}"
    -      elsif SiteSetting.tagging_enabled && @topic.tags.exists?
    +      elsif SiteSetting.tagging_enabled && visible_tags.exists?
             title +=
    -          " - #{@topic.tags.order("tags.#{Tag.topic_count_column(@guardian)} DESC").first.name}"
    +          " - #{visible_tags.order("tags.#{Tag.topic_count_column(@guardian)} DESC").first.name}"
           end
         end
         title
    @@ -580,7 +575,7 @@ class TopicView
     
       def pending_posts
         @pending_posts ||=
    -      ReviewableQueuedPost.pending.where(created_by: @user, topic: @topic).order(:created_at)
    +      ReviewableQueuedPost.pending.where(target_created_by: @user, topic: @topic).order(:created_at)
       end
     
       def actions_summary
    @@ -610,7 +605,16 @@ class TopicView
     
       def suggested_topics
         if @include_suggested
    -      @suggested_topics ||= TopicQuery.new(@user).list_suggested_for(topic, pm_params: pm_params)
    +      @suggested_topics ||=
    +        begin
    +          kwargs =
    +            DiscoursePluginRegistry.apply_modifier(
    +              :topic_view_suggested_topics_options,
    +              { include_random: true, pm_params: pm_params },
    +              self,
    +            )
    +          TopicQuery.new(@user).list_suggested_for(topic, **kwargs)
    +        end
         else
           nil
         end
    @@ -676,7 +680,7 @@ class TopicView
       end
     
       def filtered_post_id(post_number)
    -    @filtered_posts.where(post_number: post_number).pluck_first(:id)
    +    @filtered_posts.where(post_number: post_number).pick(:id)
       end
     
       def is_mega_topic?
    @@ -684,7 +688,7 @@ class TopicView
       end
     
       def last_post_id
    -    @filtered_posts.reverse_order.pluck_first(:id)
    +    @filtered_posts.reverse_order.pick(:id)
       end
     
       def current_post_number
    @@ -701,17 +705,25 @@ class TopicView
         @topic.published_page
       end
     
    -  def parse_mentions
    -    @mentions = @posts.to_h { |p| [p.id, p.mentions] }.reject { |_, v| v.empty? }
    +  def mentioned_users
    +    @mentioned_users ||=
    +      begin
    +        mentions = @posts.to_h { |p| [p.id, p.mentions] }.reject { |_, v| v.empty? }
    +        usernames = mentions.values
    +        usernames.flatten!
    +        usernames.uniq!
    +
    +        users = User.where(username: usernames).includes(:user_status).index_by(&:username)
    +
    +        mentions.reduce({}) do |hash, (post_id, post_mentioned_usernames)|
    +          hash[post_id] = post_mentioned_usernames.map { |username| users[username] }.compact
    +          hash
    +        end
    +      end
       end
     
    -  def load_mentioned_users
    -    usernames = @mentions.values.flatten.uniq
    -    mentioned_users = User.where(username: usernames)
    -
    -    mentioned_users = mentioned_users.includes(:user_status) if SiteSetting.enable_user_status
    -
    -    @mentioned_users = mentioned_users.to_h { |u| [u.username, u] }
    +  def summarizable?
    +    Summarization::Base.can_see_summary?(@topic, @user)
       end
     
       protected
    @@ -815,13 +827,9 @@ class TopicView
       end
     
       def find_topic(topic_or_topic_id)
    -    if topic_or_topic_id.is_a?(Topic)
    -      topic_or_topic_id
    -    else
    -      # with_deleted covered in #check_and_raise_exceptions
    -      finder = Topic.with_deleted.where(id: topic_or_topic_id).includes(:category)
    -      finder.first
    -    end
    +    return topic_or_topic_id if topic_or_topic_id.is_a?(Topic)
    +    # with_deleted covered in #check_and_raise_exceptions
    +    Topic.with_deleted.includes(:category).find_by(id: topic_or_topic_id)
       end
     
       def unfiltered_posts
    @@ -991,4 +999,8 @@ class TopicView
           end
         end
       end
    +
    +  def visible_tags
    +    @visible_tags ||= topic.tags.visible(guardian)
    +  end
     end
    diff --git a/lib/topics_bulk_action.rb b/lib/topics_bulk_action.rb
    index 4ea47b6eb03..bc62ad10e34 100644
    --- a/lib/topics_bulk_action.rb
    +++ b/lib/topics_bulk_action.rb
    @@ -86,6 +86,7 @@ class TopicsBulkAction
       def dismiss_posts
         highest_number_source_column =
           @user.whisperer? ? "highest_staff_post_number" : "highest_post_number"
    +
         sql = <<~SQL
           UPDATE topic_users tu
           SET last_read_post_number = t.#{highest_number_source_column}
    @@ -94,6 +95,8 @@ class TopicsBulkAction
         SQL
     
         DB.exec(sql, user_id: @user.id, topic_ids: @topic_ids)
    +    TopicTrackingState.publish_dismiss_new_posts(@user.id, topic_ids: @topic_ids.sort)
    +
         @changed_ids.concat @topic_ids
       end
     
    @@ -115,7 +118,9 @@ class TopicsBulkAction
           now = Time.zone.now
           rows = ids.map { |id| { topic_id: id, user_id: @user.id, created_at: now } }
           DismissedTopicUser.insert_all(rows)
    +      TopicTrackingState.publish_dismiss_new(@user.id, topic_ids: ids.sort)
         end
    +
         @changed_ids = ids
       end
     
    diff --git a/lib/topics_filter.rb b/lib/topics_filter.rb
    new file mode 100644
    index 00000000000..5ffa80319f1
    --- /dev/null
    +++ b/lib/topics_filter.rb
    @@ -0,0 +1,524 @@
    +# frozen_string_literal: true
    +
    +class TopicsFilter
    +  attr_reader :topic_notification_levels
    +
    +  def initialize(guardian:, scope: Topic.all)
    +    @guardian = guardian
    +    @scope = scope
    +    @topic_notification_levels = Set.new
    +  end
    +
    +  FILTER_ALIASES = { "categories" => "category", "tags" => "tag" }
    +  private_constant :FILTER_ALIASES
    +
    +  def filter_from_query_string(query_string)
    +    return @scope if query_string.blank?
    +
    +    filters = {}
    +
    +    query_string.scan(
    +      /(?(?:-|=|-=|=-))?(?[\w-]+):(?[^\s]+)/,
    +    ) do |key_prefix, key, value|
    +      key = FILTER_ALIASES[key] || key
    +
    +      filters[key] ||= {}
    +      filters[key]["key_prefixes"] ||= []
    +      filters[key]["key_prefixes"] << key_prefix
    +      filters[key]["values"] ||= []
    +      filters[key]["values"] << value
    +    end
    +
    +    filters.each do |filter, hash|
    +      key_prefixes = hash["key_prefixes"]
    +      values = hash["values"]
    +
    +      filter_values = extract_and_validate_value_for(filter, values)
    +
    +      case filter
    +      when "activity-before"
    +        filter_by_activity(before: filter_values)
    +      when "activity-after"
    +        filter_by_activity(after: filter_values)
    +      when "category"
    +        filter_categories(values: key_prefixes.zip(filter_values))
    +      when "created-after"
    +        filter_by_created(after: filter_values)
    +      when "created-before"
    +        filter_by_created(before: filter_values)
    +      when "created-by"
    +        filter_created_by_user(usernames: filter_values.flat_map { |value| value.split(",") })
    +      when "in"
    +        filter_in(values: filter_values)
    +      when "latest-post-after"
    +        filter_by_latest_post(after: filter_values)
    +      when "latest-post-before"
    +        filter_by_latest_post(before: filter_values)
    +      when "likes-min"
    +        filter_by_number_of_likes(min: filter_values)
    +      when "likes-max"
    +        filter_by_number_of_likes(max: filter_values)
    +      when "likes-op-min"
    +        filter_by_number_of_likes_in_first_post(min: filter_values)
    +      when "likes-op-max"
    +        filter_by_number_of_likes_in_first_post(max: filter_values)
    +      when "order"
    +        order_by(values: filter_values)
    +      when "posts-min"
    +        filter_by_number_of_posts(min: filter_values)
    +      when "posts-max"
    +        filter_by_number_of_posts(max: filter_values)
    +      when "posters-min"
    +        filter_by_number_of_posters(min: filter_values)
    +      when "posters-max"
    +        filter_by_number_of_posters(max: filter_values)
    +      when "status"
    +        filter_values.each { |status| @scope = filter_status(status: status) }
    +      when "tag"
    +        filter_tags(values: key_prefixes.zip(filter_values))
    +      when "views-min"
    +        filter_by_number_of_views(min: filter_values)
    +      when "views-max"
    +        filter_by_number_of_views(max: filter_values)
    +      end
    +    end
    +
    +    @scope
    +  end
    +
    +  def filter_status(status:, category_id: nil)
    +    case status
    +    when "open"
    +      @scope = @scope.where("NOT topics.closed AND NOT topics.archived")
    +    when "closed"
    +      @scope = @scope.where("topics.closed")
    +    when "archived"
    +      @scope = @scope.where("topics.archived")
    +    when "listed"
    +      @scope = @scope.where("topics.visible")
    +    when "unlisted"
    +      @scope = @scope.where("NOT topics.visible")
    +    when "deleted"
    +      category = category_id.present? ? Category.find_by(id: category_id) : nil
    +
    +      if @guardian.can_see_deleted_topics?(category)
    +        @scope = @scope.unscope(where: :deleted_at).where("topics.deleted_at IS NOT NULL")
    +      end
    +    when "public"
    +      @scope = @scope.joins(:category).where("NOT categories.read_restricted")
    +    end
    +
    +    @scope
    +  end
    +
    +  private
    +
    +  YYYY_MM_DD_REGEXP =
    +    /\A(?[12][0-9]{3})-(?0?[1-9]|1[0-2])-(?0?[1-9]|[12]\d|3[01])\z/
    +  private_constant :YYYY_MM_DD_REGEXP
    +
    +  def extract_and_validate_value_for(filter, values)
    +    case filter
    +    when "activity-before", "activity-after", "created-before", "created-after",
    +         "latest-post-before", "latest-post-after"
    +      value = values.last
    +
    +      if match_data = value.match(YYYY_MM_DD_REGEXP)
    +        Time.zone.parse(
    +          "#{match_data[:year].to_i}-#{match_data[:month].to_i}-#{match_data[:day].to_i}",
    +        )
    +      end
    +    when "likes-min", "likes-max", "likes-op-min", "likes-op-max", "posts-min", "posts-max",
    +         "posters-min", "posters-max", "views-min", "views-max"
    +      value = values.last
    +      value if value =~ /\A\d+\z/
    +    when "order"
    +      values.flat_map { |value| value.split(",") }
    +    when "created-by"
    +      values.flat_map { |value| value.split(",").map { |username| username.delete_prefix("@") } }
    +    else
    +      values
    +    end
    +  end
    +
    +  def filter_by_topic_range(column_name:, min: nil, max: nil, scope: nil)
    +    { min => ">=", max => "<=" }.each do |value, operator|
    +      next if !value
    +      @scope = (scope || @scope).where("#{column_name} #{operator} ?", value)
    +    end
    +  end
    +
    +  def filter_by_activity(before: nil, after: nil)
    +    filter_by_topic_range(column_name: "topics.bumped_at", min: after, max: before)
    +  end
    +
    +  def filter_by_created(before: nil, after: nil)
    +    filter_by_topic_range(column_name: "topics.created_at", min: after, max: before)
    +  end
    +
    +  def filter_by_latest_post(before: nil, after: nil)
    +    filter_by_topic_range(column_name: "topics.last_posted_at", min: after, max: before)
    +  end
    +
    +  def filter_by_number_of_posts(min: nil, max: nil)
    +    filter_by_topic_range(column_name: "topics.posts_count", min:, max:)
    +  end
    +
    +  def filter_by_number_of_posters(min: nil, max: nil)
    +    filter_by_topic_range(column_name: "topics.participant_count", min:, max:)
    +  end
    +
    +  def filter_by_number_of_likes(min: nil, max: nil)
    +    filter_by_topic_range(column_name: "topics.like_count", min:, max:)
    +  end
    +
    +  def filter_by_number_of_likes_in_first_post(min: nil, max: nil)
    +    filter_by_topic_range(
    +      column_name: "first_posts.like_count",
    +      min:,
    +      max:,
    +      scope: self.joins_first_posts(@scope),
    +    )
    +  end
    +
    +  def filter_by_number_of_views(min: nil, max: nil)
    +    filter_by_topic_range(column_name: "views", min:, max:)
    +  end
    +
    +  def filter_categories(values:)
    +    category_slugs = {
    +      include: {
    +        with_subcategories: [],
    +        without_subcategories: [],
    +      },
    +      exclude: {
    +        with_subcategories: [],
    +        without_subcategories: [],
    +      },
    +    }
    +
    +    values.each do |key_prefix, value|
    +      exclude_categories = key_prefix&.include?("-")
    +      exclude_subcategories = key_prefix&.include?("=")
    +
    +      value
    +        .scan(
    +          /\A(?([\p{L}\p{N}\-:]+)(?[,])?([\p{L}\p{N}\-:]+)?(\k[\p{L}\p{N}\-:]+)*)\z/,
    +        )
    +        .each do |category_slugs_match, delimiter|
    +          slugs = category_slugs_match.split(delimiter)
    +          type = exclude_categories ? :exclude : :include
    +          subcategory_type = exclude_subcategories ? :without_subcategories : :with_subcategories
    +          category_slugs[type][subcategory_type].concat(slugs)
    +        end
    +    end
    +
    +    include_category_ids = []
    +
    +    if category_slugs[:include][:without_subcategories].present?
    +      include_category_ids =
    +        get_category_ids_from_slugs(
    +          category_slugs[:include][:without_subcategories],
    +          exclude_subcategories: true,
    +        )
    +    end
    +
    +    if category_slugs[:include][:with_subcategories].present?
    +      include_category_ids.concat(
    +        get_category_ids_from_slugs(
    +          category_slugs[:include][:with_subcategories],
    +          exclude_subcategories: false,
    +        ),
    +      )
    +    end
    +
    +    if include_category_ids.present?
    +      @scope = @scope.where("topics.category_id IN (?)", include_category_ids)
    +    elsif category_slugs[:include].values.flatten.present?
    +      @scope = @scope.none
    +      return
    +    end
    +
    +    exclude_category_ids = []
    +
    +    if category_slugs[:exclude][:without_subcategories].present?
    +      exclude_category_ids =
    +        get_category_ids_from_slugs(
    +          category_slugs[:exclude][:without_subcategories],
    +          exclude_subcategories: true,
    +        )
    +    end
    +
    +    if category_slugs[:exclude][:with_subcategories].present?
    +      exclude_category_ids.concat(
    +        get_category_ids_from_slugs(
    +          category_slugs[:exclude][:with_subcategories],
    +          exclude_subcategories: false,
    +        ),
    +      )
    +    end
    +
    +    if exclude_category_ids.present?
    +      @scope = @scope.where("topics.category_id NOT IN (?)", exclude_category_ids)
    +    end
    +  end
    +
    +  def filter_created_by_user(usernames:)
    +    @scope =
    +      @scope.joins(:user).where(
    +        "users.username_lower IN (:usernames)",
    +        usernames: usernames.map(&:downcase),
    +      )
    +  end
    +
    +  def filter_in(values:)
    +    values.uniq!
    +
    +    if values.delete("pinned")
    +      @scope =
    +        @scope.where(
    +          "topics.pinned_at IS NOT NULL AND topics.pinned_until > topics.pinned_at AND ? < topics.pinned_until",
    +          Time.zone.now,
    +        )
    +    end
    +
    +    if @guardian.user
    +      if values.delete("bookmarked")
    +        @scope =
    +          @scope.joins(:topic_users).where(
    +            "topic_users.bookmarked AND topic_users.user_id = ?",
    +            @guardian.user.id,
    +          )
    +      end
    +
    +      if values.present?
    +        values.each do |value|
    +          value
    +            .split(",")
    +            .each do |topic_notification_level|
    +              if level = TopicUser.notification_levels[topic_notification_level.to_sym]
    +                @topic_notification_levels << level
    +              end
    +            end
    +        end
    +
    +        @scope =
    +          @scope.joins(:topic_users).where(
    +            "topic_users.notification_level IN (:topic_notification_levels) AND topic_users.user_id = :user_id",
    +            topic_notification_levels: @topic_notification_levels.to_a,
    +            user_id: @guardian.user.id,
    +          )
    +      end
    +    elsif values.present?
    +      @scope = @scope.none
    +    end
    +  end
    +
    +  def get_category_ids_from_slugs(slugs, exclude_subcategories: false)
    +    category_ids = Category.ids_from_slugs(slugs)
    +
    +    category_ids =
    +      Category
    +        .where(id: category_ids)
    +        .filter { |category| @guardian.can_see_category?(category) }
    +        .map(&:id)
    +
    +    if !exclude_subcategories
    +      category_ids = category_ids.flat_map { |category_id| Category.subcategory_ids(category_id) }
    +    end
    +
    +    category_ids
    +  end
    +
    +  # Accepts an array of tag names and returns an array of tag ids and the tag ids of aliases for the tag names which the user can see.
    +  # If a block is given, it will be called with the tag ids and alias tag ids as arguments.
    +  def tag_ids_from_tag_names(tag_names)
    +    tag_ids, alias_tag_ids =
    +      DiscourseTagging
    +        .filter_visible(Tag, @guardian)
    +        .where_name(tag_names)
    +        .pluck(:id, :target_tag_id)
    +        .transpose
    +
    +    tag_ids ||= []
    +    alias_tag_ids ||= []
    +
    +    yield(tag_ids, alias_tag_ids) if block_given?
    +
    +    all_tag_ids = tag_ids.concat(alias_tag_ids)
    +    all_tag_ids.compact!
    +    all_tag_ids.uniq!
    +    all_tag_ids
    +  end
    +
    +  def filter_tags(values:)
    +    return if !SiteSetting.tagging_enabled?
    +
    +    exclude_all_tags = []
    +    exclude_any_tags = []
    +    include_any_tags = []
    +    include_all_tags = []
    +
    +    values.each do |key_prefix, value|
    +      break if key_prefix && key_prefix != "-"
    +
    +      value.scan(
    +        /\A(?([\p{N}\p{L}\-]+)(?[,+])?([\p{N}\p{L}\-]+)?(\k[\p{N}\p{L}\-]+)*)\z/,
    +      ) do |tag_names, delimiter|
    +        match_all =
    +          if delimiter == ","
    +            false
    +          else
    +            true
    +          end
    +
    +        (
    +          case [key_prefix, match_all]
    +          in ["-", true]
    +            exclude_all_tags
    +          in ["-", false]
    +            exclude_any_tags
    +          in [nil, true]
    +            include_all_tags
    +          in [nil, false]
    +            include_any_tags
    +          end
    +        ).concat(tag_names.split(delimiter))
    +      end
    +    end
    +
    +    if exclude_all_tags.present?
    +      exclude_topics_with_all_tags(tag_ids_from_tag_names(exclude_all_tags))
    +    end
    +
    +    if exclude_any_tags.present?
    +      exclude_topics_with_any_tags(tag_ids_from_tag_names(exclude_any_tags))
    +    end
    +
    +    if include_any_tags.present?
    +      include_topics_with_any_tags(tag_ids_from_tag_names(include_any_tags))
    +    end
    +
    +    if include_all_tags.present?
    +      has_invalid_tags = false
    +
    +      all_tag_ids =
    +        tag_ids_from_tag_names(include_all_tags) do |tag_ids, _|
    +          has_invalid_tags = tag_ids.length < include_all_tags.length
    +        end
    +
    +      if has_invalid_tags
    +        @scope = @scope.none
    +      else
    +        include_topics_with_all_tags(all_tag_ids)
    +      end
    +    end
    +  end
    +
    +  def topic_tags_alias
    +    @topic_tags_alias ||= 0
    +    "tt#{@topic_tags_alias += 1}"
    +  end
    +
    +  def exclude_topics_with_all_tags(tag_ids)
    +    where_clause = []
    +
    +    tag_ids.each do |tag_id|
    +      sql_alias = "tt#{topic_tags_alias}"
    +
    +      @scope =
    +        @scope.joins(
    +          "LEFT JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag_id}",
    +        )
    +
    +      where_clause << "#{sql_alias}.topic_id IS NULL"
    +    end
    +
    +    @scope = @scope.where(where_clause.join(" OR "))
    +  end
    +
    +  def exclude_topics_with_any_tags(tag_ids)
    +    @scope =
    +      @scope.where(
    +        "topics.id NOT IN (SELECT DISTINCT topic_id FROM topic_tags WHERE topic_tags.tag_id IN (?))",
    +        tag_ids,
    +      )
    +  end
    +
    +  def include_topics_with_all_tags(tag_ids)
    +    tag_ids.each do |tag_id|
    +      sql_alias = topic_tags_alias
    +
    +      @scope =
    +        @scope.joins(
    +          "INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag_id}",
    +        )
    +    end
    +  end
    +
    +  def include_topics_with_any_tags(tag_ids)
    +    sql_alias = topic_tags_alias
    +
    +    @scope =
    +      @scope
    +        .joins("INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id")
    +        .where("#{sql_alias}.tag_id IN (?)", tag_ids)
    +        .distinct(:id)
    +  end
    +
    +  ORDER_BY_MAPPINGS = {
    +    "activity" => {
    +      column: "topics.bumped_at",
    +    },
    +    "category" => {
    +      column: "categories.name",
    +      scope: -> { @scope.joins(:category) },
    +    },
    +    "created" => {
    +      column: "topics.created_at",
    +    },
    +    "latest-post" => {
    +      column: "topics.last_posted_at",
    +    },
    +    "likes" => {
    +      column: "topics.like_count",
    +    },
    +    "likes-op" => {
    +      column: "first_posts.like_count",
    +      scope: -> { joins_first_posts(@scope) },
    +    },
    +    "posters" => {
    +      column: "topics.participant_count",
    +    },
    +    "title" => {
    +      column: "LOWER(topics.title)",
    +    },
    +    "views" => {
    +      column: "topics.views",
    +    },
    +  }
    +  private_constant :ORDER_BY_MAPPINGS
    +
    +  ORDER_BY_REGEXP = /\A(?#{ORDER_BY_MAPPINGS.keys.join("|")})(?-asc)?\z/
    +  private_constant :ORDER_BY_REGEXP
    +
    +  def order_by(values:)
    +    values.each do |value|
    +      match_data = value.match(ORDER_BY_REGEXP)
    +
    +      if match_data && column_name = ORDER_BY_MAPPINGS.dig(match_data[:order_by], :column)
    +        if scope = ORDER_BY_MAPPINGS.dig(match_data[:order_by], :scope)
    +          @scope = instance_exec(&scope)
    +        end
    +
    +        @scope = @scope.order("#{column_name} #{match_data[:asc] ? "ASC" : "DESC"}")
    +      end
    +    end
    +  end
    +
    +  def joins_first_posts(scope)
    +    scope.joins(
    +      "INNER JOIN posts AS first_posts ON first_posts.topic_id = topics.id AND first_posts.post_number = 1",
    +    )
    +  end
    +end
    diff --git a/lib/trust_level.rb b/lib/trust_level.rb
    index 184b44ced6e..8db2e634baf 100644
    --- a/lib/trust_level.rb
    +++ b/lib/trust_level.rb
    @@ -38,7 +38,7 @@ class TrustLevel
         granted_trust_level = user.group_granted_trust_level || 0
         previous_trust_level = use_previous_trust_level ? find_previous_trust_level(user) : 0
     
    -    [granted_trust_level, previous_trust_level].max
    +    [granted_trust_level, previous_trust_level, SiteSetting.default_trust_level].max
       end
     
       private
    @@ -48,7 +48,7 @@ class TrustLevel
           .where(action: UserHistory.actions[:change_trust_level])
           .where(target_user_id: user.id)
           .order(created_at: :desc)
    -      .pluck_first(:new_value)
    +      .pick(:new_value)
           .to_i
       end
     end
    diff --git a/lib/turbo_tests.rb b/lib/turbo_tests.rb
    index dd3e9c0b619..c8ecc41c6a0 100644
    --- a/lib/turbo_tests.rb
    +++ b/lib/turbo_tests.rb
    @@ -15,6 +15,7 @@ require "parallel_tests/rspec/runner"
     require "./lib/turbo_tests/reporter"
     require "./lib/turbo_tests/runner"
     require "./lib/turbo_tests/json_rows_formatter"
    +require "./lib/turbo_tests/documentation_formatter"
     
     module TurboTests
       FakeException = Struct.new(:backtrace, :message, :cause)
    @@ -54,9 +55,18 @@ module TurboTests
       end
     
       FakeExample =
    -    Struct.new(:execution_result, :location, :full_description, :metadata, :location_rerun_argument)
    +    Struct.new(
    +      :execution_result,
    +      :location,
    +      :description,
    +      :full_description,
    +      :metadata,
    +      :location_rerun_argument,
    +      :process_id,
    +    )
    +
       class FakeExample
    -    def self.from_obj(obj)
    +    def self.from_obj(obj, process_id)
           obj = obj.symbolize_keys
           metadata = obj[:metadata].symbolize_keys
     
    @@ -71,9 +81,11 @@ module TurboTests
           new(
             FakeExecutionResult.from_obj(obj[:execution_result]),
             obj[:location],
    +        obj[:description],
             obj[:full_description],
             metadata,
             obj[:location_rerun_argument],
    +        process_id,
           )
         end
     
    diff --git a/lib/turbo_tests/base_formatter.rb b/lib/turbo_tests/base_formatter.rb
    new file mode 100644
    index 00000000000..ab715a11def
    --- /dev/null
    +++ b/lib/turbo_tests/base_formatter.rb
    @@ -0,0 +1,23 @@
    +# frozen_string_literal: true
    +
    +RSpec::Support.require_rspec_core "formatters/base_text_formatter"
    +RSpec::Support.require_rspec_core "formatters/console_codes"
    +
    +module TurboTests
    +  class BaseFormatter < RSpec::Core::Formatters::BaseTextFormatter
    +    RSpec::Core::Formatters.register(self, :dump_summary)
    +
    +    def dump_summary(notification, timings)
    +      if timings.present?
    +        output.puts "\nTop #{timings.size} Slowest examples:"
    +
    +        timings.each do |(full_description, source_location, duration)|
    +          output.puts "  #{full_description}"
    +          output.puts "    #{RSpec::Core::Formatters::ConsoleCodes.wrap(duration.to_s + "ms", :bold)} #{source_location}"
    +        end
    +      end
    +
    +      super(notification)
    +    end
    +  end
    +end
    diff --git a/lib/turbo_tests/documentation_formatter.rb b/lib/turbo_tests/documentation_formatter.rb
    new file mode 100644
    index 00000000000..0fab466ab2b
    --- /dev/null
    +++ b/lib/turbo_tests/documentation_formatter.rb
    @@ -0,0 +1,55 @@
    +# frozen_string_literal: true
    +
    +module TurboTests
    +  # An RSpec formatter that prepends the process id to all messages
    +  class DocumentationFormatter < ::TurboTests::BaseFormatter
    +    RSpec::Core::Formatters.register(self, :example_failed, :example_passed, :example_pending)
    +
    +    def example_passed(notification)
    +      output.puts RSpec::Core::Formatters::ConsoleCodes.wrap(
    +                    output_example(notification.example),
    +                    :success,
    +                  )
    +
    +      output.flush
    +    end
    +
    +    def example_pending(notification)
    +      message = notification.example.execution_result.pending_message
    +
    +      output.puts RSpec::Core::Formatters::ConsoleCodes.wrap(
    +                    "#{output_example(notification.example)} (PENDING: #{message})",
    +                    :pending,
    +                  )
    +
    +      output.flush
    +    end
    +
    +    def example_failed(notification)
    +      output.puts RSpec::Core::Formatters::ConsoleCodes.wrap(
    +                    "#{output_example(notification.example)} (FAILED - #{next_failure_index})",
    +                    :failure,
    +                  )
    +
    +      output.flush
    +    end
    +
    +    private
    +
    +    def output_example(example)
    +      output =
    +        +"[#{example.process_id}] (##{example.metadata[:process_pid]}) #{example.full_description}"
    +
    +      if run_duration_ms = example.metadata[:run_duration_ms]
    +        output << " (#{run_duration_ms}ms)"
    +      end
    +
    +      output
    +    end
    +
    +    def next_failure_index
    +      @next_failure_index ||= 0
    +      @next_failure_index += 1
    +    end
    +  end
    +end
    diff --git a/lib/turbo_tests/json_rows_formatter.rb b/lib/turbo_tests/json_rows_formatter.rb
    index 35118c35027..e1ab129e382 100644
    --- a/lib/turbo_tests/json_rows_formatter.rb
    +++ b/lib/turbo_tests/json_rows_formatter.rb
    @@ -54,6 +54,8 @@ module TurboTests
               shared_group_inclusion_backtrace:
                 example.metadata[:shared_group_inclusion_backtrace].map(&method(:stack_frame_to_json)),
               extra_failure_lines: example.metadata[:extra_failure_lines],
    +          run_duration_ms: example.metadata[:run_duration_ms],
    +          process_pid: Process.pid,
             },
             location_rerun_argument: example.location_rerun_argument,
           }
    diff --git a/lib/turbo_tests/progress_formatter.rb b/lib/turbo_tests/progress_formatter.rb
    new file mode 100644
    index 00000000000..210a8bd08d1
    --- /dev/null
    +++ b/lib/turbo_tests/progress_formatter.rb
    @@ -0,0 +1,50 @@
    +# frozen_string_literal: true
    +
    +module TurboTests
    +  class ProgressFormatter < ::TurboTests::BaseFormatter
    +    LINE_LENGTH = 80
    +
    +    RSpec::Core::Formatters.register(
    +      self,
    +      :example_passed,
    +      :example_pending,
    +      :example_failed,
    +      :start_dump,
    +    )
    +
    +    def initialize(*args)
    +      super
    +      @examples = 0
    +    end
    +
    +    def example_passed(_notification)
    +      output.print RSpec::Core::Formatters::ConsoleCodes.wrap(".", :success)
    +      wrap
    +    end
    +
    +    def example_pending(_notification)
    +      output.print RSpec::Core::Formatters::ConsoleCodes.wrap("*", :pending)
    +      wrap
    +    end
    +
    +    def example_failed(_notification)
    +      output.print RSpec::Core::Formatters::ConsoleCodes.wrap("F", :failure)
    +      wrap
    +    end
    +
    +    def start_dump(_notification)
    +      output.puts
    +    end
    +
    +    private
    +
    +    def wrap
    +      @examples += 1
    +
    +      if ENV["GITHUB_ACTIONS"] && @examples == LINE_LENGTH
    +        output.print "\n"
    +        @examples = 0
    +      end
    +    end
    +  end
    +end
    diff --git a/lib/turbo_tests/reporter.rb b/lib/turbo_tests/reporter.rb
    index 67c2a71bc30..068735020c6 100644
    --- a/lib/turbo_tests/reporter.rb
    +++ b/lib/turbo_tests/reporter.rb
    @@ -2,8 +2,8 @@
     
     module TurboTests
       class Reporter
    -    def self.from_config(formatter_config, start_time)
    -      reporter = new(start_time)
    +    def self.from_config(formatter_config, start_time, max_timings_count: nil)
    +      reporter = new(start_time:, max_timings_count:)
     
           formatter_config.each do |config|
             name, outputs = config.values_at(:name, :outputs)
    @@ -18,8 +18,9 @@ module TurboTests
     
         attr_reader :pending_examples
         attr_reader :failed_examples
    +    attr_reader :formatters
     
    -    def initialize(start_time)
    +    def initialize(start_time:, max_timings_count:)
           @formatters = []
           @pending_examples = []
           @failed_examples = []
    @@ -27,6 +28,8 @@ module TurboTests
           @start_time = start_time
           @messages = []
           @errors_outside_of_examples_count = 0
    +      @timings = []
    +      @max_timings_count = max_timings_count
         end
     
         def add(name, outputs)
    @@ -34,7 +37,9 @@ module TurboTests
             formatter_class =
               case name
               when "p", "progress"
    -            RSpec::Core::Formatters::ProgressFormatter
    +            TurboTests::ProgressFormatter
    +          when "d", "documentation"
    +            TurboTests::DocumentationFormatter
               else
                 Kernel.const_get(name)
               end
    @@ -47,6 +52,7 @@ module TurboTests
           delegate_to_formatters(:example_passed, example.notification)
     
           @all_examples << example
    +      log_timing(example)
         end
     
         def example_pending(example)
    @@ -54,6 +60,7 @@ module TurboTests
     
           @all_examples << example
           @pending_examples << example
    +      log_timing(example)
         end
     
         def example_failed(example)
    @@ -61,6 +68,7 @@ module TurboTests
     
           @all_examples << example
           @failed_examples << example
    +      log_timing(example)
         end
     
         def message(message)
    @@ -76,14 +84,17 @@ module TurboTests
           end_time = Time.now
     
           delegate_to_formatters(:start_dump, RSpec::Core::Notifications::NullNotification)
    +
           delegate_to_formatters(
             :dump_pending,
             RSpec::Core::Notifications::ExamplesNotification.new(self),
           )
    +
           delegate_to_formatters(
             :dump_failures,
             RSpec::Core::Notifications::ExamplesNotification.new(self),
           )
    +
           delegate_to_formatters(
             :dump_summary,
             RSpec::Core::Notifications::SummaryNotification.new(
    @@ -94,7 +105,9 @@ module TurboTests
               0,
               @errors_outside_of_examples_count,
             ),
    +        @timings,
           )
    +
           delegate_to_formatters(:close, RSpec::Core::Notifications::NullNotification)
         end
     
    @@ -105,5 +118,15 @@ module TurboTests
             formatter.send(method, *args) if formatter.respond_to?(method)
           end
         end
    +
    +    private
    +
    +    def log_timing(example)
    +      if run_duration_ms = example.metadata[:run_duration_ms]
    +        @timings << [example.full_description, example.location, run_duration_ms]
    +        @timings.sort_by! { |timing| -timing.last }
    +        @timings.pop if @timings.size > @max_timings_count
    +      end
    +    end
       end
     end
    diff --git a/lib/turbo_tests/runner.rb b/lib/turbo_tests/runner.rb
    index b81be543ed9..c404a87f377 100644
    --- a/lib/turbo_tests/runner.rb
    +++ b/lib/turbo_tests/runner.rb
    @@ -5,15 +5,47 @@ module TurboTests
         def self.run(opts = {})
           files = opts[:files]
           formatters = opts[:formatters]
    +      seed = opts[:seed]
           start_time = opts.fetch(:start_time) { Time.now }
           verbose = opts.fetch(:verbose, false)
           fail_fast = opts.fetch(:fail_fast, nil)
    +      use_runtime_info = opts.fetch(:use_runtime_info, false)
     
    -      STDERR.puts "VERBOSE" if verbose
    +      STDOUT.puts "VERBOSE" if verbose
     
    -      reporter = Reporter.from_config(formatters, start_time)
    +      reporter =
    +        Reporter.from_config(
    +          formatters,
    +          start_time,
    +          max_timings_count: opts[:profile_print_slowest_examples_count],
    +        )
     
    -      new(reporter: reporter, files: files, verbose: verbose, fail_fast: fail_fast).run
    +      if ENV["GITHUB_ACTIONS"]
    +        RSpec.configure do |config|
    +          # Enable color output in GitHub Actions
    +          # This eventually will be `config.color_mode = :on` in RSpec 4?
    +          config.tty = true
    +          config.color = true
    +        end
    +      end
    +
    +      new(
    +        reporter: reporter,
    +        files: files,
    +        verbose: verbose,
    +        fail_fast: fail_fast,
    +        use_runtime_info: use_runtime_info,
    +        seed: seed,
    +        profile: opts[:profile],
    +      ).run
    +    end
    +
    +    def self.default_spec_folders
    +      # We do not want to include system specs by default, they are quite slow.
    +      Dir
    +        .entries("#{Rails.root}/spec")
    +        .reject { |entry| !File.directory?("spec/#{entry}") || %w[.. . system].include?(entry) }
    +        .map { |entry| "spec/#{entry}" }
         end
     
         def initialize(opts)
    @@ -21,6 +53,9 @@ module TurboTests
           @files = opts[:files]
           @verbose = opts[:verbose]
           @fail_fast = opts[:fail_fast]
    +      @use_runtime_info = opts[:use_runtime_info]
    +      @seed = opts[:seed]
    +      @profile = opts[:profile]
           @failure_count = 0
     
           @messages = Queue.new
    @@ -32,22 +67,16 @@ module TurboTests
           check_for_migrations
     
           @num_processes = ParallelTests.determine_number_of_processes(nil)
    -      use_runtime_info = @files == ["spec"]
     
           group_opts = {}
    -
    -      if use_runtime_info
    -        group_opts[:runtime_log] = "tmp/turbo_rspec_runtime.log"
    -      else
    -        group_opts[:group_by] = :filesize
    -      end
    +      group_opts[:runtime_log] = "tmp/turbo_rspec_runtime.log" if @use_runtime_info
     
           tests_in_groups =
             ParallelTests::RSpec::Runner.tests_in_groups(@files, @num_processes, **group_opts)
     
           setup_tmp_dir
     
    -      subprocess_opts = { record_runtime: use_runtime_info }
    +      subprocess_opts = { record_runtime: @use_runtime_info }
     
           start_multisite_subprocess(@files, **subprocess_opts)
     
    @@ -136,8 +165,8 @@ module TurboTests
               "exec",
               "rspec",
               *extra_args,
    -          "--seed",
    -          rand(2**16).to_s,
    +          "--order",
    +          "random:#{@seed}",
               "--format",
               "TurboTests::JsonRowsFormatter",
               "--out",
    @@ -146,12 +175,14 @@ module TurboTests
               *tests,
             ]
     
    -        if @verbose
    -          command_str =
    -            [env.map { |k, v| "#{k}=#{v}" }.join(" "), command.join(" ")].select { |x| x.size > 0 }
    -              .join(" ")
    +        env["DISCOURSE_RSPEC_PROFILE_EACH_EXAMPLE"] = "1" if @profile
     
    -          STDERR.puts "Process #{process_id}: #{command_str}"
    +        if @verbose
    +          command_str = [env.map { |k, v| "#{k}=#{v}" }.join(" "), command.join(" ")].join(" ")
    +
    +          STDOUT.puts "::group::[#{process_id}] Run RSpec" if ENV["GITHUB_ACTIONS"]
    +          STDOUT.puts "Process #{process_id}: #{command_str}"
    +          STDOUT.puts "::endgroup::" if ENV["GITHUB_ACTIONS"]
             end
     
             stdin, stdout, stderr, wait_thr = Open3.popen3(env, *command)
    @@ -200,13 +231,13 @@ module TurboTests
               message = @messages.pop
               case message[:type]
               when "example_passed"
    -            example = FakeExample.from_obj(message[:example])
    +            example = FakeExample.from_obj(message[:example], message[:process_id])
                 @reporter.example_passed(example)
               when "example_pending"
    -            example = FakeExample.from_obj(message[:example])
    +            example = FakeExample.from_obj(message[:example], message[:process_id])
                 @reporter.example_pending(example)
               when "example_failed"
    -            example = FakeExample.from_obj(message[:example])
    +            example = FakeExample.from_obj(message[:example], message[:process_id])
                 @reporter.example_failed(example)
                 @failure_count += 1
                 if fail_fast_met
    @@ -222,6 +253,11 @@ module TurboTests
                 @error = true
               when "exit"
                 exited += 1
    +
    +            if @reporter.formatters.any? { |f| f.is_a?(DocumentationFormatter) }
    +              @reporter.message("[#{message[:process_id]}] DONE (#{exited}/#{@num_processes + 1})")
    +            end
    +
                 break if exited == @num_processes + 1
               else
                 STDERR.puts("Unhandled message in main process: #{message}")
    diff --git a/lib/twitter_api.rb b/lib/twitter_api.rb
    index c7f5126e1e9..533c9ec2d9a 100644
    --- a/lib/twitter_api.rb
    +++ b/lib/twitter_api.rb
    @@ -3,44 +3,48 @@
     # lightweight Twitter api calls
     class TwitterApi
       class << self
    -    include ActionView::Helpers::NumberHelper
    -
         BASE_URL = "https://api.twitter.com"
    +    URL_PARAMS = %w[
    +      tweet.fields=id,author_id,text,created_at,entities,referenced_tweets,public_metrics
    +      user.fields=id,name,username,profile_image_url
    +      media.fields=type,height,width,variants,preview_image_url,url
    +      expansions=attachments.media_keys,referenced_tweets.id.author_id
    +    ]
     
         def prettify_tweet(tweet)
    -      text = tweet["full_text"].dup
    -      if (entities = tweet["entities"]) && (urls = entities["urls"])
    +      text = tweet[:data][:text].dup.to_s
    +      if (entities = tweet[:data][:entities]) && (urls = entities[:urls])
             urls.each do |url|
    -          text.gsub!(
    -            url["url"],
    -            "#{url["display_url"]}",
    -          )
    +          if !url[:display_url].start_with?("pic.twitter.com")
    +            text.gsub!(
    +              url[:url],
    +              "#{url[:display_url]}",
    +            )
    +          else
    +            text.gsub!(url[:url], "")
    +          end
             end
           end
    -
           text = link_hashtags_in link_handles_in text
    -
           result = Rinku.auto_link(text, :all, 'target="_blank"').to_s
     
    -      if tweet["extended_entities"] && media = tweet["extended_entities"]["media"]
    +      if tweet[:includes] && media = tweet[:includes][:media]
             media.each do |m|
    -          if m["type"] == "photo"
    -            if large = m["sizes"]["large"]
    -              result << "
    " - end - elsif m["type"] == "video" || m["type"] == "animated_gif" + if m[:type] == "photo" + result << "
    " + elsif m[:type] == "video" || m[:type] == "animated_gif" video_to_display = - m["video_info"]["variants"] - .select { |v| v["content_type"] == "video/mp4" } - .sort { |v| v["bitrate"] } + m[:variants] + .select { |v| v[:content_type] == "video/mp4" } + .sort { |v| v[:bit_rate] } .last # choose highest bitrate - if video_to_display && url = video_to_display["url"] - width = m["sizes"]["large"]["w"] - height = m["sizes"]["large"]["h"] + if video_to_display && url = video_to_display[:url] + width = m[:width] + height = m[:height] attributes = - if m["type"] == "animated_gif" + if m[:type] == "animated_gif" %w[playsinline loop muted autoplay disableRemotePlayback disablePictureInPicture] else %w[controls playsinline] @@ -52,7 +56,7 @@ class TwitterApi
    @@ -66,19 +70,6 @@ class TwitterApi result end - def prettify_number(count) - number_to_human( - count, - format: "%n%u", - precision: 2, - units: { - thousand: "K", - million: "M", - billion: "B", - }, - ) - end - def tweet_for(id) JSON.parse(twitter_get(tweet_uri_for(id))) end @@ -111,7 +102,7 @@ class TwitterApi end def tweet_uri_for(id) - URI.parse "#{BASE_URL}/1.1/statuses/show.json?id=#{id}&tweet_mode=extended" + URI.parse "#{BASE_URL}/2/tweets/#{id}?#{URL_PARAMS.join("&")}" end def twitter_get(uri) diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index f37e6750309..54615a1101f 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -31,9 +31,6 @@ class UploadCreator use ].each(&:freeze) - include ActiveSupport::Deprecation::DeprecatedConstantAccessor - deprecate_constant "WHITELISTED_SVG_ELEMENTS", "UploadCreator::ALLOWED_SVG_ELEMENTS" - # Available options # - type (string) # - origin (string) @@ -57,9 +54,6 @@ class UploadCreator true end ) - - # TODO (martin) Validate @opts[:type] to make sure only blessed types are passed - # in, since the clientside can pass any type it wants. end def create_for(user_id) @@ -77,15 +71,16 @@ class UploadCreator is_image = FileHelper.is_supported_image?(@filename) is_image ||= @image_info && FileHelper.is_supported_image?("test.#{@image_info.type}") is_image = false if @opts[:for_theme] + is_thumbnail = SiteSetting.video_thumbnails_enabled && @opts[:type] == "thumbnail" - # if this is present then it means we are creating an upload record from + # If this is present then it means we are creating an upload record from # an external_upload_stub and the file is > ExternalUploadManager::DOWNLOAD_LIMIT, # so we have not downloaded it to a tempfile. no modifications can be made to the # file in this case because it does not exist; we simply move it to its new location # in S3 # - # TODO (martin) I've added a bunch of external_upload_too_big checks littered - # throughout the UploadCreator code. It would be better to have two seperate + # FIXME: I've added a bunch of external_upload_too_big checks littered + # throughout the UploadCreator code. It would be better to have two separate # classes with shared methods, rather than doing all these checks all over the # place. Needs a refactor. external_upload_too_big = @opts[:external_upload_too_big] @@ -122,13 +117,17 @@ class UploadCreator # compute the sha of the file and generate a unique hash # which is only used for secure uploads sha1 = Upload.generate_digest(@file) if !external_upload_too_big - unique_hash = generate_fake_sha1_hash if SiteSetting.secure_uploads || external_upload_too_big + unique_hash = generate_fake_sha1_hash if SiteSetting.secure_uploads || + external_upload_too_big || is_thumbnail # we do not check for duplicate uploads if secure uploads is # enabled because we use a unique access hash to differentiate # between uploads instead of the sha1, and to get around various # access/permission issues for uploads - if !SiteSetting.secure_uploads && !external_upload_too_big + # We do not check for duplicate uploads for video thumbnails because + # their filename needs to match with their corresponding video. This also + # enables rebuilding the html on a topic to regenerate a thumbnail. + if !SiteSetting.secure_uploads && !external_upload_too_big && !is_thumbnail # do we already have that upload? @upload = Upload.find_by(sha1: sha1) @@ -166,7 +165,14 @@ class UploadCreator @upload.user_id = user_id @upload.original_filename = fixed_original_filename || @filename @upload.filesize = filesize - @upload.sha1 = (SiteSetting.secure_uploads? || external_upload_too_big) ? unique_hash : sha1 + @upload.sha1 = + ( + if (SiteSetting.secure_uploads? || external_upload_too_big || is_thumbnail) + unique_hash + else + sha1 + end + ) @upload.original_sha1 = SiteSetting.secure_uploads? ? sha1 : nil @upload.url = "" @upload.origin = @opts[:origin][0...1000] if @opts[:origin] @@ -389,7 +395,7 @@ class UploadCreator end def convert_heif_to_jpeg? - File.extname(@filename).downcase.match?(/\.hei(f|c)$/) + File.extname(@filename).downcase.match?(/\.hei(f|c)\z/) end def convert_heif! @@ -597,7 +603,7 @@ class UploadCreator def should_optimize? # GIF is too slow (plus, we'll soon be converting them to MP4) # Optimizing SVG is useless - return false if @file.path =~ /\.(gif|svg)$/i + return false if @file.path =~ /\.(gif|svg)\z/i # Safeguard for large PNGs return pixels < 2_000_000 if @file.path =~ /\.png/i # Everything else is fine! @@ -653,7 +659,7 @@ class UploadCreator if is_animated != nil # FastImage will return nil if it cannot determine if animated is_animated - elsif type == "gif" || type == "webp" + elsif %w[gif webp avif].include?(type) # Only GIFs, WEBPs and a few other unsupported image types can be animated OptimizedImage.ensure_safe_paths!(@file.path) diff --git a/lib/upload_recovery.rb b/lib/upload_recovery.rb index c2e340d9693..5ac0e556a2b 100644 --- a/lib/upload_recovery.rb +++ b/lib/upload_recovery.rb @@ -150,7 +150,13 @@ class UploadRecovery old_key = key key = key.sub(tombstone_prefix, "") - Discourse.store.s3_helper.copy(old_key, key, options: { acl: "public-read" }) + Discourse.store.s3_helper.copy( + old_key, + key, + options: { + acl: SiteSetting.s3_use_acls ? "public-read" : nil, + }, + ) end next if upload_exists diff --git a/lib/upload_security.rb b/lib/upload_security.rb index c00c1376ecf..ecdc3c43b40 100644 --- a/lib/upload_security.rb +++ b/lib/upload_security.rb @@ -143,7 +143,7 @@ class UploadSecurity LEFT JOIN posts ON upload_references.target_type = 'Post' AND upload_references.target_id = posts.id SQL .where("posts.deleted_at IS NULL") - .order(created_at: :asc) + .order("upload_references.created_at ASC, upload_references.id ASC") .first return false if first_reference.blank? PUBLIC_UPLOAD_REFERENCE_TYPES.include?(first_reference.target_type) diff --git a/lib/url_helper.rb b/lib/url_helper.rb index 12fc5cbb3fb..432464798b2 100644 --- a/lib/url_helper.rb +++ b/lib/url_helper.rb @@ -50,8 +50,8 @@ class UrlHelper end def self.absolute(url, cdn = Discourse.asset_host) - cdn = "https:#{cdn}" if cdn && cdn =~ %r{^//} - url =~ %r{^/[^/]} ? (cdn || Discourse.base_url_no_prefix) + url : url + cdn = "https:#{cdn}" if cdn && cdn =~ %r{\A//} + url =~ %r{\A/[^/]} ? (cdn || Discourse.base_url_no_prefix) + url : url end def self.absolute_without_cdn(url) @@ -59,7 +59,7 @@ class UrlHelper end def self.schemaless(url) - url.sub(/^http:/i, "") + url.sub(/\Ahttp:/i, "") end def self.secure_proxy_without_cdn(url) diff --git a/lib/user_comm_screener.rb b/lib/user_comm_screener.rb index 6593606dc8a..c1a451bf27a 100644 --- a/lib/user_comm_screener.rb +++ b/lib/user_comm_screener.rb @@ -180,6 +180,10 @@ class UserCommScreener actor_preferences[:disallowed_pms_from].include?(user_id) end + def actor_disallowing_any_pms?(user_ids) + user_ids.any? { |user_id| actor_disallowing_pms?(user_id) } + end + def actor_disallowing_all_pms? !acting_user.user_option.allow_private_messages end diff --git a/lib/validators/css_color_validator.rb b/lib/validators/css_color_validator.rb index fdc1fe8f28a..6085ab49aa3 100644 --- a/lib/validators/css_color_validator.rb +++ b/lib/validators/css_color_validator.rb @@ -156,7 +156,7 @@ class CssColorValidator end def valid_value?(val) - !!(val =~ /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ || COLORS.include?(val&.downcase)) + !!(val =~ /\A#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ || COLORS.include?(val&.downcase)) end def error_message diff --git a/lib/validators/enable_invite_only_validator.rb b/lib/validators/enable_invite_only_validator.rb deleted file mode 100644 index 89e062ff162..00000000000 --- a/lib/validators/enable_invite_only_validator.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class EnableInviteOnlyValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - return true if val == "f" - !SiteSetting.enable_discourse_connect? - end - - def error_message - I18n.t("site_settings.errors.discourse_connect_invite_only") - end -end diff --git a/lib/validators/enable_sso_validator.rb b/lib/validators/enable_sso_validator.rb index 3d75391f53b..84281afd799 100644 --- a/lib/validators/enable_sso_validator.rb +++ b/lib/validators/enable_sso_validator.rb @@ -7,9 +7,7 @@ class EnableSsoValidator def valid_value?(val) return true if val == "f" - if SiteSetting.discourse_connect_url.blank? || SiteSetting.invite_only? || is_2fa_enforced? - return false - end + return false if SiteSetting.discourse_connect_url.blank? || is_2fa_enforced? true end @@ -17,7 +15,7 @@ class EnableSsoValidator if SiteSetting.discourse_connect_url.blank? return I18n.t("site_settings.errors.discourse_connect_url_is_empty") end - return I18n.t("site_settings.errors.discourse_connect_invite_only") if SiteSetting.invite_only? + if is_2fa_enforced? I18n.t("site_settings.errors.discourse_connect_cannot_be_enabled_if_second_factor_enforced") end diff --git a/lib/validators/form_template_yaml_validator.rb b/lib/validators/form_template_yaml_validator.rb new file mode 100644 index 00000000000..9a0fdfd7d4d --- /dev/null +++ b/lib/validators/form_template_yaml_validator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class FormTemplateYamlValidator < ActiveModel::Validator + def validate(record) + begin + yaml = Psych.safe_load(record.template) + check_missing_type(record, yaml) + check_allowed_types(record, yaml) + rescue Psych::SyntaxError + record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml")) + end + end + + def check_allowed_types(record, yaml) + allowed_types = %w[checkbox dropdown input multi-select textarea upload] + yaml.each do |field| + if !allowed_types.include?(field["type"]) + return( + record.errors.add( + :template, + I18n.t( + "form_templates.errors.invalid_type", + type: field["type"], + valid_types: allowed_types.join(", "), + ), + ) + ) + end + end + end + + def check_missing_type(record, yaml) + yaml.each do |field| + if field["type"].blank? + return record.errors.add(:template, I18n.t("form_templates.errors.missing_type")) + end + end + end +end diff --git a/lib/validators/max_username_length_validator.rb b/lib/validators/max_username_length_validator.rb index fe29824f834..49bcc1b400c 100644 --- a/lib/validators/max_username_length_validator.rb +++ b/lib/validators/max_username_length_validator.rb @@ -13,7 +13,7 @@ class MaxUsernameLengthValidator return false end return false if value < SiteSetting.min_username_length - @username = User.where("length(username) > ?", value).pluck_first(:username) + @username = User.where("length(username) > ?", value).pick(:username) @username.blank? end diff --git a/lib/validators/min_username_length_validator.rb b/lib/validators/min_username_length_validator.rb index 1ae9342fef1..ee0af94b698 100644 --- a/lib/validators/min_username_length_validator.rb +++ b/lib/validators/min_username_length_validator.rb @@ -13,7 +13,7 @@ class MinUsernameLengthValidator return false end return false if value > SiteSetting.max_username_length - @username = User.where("length(username) < ?", value).pluck_first(:username) + @username = User.where("length(username) < ?", value).pick(:username) @username.blank? end diff --git a/lib/validators/search_ranking_weights_validator.rb b/lib/validators/search_ranking_weights_validator.rb new file mode 100644 index 00000000000..58e7c25c221 --- /dev/null +++ b/lib/validators/search_ranking_weights_validator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SearchRankingWeightsValidator + def initialize(opts = {}) + @opts = opts + end + + WEIGHT_REGEXP = "1\.0|0\.[0-9]+" + WEIGHTS_REGEXP = + /{(?#{WEIGHT_REGEXP}),(?#{WEIGHT_REGEXP}),(?#{WEIGHT_REGEXP}),(?#{WEIGHT_REGEXP})}/ + + def valid_value?(value) + return true if value.blank? + value.match(WEIGHTS_REGEXP) + end + + def error_message + I18n.t("site_settings.errors.invalid_search_ranking_weights") + end +end diff --git a/lib/validators/summarization_validator.rb b/lib/validators/summarization_validator.rb new file mode 100644 index 00000000000..8cb35fbb39f --- /dev/null +++ b/lib/validators/summarization_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class SummarizationValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + strategy = Summarization::Base.find_strategy(val) + + return true unless strategy + + strategy.correctly_configured?.tap { |is_valid| @strategy = strategy unless is_valid } + end + + def error_message + @strategy.configuration_hint + end +end diff --git a/lib/validators/unicode_username_allowlist_validator.rb b/lib/validators/unicode_username_allowlist_validator.rb index 824e5e43442..f60c161707c 100644 --- a/lib/validators/unicode_username_allowlist_validator.rb +++ b/lib/validators/unicode_username_allowlist_validator.rb @@ -9,7 +9,7 @@ class UnicodeUsernameAllowlistValidator @error_message = nil return true if value.blank? - if value.match?(%r{^/.*/[imxo]*$}) + if value.match?(%r{\A/.*/[imxo]*\z}) @error_message = I18n.t("site_settings.errors.allowed_unicode_usernames.leading_trailing_slash") else diff --git a/lib/version.rb b/lib/version.rb index 06dc63c2e39..c32cadfccff 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -7,12 +7,16 @@ module Discourse # work around reloader unless defined?(::Discourse::VERSION) module VERSION #:nodoc: - MAJOR = 3 - MINOR = 0 - TINY = 6 - PRE = nil + STRING = "3.1.0.beta8" - STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + PARTS = STRING.split(".") + private_constant :PARTS + + MAJOR = PARTS[0].to_i + MINOR = PARTS[1].to_i + TINY = PARTS[2].to_i + PRE = PARTS[3]&.split("-", 2)&.first + DEV = PARTS[3]&.split("-", 2)&.second end end diff --git a/lib/wizard/builder.rb b/lib/wizard/builder.rb index ebfef200260..340c750f388 100644 --- a/lib/wizard/builder.rb +++ b/lib/wizard/builder.rb @@ -86,7 +86,7 @@ class Wizard step.add_field( id: "chat_enabled", type: "checkbox", - icon: "comment", + icon: "d-chat", value: SiteSetting.chat_enabled, ) end diff --git a/package.json b/package.json index d39655d49c4..6a83a93e430 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,61 @@ { "name": "discourse", "version": "1.0.0", - "main": "index.js", - "repository": "git@github.com:discourse/discourse.git", + "repository": "https://github.com/discourse/discourse", "author": "Discourse", "license": "GPL-2.0-only", - "dependencies": { + "devDependencies": { + "@glint/core": "^1.0.2", + "@glint/environment-ember-loose": "^1.0.2", + "@glint/environment-ember-template-imports": "^1.0.2", + "@glint/template": "^1.0.2", "@discourse/moment-timezone-names-translations": "^1.0.0", "@fortawesome/fontawesome-free": "5.15.4", - "@highlightjs/cdn-assets": "^11.6.0", - "@json-editor/json-editor": "^2.6.1", + "@highlightjs/cdn-assets": "11.8.0", + "@json-editor/json-editor": "2.10.0", + "@mixer/parallel-prettier": "^2.0.3", "ace-builds": "1.4.13", "chart.js": "3.5.1", - "chartjs-plugin-datalabels": "^2.0.0", - "diffhtml": "^1.0.0-beta.20", - "magnific-popup": "1.1.0", - "moment": "2.29.4", - "moment-timezone": "0.5.39", - "pikaday": "1.8.2", - "squoosh": "discourse/squoosh#dc9649d", - "workbox-cacheable-response": "^4.3.1", - "workbox-core": "^4.3.1", - "workbox-expiration": "^4.3.1", - "workbox-routing": "^4.3.1", - "workbox-strategies": "^4.3.1", - "workbox-sw": "^4.3.1" - }, - "devDependencies": { - "@mixer/parallel-prettier": "^2.0.3", + "chartjs-plugin-datalabels": "2.2.0", "chrome-launcher": "^0.15.1", "chrome-remote-interface": "^0.31.3", - "eslint-config-discourse": "^3.3.0", + "concurrently": "^8.0.1", + "diffhtml": "1.0.0-beta.20", + "ember-template-lint": "5.10.3", + "eslint": "^8.37.0", + "eslint-config-discourse": "^3.5.0", + "eslint-plugin-ember": "11.8.0", + "eslint-plugin-sort-class-members": "1.18.0", + "jsdoc": "^4.0.0", "lefthook": "^1.2.0", - "puppeteer-core": "^13.7.0" + "lint-to-the-future": "^2.0.0", + "lint-to-the-future-ember-template": "^1.1.1", + "lint-to-the-future-eslint": "^2.0.1", + "magnific-popup": "1.1.0", + "moment": "2.29.4", + "moment-timezone": "0.5.43", + "prettier-plugin-ember-template-tag": "^0.3.2", + "pikaday": "1.8.2", + "puppeteer-core": "^13.7.0", + "squoosh": "discourse/squoosh#dc9649d", + "tidy-jsdoc": "^1.4.1", + "typescript": "^5.1.3" }, "scripts": { - "postinstall": "yarn --cwd app/assets/javascripts/discourse $(node -e 'if(JSON.parse(process.env.npm_config_argv).original.includes(`--frozen-lockfile`)){console.log(`--frozen-lockfile`)}')" + "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"", + "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"", + "lint:js": "eslint ./app/assets/javascripts --cache", + "lint:js-plugins": "eslint ./plugins --cache", + "lint:js:fix": "eslint ./app/assets/javascripts", + "lint:js-plugins:fix": "eslint ./plugins", + "lint:hbs": "ember-template-lint 'app/assets/javascripts/**/*.{gjs,hbs}' 'plugins/**/assets/javascripts/**/*.{gjs,hbs}' --no-error-on-unmatched-pattern", + "lint:hbs:fix": "ember-template-lint 'app/assets/javascripts/**/*.{gjs,hbs}' 'plugins/**/assets/javascripts/**/*.{gjs,hbs}' --no-error-on-unmatched-pattern --fix", + "lint:prettier": "yarn pprettier --list-different 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs}' 'plugins/**/assets/stylesheets/**/*.scss' 'plugins/**/assets/javascripts/**/*.{js,gjs,hbs}'", + "lint:prettier:fix": "yarn prettier -w 'app/assets/stylesheets/**/*.scss' 'app/assets/javascripts/**/*.{js,gjs,hbs}' 'plugins/**/assets/stylesheets/**/*.scss' 'plugins/**/assets/javascripts/**/*.{js,gjs,hbs}'", + "lttf:ignore": "lint-to-the-future ignore", + "lttf:output": "lint-to-the-future output -o ./lint-progress/", + "lint-progress": "yarn lttf:output && npx html-pages ./lint-progress --no-cache", + "postinstall": "yarn --cwd app/assets/javascripts $(node -e 'if(JSON.parse(process.env.npm_config_argv).original.includes(`--frozen-lockfile`)){console.log(`--frozen-lockfile`)}')" }, "engines": { "node": "16.* || >= 18", diff --git a/plugins/chat/README.md b/plugins/chat/README.md index fc0b204240b..a3d205fb72f 100644 --- a/plugins/chat/README.md +++ b/plugins/chat/README.md @@ -1,54 +1,9 @@ -:warning: This plugin is still in active development and may change frequently +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) +For user 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"]` +For developer documentation, see [Discourse Documentation](https://discourse.github.io/discourse/). 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 deleted file mode 100644 index 24bcd25abda..00000000000 --- a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,60 +0,0 @@ -# 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/chat_channels_controller.rb b/plugins/chat/app/controllers/api/chat_channels_controller.rb deleted file mode 100644 index 69aaaadbb02..00000000000 --- a/plugins/chat/app/controllers/api/chat_channels_controller.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -CHANNEL_EDITABLE_PARAMS = %i[name description] -CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions] - -class Chat::Api::ChatChannelsController < Chat::Api - def index - permitted = params.permit(:filter, :limit, :offset, :status) - - options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i } - options[:offset] = permitted[:offset].to_i - options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil - - 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 - - load_more_params = options.merge(offset: options[:offset] + options[:limit]).to_query - load_more_url = URI::HTTP.build(path: "/chat/api/channels", query: load_more_params).request_uri - - render json: serialized_channels, root: "channels", meta: { load_more_url: load_more_url } - end - - def destroy - confirmation = params.require(:channel).require(:name_confirmation)&.downcase - guardian.ensure_can_delete_chat_channel! - - if channel_from_params.title(current_user).downcase != confirmation - raise Discourse::InvalidParameters.new(:name_confirmation) - end - - begin - ChatChannel.transaction do - channel_from_params.update!( - slug: - "#{Time.now.strftime("%Y%m%d-%H%M")}-#{channel_from_params.slug}-deleted".truncate( - SiteSetting.max_topic_title_length, - omission: "", - ), - ) - channel_from_params.trash!(current_user) - StaffActionLogger.new(current_user).log_custom( - "chat_channel_delete", - { - chat_channel_id: channel_from_params.id, - chat_channel_name: channel_from_params.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: channel_from_params.id }) - render json: success_json - end - - def create - channel_params = - params.require(:channel).permit(:chatable_id, :name, :description, :auto_join_users) - - guardian.ensure_can_create_chat_channel! - if channel_params[:name].length > SiteSetting.max_topic_title_length - raise Discourse::InvalidParameters.new(:name) - end - - if ChatChannel.exists?( - chatable_type: "Category", - chatable_id: channel_params[:chatable_id], - name: channel_params[:name], - ) - raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category")) - end - - chatable = Category.find_by(id: channel_params[:chatable_id]) - raise Discourse::NotFound unless chatable - - auto_join_users = - ActiveRecord::Type::Boolean.new.deserialize(channel_params[:auto_join_users]) || false - - channel = - chatable.create_chat_channel!( - name: channel_params[:name], - description: channel_params[:description], - user_count: 1, - auto_join_users: auto_join_users, - ) - - channel.user_chat_channel_memberships.create!(user: current_user, following: true) - - if channel.auto_join_users - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships - end - - render_serialized( - channel, - ChatChannelSerializer, - membership: channel.membership_for(current_user), - root: "channel", - ) - end - - def show - render_serialized( - channel_from_params, - ChatChannelSerializer, - membership: channel_from_params.membership_for(current_user), - root: "channel", - ) - end - - def update - guardian.ensure_can_edit_chat_channel! - - if channel_from_params.direct_message_channel? - raise Discourse::InvalidParameters.new( - I18n.t("chat.errors.cant_update_direct_message_channel"), - ) - end - - params_to_edit = editable_params(params, channel_from_params) - 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(channel_from_params).performed! - end - - channel_from_params.update!(params_to_edit) - - ChatPublisher.publish_chat_channel_edit(channel_from_params, current_user) - - if channel_from_params.category_channel? && channel_from_params.auto_join_users - Chat::ChatChannelMembershipManager.new( - channel_from_params, - ).enforce_automatic_channel_memberships - end - - render_serialized( - channel_from_params, - ChatChannelSerializer, - root: "channel", - membership: channel_from_params.membership_for(current_user), - ) - end - - private - - def channel_from_params - @channel ||= - begin - channel = ChatChannel.find(params.require(:channel_id)) - guardian.ensure_can_preview_chat_channel!(channel) - channel - end - end - - def membership_from_params - @membership ||= - begin - membership = - Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user) - raise Discourse::NotFound if membership.blank? - membership - end - end - - def auto_join_limiter(channel) - RateLimiter.new( - current_user, - "auto_join_users_channel_#{channel.id}", - 1, - 3.minutes, - apply_limit_to_staff: true, - ) - end - - def editable_params(params, channel) - permitted_params = CHANNEL_EDITABLE_PARAMS - permitted_params += CATEGORY_CHANNEL_EDITABLE_PARAMS if channel.category_channel? - params.require(:channel).permit(*permitted_params) - end -end diff --git a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb b/plugins/chat/app/controllers/api/chat_channels_status_controller.rb deleted file mode 100644 index 78b1ac3f2cb..00000000000 --- a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController - def update - 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?(status) || status == "read_only" || status == "archive" - raise Discourse::InvalidParameters - end - - guardian.ensure_can_change_channel_status!(channel_from_params, status.to_sym) - channel_from_params.public_send("#{status}!", current_user) - - render_serialized(channel_from_params, ChatChannelSerializer, root: "channel") - end -end diff --git a/plugins/chat/app/controllers/api/chat_chatables_controller.rb b/plugins/chat/app/controllers/api/chat_chatables_controller.rb deleted file mode 100644 index 9eaec32b89b..00000000000 --- a/plugins/chat/app/controllers/api/chat_chatables_controller.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatChatablesController < Chat::Api - def index - 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 - # FIXME: investigate the cost of this query - 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 -end diff --git a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb b/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb deleted file mode 100644 index ecc01163606..00000000000 --- a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatCurrentUserChannelsController < Chat::Api - def index - structured = Chat::ChatChannelFetcher.structured(guardian) - render_serialized(structured, ChatChannelIndexSerializer, root: false) - end -end diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb deleted file mode 100644 index fa27b825d83..00000000000 --- a/plugins/chat/app/controllers/api_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -# 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! - end -end diff --git a/plugins/chat/app/controllers/chat/admin/export_controller.rb b/plugins/chat/app/controllers/chat/admin/export_controller.rb new file mode 100644 index 00000000000..7872e46f61b --- /dev/null +++ b/plugins/chat/app/controllers/chat/admin/export_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + module Admin + class ExportController < ::Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def export_messages + entity = "chat_message" + Jobs.enqueue(:export_csv_file, entity: entity, user_id: current_user.id) + StaffActionLogger.new(current_user).log_entity_export(entity) + end + end + end +end diff --git a/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..14932e9212f --- /dev/null +++ b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + module Admin + class IncomingWebhooksController < ::Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def index + render_serialized( + { + chat_channels: Chat::Channel.public_channels, + incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all, + }, + Chat::AdminChatIndexSerializer, + root: false, + ) + end + + def create + params.require(%i[name chat_channel_id]) + + chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel) + if webhook.save + render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false) + else + render_json_error(webhook) + end + end + + def update + params.require(%i[incoming_chat_webhook_id name chat_channel_id]) + + webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + raise Discourse::NotFound unless webhook + + chat_channel = Chat::Channel.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 = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + webhook.destroy if webhook + render json: success_json + end + end + end +end diff --git a/plugins/chat/app/controllers/api/category_chatables_controller.rb b/plugins/chat/app/controllers/chat/api/category_chatables_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/category_chatables_controller.rb rename to plugins/chat/app/controllers/chat/api/category_chatables_controller.rb diff --git a/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb new file mode 100644 index 00000000000..a68aa2d3967 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelMessagesController < Chat::ApiController + def index + with_service(::Chat::ListChannelMessages) do + on_success { render_serialized(result, ::Chat::MessagesSerializer, root: false) } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:target_message_exists) { raise Discourse::NotFound } + on_model_not_found(:channel) { raise Discourse::NotFound } + end + end + + def destroy + with_service(Chat::TrashMessage) { on_model_not_found(:message) { raise Discourse::NotFound } } + end + + def restore + with_service(Chat::RestoreMessage) do + on_model_not_found(:message) { raise Discourse::NotFound } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb b/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb new file mode 100644 index 00000000000..324069d9f8c --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channel_thread_messages_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelThreadMessagesController < Chat::ApiController + def index + with_service(::Chat::ListChannelThreadMessages) do + on_success do + render_serialized( + result, + ::Chat::MessagesSerializer, + root: false, + include_thread_preview: false, + include_thread_original_message: false, + ) + end + + on_failed_policy(:ensure_thread_enabled) { raise Discourse::NotFound } + on_failed_policy(:target_message_exists) { raise Discourse::NotFound } + on_failed_policy(:can_view_thread) { raise Discourse::InvalidAccess } + on_model_not_found(:thread) { raise Discourse::NotFound } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb new file mode 100644 index 00000000000..fd16e5f0cd0 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelThreadsController < Chat::ApiController + def index + with_service(::Chat::LookupChannelThreads) do + on_success do + render_serialized( + ::Chat::ThreadsView.new( + user: guardian.user, + threads: result.threads, + channel: result.channel, + tracking: result.tracking, + memberships: result.memberships, + load_more_url: result.load_more_url, + ), + ::Chat::ThreadListSerializer, + root: false, + ) + end + on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_model_not_found(:channel) { raise Discourse::NotFound } + on_model_not_found(:threads) { render json: success_json.merge(threads: []) } + end + end + + def show + with_service(::Chat::LookupThread) do + on_success do + render_serialized( + result.thread, + ::Chat::ThreadSerializer, + root: "thread", + membership: result.membership, + include_thread_preview: true, + include_thread_original_message: true, + participants: result.participants, + ) + end + on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_model_not_found(:thread) { raise Discourse::NotFound } + end + end + + def update + with_service(::Chat::UpdateThread) do + on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:can_edit_thread) { raise Discourse::InvalidAccess } + on_model_not_found(:thread) { raise Discourse::NotFound } + on_failed_step(:update) do + render json: failed_json.merge(errors: [result["result.step.update"].error]), status: 422 + end + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/channel_threads_current_user_notifications_settings_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_current_user_notifications_settings_controller.rb new file mode 100644 index 00000000000..e54d5665450 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channel_threads_current_user_notifications_settings_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelThreadsCurrentUserNotificationsSettingsController < Chat::ApiController + def update + with_service(Chat::UpdateThreadNotificationSettings) do + on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } + on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess } + on_model_not_found(:thread) { raise Discourse::NotFound } + on_success do + render_serialized( + result.membership, + Chat::BaseThreadMembershipSerializer, + root: "membership", + ) + end + end + end +end diff --git a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_archives_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_archives_controller.rb index ca5640e9925..51ac0be0fdb 100644 --- a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController def create existing_archive = channel_from_params.chat_channel_archive if existing_archive.present? guardian.ensure_can_change_channel_status!(channel_from_params, :archived) raise Discourse::InvalidAccess if !existing_archive.failed? - Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) + Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) return render json: success_json end @@ -20,12 +20,12 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl end begin - Chat::ChatChannelArchiveService.create_archive_process( + Chat::ChannelArchiveService.create_archive_process( chat_channel: channel_from_params, acting_user: current_user, topic_params: topic_params, ) - rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err + rescue Chat::ChannelArchiveService::ArchiveValidationError => err return render json: failed_json.merge(errors: err.errors), status: 400 end diff --git a/plugins/chat/app/controllers/chat/api/channels_controller.rb b/plugins/chat/app/controllers/chat/api/channels_controller.rb new file mode 100644 index 00000000000..b69b33ff33c --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_controller.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +CHANNEL_EDITABLE_PARAMS ||= %i[name description slug] +CATEGORY_CHANNEL_EDITABLE_PARAMS ||= %i[ + auto_join_users + allow_channel_wide_mentions + threading_enabled +] + +class Chat::Api::ChannelsController < Chat::ApiController + def index + permitted = params.permit(:filter, :limit, :offset, :status) + + options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i } + options[:offset] = permitted[:offset].to_i + options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil + + memberships = Chat::ChannelMembershipManager.all_for_user(current_user) + channels = Chat::ChannelFetcher.secured_public_channels(guardian, options) + serialized_channels = + channels.map do |channel| + Chat::ChannelSerializer.new( + channel, + scope: Guardian.new(current_user), + membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, + ) + end + + load_more_params = options.merge(offset: options[:offset] + options[:limit]).to_query + load_more_url = URI::HTTP.build(path: "/chat/api/channels", query: load_more_params).request_uri + + render json: serialized_channels, root: "channels", meta: { load_more_url: load_more_url } + end + + def destroy + with_service Chat::TrashChannel do + on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } + end + end + + def create + channel_params = + params.require(:channel).permit( + :chatable_id, + :name, + :slug, + :description, + :auto_join_users, + :threading_enabled, + ) + + # NOTE: We don't allow creating channels for anything but category chatable types + # at the moment. This may change in future, at which point we will need to pass in + # a chatable_type param as well and switch to the correct service here. + with_service( + Chat::CreateCategoryChannel, + **channel_params.merge(category_id: channel_params[:chatable_id]), + ) do + on_success do + render_serialized( + result.channel, + Chat::ChannelSerializer, + root: "channel", + membership: result.membership, + ) + end + on_model_not_found(:category) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:can_create_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:category_channel_does_not_exist) do + raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category")) + end + on_model_errors(:channel) do + render_json_error(result.channel, type: :record_invalid, status: 422) + end + on_model_errors(:membership) do + render_json_error(result.membership, type: :record_invalid, status: 422) + end + end + end + + def show + render_serialized( + channel_from_params, + Chat::ChannelSerializer, + membership: channel_from_params.membership_for(current_user), + root: "channel", + include_extra_info: true, + ) + end + + def update + params_to_edit = editable_params(params, channel_from_params) + 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(channel_from_params).performed! + end + + with_service(Chat::UpdateChannel, **params_to_edit) do + on_success do + render_serialized( + result.channel, + Chat::ChannelSerializer, + root: "channel", + membership: result.channel.membership_for(current_user), + ) + end + on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } + on_failed_policy(:no_direct_message_channel) { raise Discourse::InvalidAccess } + end + end + + private + + def channel_from_params + @channel ||= + begin + channel = Chat::Channel.find(params.require(:channel_id)) + guardian.ensure_can_join_chat_channel!(channel) + channel + end + end + + def membership_from_params + @membership ||= + begin + membership = + Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user) + raise Discourse::NotFound if membership.blank? + membership + end + end + + def auto_join_limiter(channel) + RateLimiter.new( + current_user, + "auto_join_users_channel_#{channel.id}", + 1, + 3.minutes, + apply_limit_to_staff: true, + ) + end + + def editable_params(params, channel) + permitted_params = CHANNEL_EDITABLE_PARAMS + permitted_params += CATEGORY_CHANNEL_EDITABLE_PARAMS if channel.category_channel? + params.require(:channel).permit(*permitted_params) + end +end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb similarity index 65% rename from plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb index 91422f9d673..5f1e4b2af14 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController def create guardian.ensure_can_join_chat_channel!(channel_from_params) render_serialized( channel_from_params.add(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end @@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh def destroy render_serialized( channel_from_params.remove(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb similarity index 71% rename from plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb index d9a8f4ac57e..6c39585d3ec 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb @@ -2,13 +2,13 @@ MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] -class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController def update settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS) membership_from_params.update!(settings_params.to_h) render_serialized( membership_from_params, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb similarity index 76% rename from plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb index 75251a930fb..d35d63df59c 100644 --- a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController INDEX_LIMIT = 50 def index @@ -10,8 +10,8 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont limit = fetch_limit_from_params(default: INDEX_LIMIT, max: INDEX_LIMIT) memberships = - ChatChannelMembershipsQuery.call( - channel_from_params, + Chat::ChannelMembershipsQuery.call( + channel: channel_from_params, offset: offset, limit: limit, username: params[:username], @@ -19,7 +19,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont render_serialized( memberships, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "memberships", meta: { total_rows: channel_from_params.user_count, diff --git a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb index d0d3ff1777f..100b9330492 100644 --- a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController def create move_params = params.require(:move) move_params.require(:message_ids) @@ -8,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params) destination_channel = - Chat::ChatChannelFetcher.find_with_access_check( - move_params[:destination_channel_id], - guardian, - ) + Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian) begin message_ids = move_params[:message_ids].map(&:to_i) diff --git a/plugins/chat/app/controllers/chat/api/channels_status_controller.rb b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb new file mode 100644 index 00000000000..38e83f23104 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController + def update + with_service(Chat::UpdateChannelStatus) do + on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") } + on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/chatables_controller.rb b/plugins/chat/app/controllers/chat/api/chatables_controller.rb new file mode 100644 index 00000000000..7940a7221e5 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/chatables_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChatablesController < Chat::ApiController + before_action :ensure_logged_in + + def index + with_service(::Chat::SearchChatable) do + on_success { render_serialized(result, ::Chat::ChatablesSerializer, root: false) } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb new file mode 100644 index 00000000000..613af229090 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::Api::CurrentUserChannelsController < Chat::ApiController + def index + structured = Chat::ChannelFetcher.structured(guardian) + render_serialized(structured, Chat::ChannelIndexSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/chat/api/direct_messages_controller.rb b/plugins/chat/app/controllers/chat/api/direct_messages_controller.rb new file mode 100644 index 00000000000..c4fa7766286 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/direct_messages_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# TODO (martin) Remove this endpoint when we move to do the channel creation +# when a message is first sent to avoid double-request round trips for DMs. +class Chat::Api::DirectMessagesController < Chat::ApiController + def create + with_service(Chat::CreateDirectMessageChannel) do + on_success do + render_serialized( + result.channel, + Chat::ChannelSerializer, + root: "channel", + membership: result.membership, + ) + end + on_model_not_found(:target_users) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:satisfies_dms_max_users_limit) do |policy| + raise Discourse::InvalidParameters.new(:target_usernames, policy.reason) + end + on_failed_policy(:actor_allows_dms) do + render_json_error(I18n.t("chat.errors.actor_disallowed_dms")) + end + on_failed_policy(:targets_allow_dms_from_user) { |policy| render_json_error(policy.reason) } + on_model_errors(:direct_message) do |model| + render_json_error(model, type: :record_invalid, status: 422) + end + on_model_errors(:channel) do |model| + render_json_error(model, type: :record_invalid, status: 422) + end + end + end +end diff --git a/plugins/chat/app/controllers/api/hints_controller.rb b/plugins/chat/app/controllers/chat/api/hints_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/hints_controller.rb rename to plugins/chat/app/controllers/chat/api/hints_controller.rb diff --git a/plugins/chat/app/controllers/chat/api/reads_controller.rb b/plugins/chat/app/controllers/chat/api/reads_controller.rb new file mode 100644 index 00000000000..99a8dff412b --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/reads_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Chat::Api::ReadsController < Chat::ApiController + def update + params.require(%i[channel_id message_id]) + + with_service(Chat::UpdateUserLastRead) do + on_failed_policy(:ensure_message_id_recency) do + raise Discourse::InvalidParameters.new(:message_id) + end + on_model_not_found(:message) { raise Discourse::NotFound } + on_model_not_found(:active_membership) { raise Discourse::NotFound } + on_model_not_found(:channel) { raise Discourse::NotFound } + end + end + + def update_all + with_service(Chat::MarkAllUserChannelsRead) do + on_success do + render(json: success_json.merge(updated_memberships: result.updated_memberships)) + end + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/summaries_controller.rb b/plugins/chat/app/controllers/chat/api/summaries_controller.rb new file mode 100644 index 00000000000..208cf98c11b --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/summaries_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Chat::Api::SummariesController < Chat::ApiController + VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168] + + def get_summary + since = params[:since].to_i + raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since) + + channel = Chat::Channel.find(params[:channel_id]) + guardian.ensure_can_join_chat_channel!(channel) + + strategy = Summarization::Base.selected_strategy + raise Discourse::NotFound.new unless strategy + raise Discourse::InvalidAccess unless Summarization::Base.can_request_summary_for?(current_user) + + RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed! + + hijack do + content = { content_title: channel.name } + + content[:contents] = channel + .chat_messages + .where("chat_messages.created_at > ?", since.hours.ago) + .includes(:user) + .order(created_at: :asc) + .pluck(:id, :username_lower, :message) + .map { { id: _1, poster: _2, text: _3 } } + + summarized_text = + if content[:contents].empty? + I18n.t("chat.summaries.no_targets") + else + strategy.summarize(content).dig(:summary) + end + + render json: { summary: summarized_text } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api/thread_reads_controller.rb b/plugins/chat/app/controllers/chat/api/thread_reads_controller.rb new file mode 100644 index 00000000000..dfb72d9b70f --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/thread_reads_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ThreadReadsController < Chat::ApiController + def update + params.require(%i[channel_id thread_id]) + + with_service(Chat::UpdateUserThreadLastRead) do + on_model_not_found(:thread) { raise Discourse::NotFound } + end + end +end diff --git a/plugins/chat/app/controllers/chat/api_controller.rb b/plugins/chat/app/controllers/chat/api_controller.rb new file mode 100644 index 00000000000..57ec914e2cd --- /dev/null +++ b/plugins/chat/app/controllers/chat/api_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Chat + class ApiController < ::Chat::BaseController + before_action :ensure_logged_in + before_action :ensure_can_chat + + include Chat::WithServiceHelper + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def default_actions_for_service + proc do + on_success { render(json: success_json) } + on_failure { render(json: failed_json, status: 422) } + on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } + on_failed_contract do |contract| + render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400) + end + end + end + 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 00000000000..3f7e2691c29 --- /dev/null +++ b/plugins/chat/app/controllers/chat/base_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + class BaseController < ::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! + 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::ChannelFetcher.find_with_access_check(id_or_name, guardian) + @chatable = @chat_channel.chatable + end + end +end diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb new file mode 100644 index 00000000000..943d2786be7 --- /dev/null +++ b/plugins/chat/app/controllers/chat/chat_controller.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +module Chat + class ChatController < ::Chat::BaseController + PAST_MESSAGE_LIMIT = 40 + 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[edit_message rebake message_link] + before_action :set_channel_and_chatable_with_access_check, + except: %i[ + respond + enable_chat + disable_chat + message_link + set_user_chat_status + dismiss_retention_reminder + flag + ] + + def respond + render + end + + def enable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + + guardian.ensure_can_join_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_join_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::ChannelMembershipManager.new(channel).follow(user) + render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership) + else + render_json_error(chat_channel) + end + + Chat::ChannelMembershipManager.new(channel).follow(user) + end + + def disable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + guardian.ensure_can_join_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::MessageRateLimiter.run!(current_user) + + @user_chat_channel_membership = + Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(current_user) + raise Discourse::InvalidAccess unless @user_chat_channel_membership + + reply_to_msg_id = params[:in_reply_to_id] + if reply_to_msg_id.present? + rm = Chat::Message.find(reply_to_msg_id) + raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id + end + + content = params[:message] + + chat_message_creator = + Chat::MessageCreator.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], + thread_id: params[:thread_id], + staged_thread_id: params[:staged_thread_id], + ) + + return render_json_error(chat_message_creator.error) if chat_message_creator.failed? + + if !chat_message_creator.chat_message.thread_id.present? + @user_chat_channel_membership.update!( + last_read_message_id: chat_message_creator.chat_message.id, + ) + end + + if @chat_channel.direct_message_channel? + # If any of the channel users is ignoring, muting, or preventing DMs from + # the current user then we should not auto-follow the channel once again or + # publish the new channel. + allowed_user_ids = + UserCommScreener.new( + acting_user: current_user, + target_user_ids: + @chat_channel.user_chat_channel_memberships.where(following: false).pluck(:user_id), + ).allowing_actor_communication + + allowed_user_ids << current_user.id if !@user_chat_channel_membership.following + + if allowed_user_ids.any? + Chat::Publisher.publish_new_channel(@chat_channel, User.where(id: allowed_user_ids)) + + @chat_channel + .user_chat_channel_memberships + .where(user_id: allowed_user_ids) + .update_all(following: true) + end + end + + message = + ( + if @user_chat_channel_membership.last_read_message_id && + chat_message_creator.chat_message.in_thread? + Chat::Message.find(@user_chat_channel_membership.last_read_message_id) + else + chat_message_creator.chat_message + end + ) + + Chat::Publisher.publish_user_tracking_state!(current_user, @chat_channel, message) + + render json: success_json.merge(message_id: chat_message_creator.chat_message.id) + end + + def edit_message + chat_message_updater = + Chat::MessageUpdater.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 react + params.require(%i[message_id emoji react_action]) + guardian.ensure_can_react! + + Chat::MessageReactor.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 rebake + guardian.ensure_can_rebake_chat_message!(@message) + @message.rebake!(invalidate_oneboxes: true) + render json: success_json + end + + def message_link + raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? + raise Discourse::NotFound 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 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| + if user.guardian.can_join_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! + unless Chat::Channel.chatable_types.include?(params[:chatable_type]) + raise Discourse::InvalidParameters + end + + field = + ( + if Chat::Channel.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 = + Chat::TranscriptService.new( + @chat_channel, + current_user, + messages_or_ids: message_ids, + ).generate_markdown + render json: success_json.merge(markdown: markdown) + 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 = + Chat::Message.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::ReviewQueue.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? + Chat::Draft.find_or_initialize_by( + user: current_user, + chat_channel_id: @chat_channel.id, + ).update!(data: params[:data]) + else + Chat::Draft.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 = + Chat::Message + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(user: :primary_group) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + .includes(:thread) + .includes(:chat_mentions) + + 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 +end diff --git a/plugins/chat/app/controllers/chat/direct_messages_controller.rb b/plugins/chat/app/controllers/chat/direct_messages_controller.rb new file mode 100644 index 00000000000..f20c647cfc5 --- /dev/null +++ b/plugins/chat/app/controllers/chat/direct_messages_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Chat + class DirectMessagesController < ::Chat::BaseController + def index + guardian.ensure_can_chat! + users = users_from_usernames(current_user, params) + + direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq) + if direct_message + chat_channel = Chat::Channel.find_by(chatable_id: direct_message) + render_serialized( + chat_channel, + Chat::ChannelSerializer, + root: "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 +end diff --git a/plugins/chat/app/controllers/chat/emojis_controller.rb b/plugins/chat/app/controllers/chat/emojis_controller.rb new file mode 100644 index 00000000000..e27f8ae537e --- /dev/null +++ b/plugins/chat/app/controllers/chat/emojis_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Chat + class EmojisController < ::Chat::BaseController + def index + emojis = Emoji.allowed.group_by(&:group) + render json: MultiJson.dump(emojis) + end + end +end diff --git a/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..3e3485aa588 --- /dev/null +++ b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhooksController < ::ApplicationController + requires_plugin Chat::PLUGIN_NAME + + 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::MessageCreator.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 = Chat::IncomingWebhook.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 <= SiteSetting.chat_maximum_message_length + raise Discourse::InvalidParameters.new( + "Body cannot be over #{SiteSetting.chat_maximum_message_length} characters", + ) + end + + # The webhook POST body can be in 3 different formats: + # + # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads + # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments + # * { payload: "", attachments: null, text: null }, where JSON STRING can look + # like the `attachments` example above (along with other attributes), which is fired by OpsGenie + def validate_payload + params.require(:key) + + 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 for endpoint #{params[:key]}: " + + JSON.dump( + { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, + ), + ) + end + end +end diff --git a/plugins/chat/app/controllers/chat_base_controller.rb b/plugins/chat/app/controllers/chat_base_controller.rb deleted file mode 100644 index 6e014502ddb..00000000000 --- a/plugins/chat/app/controllers/chat_base_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# 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! - 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_controller.rb b/plugins/chat/app/controllers/chat_controller.rb deleted file mode 100644 index 31ad38e02a6..00000000000 --- a/plugins/chat/app/controllers/chat_controller.rb +++ /dev/null @@ -1,476 +0,0 @@ -# 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_join_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_join_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_join_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? - - @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? - ChatPublisher.publish_new_channel( - @chat_channel, - @chat_channel.chatable.users.where(id: user_ids_allowing_communication), - ) - - @chat_channel - .user_chat_channel_memberships - .where(user_id: user_ids_allowing_communication) - .update_all(following: true) - 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 - raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? - raise Discourse::NotFound 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? && guardian.can_join_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! - 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 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 deleted file mode 100644 index b0100a95a89..00000000000 --- a/plugins/chat/app/controllers/direct_messages_controller.rb +++ /dev/null @@ -1,55 +0,0 @@ -# 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! - 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: "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! - 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: "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 deleted file mode 100644 index 8d895e2bd70..00000000000 --- a/plugins/chat/app/controllers/emojis_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# 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 deleted file mode 100644 index f528e59513a..00000000000 --- a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -class Chat::IncomingChatWebhooksController < ApplicationController - 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 <= SiteSetting.chat_maximum_message_length - raise Discourse::InvalidParameters.new( - "Body cannot be over #{SiteSetting.chat_maximum_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 deleted file mode 100644 index 9e38199f2ed..00000000000 --- a/plugins/chat/app/core_ext/plugin_instance.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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/helpers/chat/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb new file mode 100644 index 00000000000..7faf0aeff07 --- /dev/null +++ b/plugins/chat/app/helpers/chat/with_service_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module Chat + module WithServiceHelper + def result + @_result + end + + # @param service [Class] A class including {Chat::Service::Base} + # @param dependencies [kwargs] Any additional params to load into the service context, + # in addition to controller @params. + def with_service(service, default_actions: true, **dependencies, &block) + object = self + merged_block = + proc do + instance_exec(&object.method(:default_actions_for_service).call) if default_actions + instance_exec(&(block || proc {})) + end + ServiceRunner.call(service, object, **dependencies, &merged_block) + end + + def run_service(service, dependencies) + @_result = service.call(params.to_unsafe_h.merge(guardian: guardian, **dependencies)) + end + + def default_actions_for_service + proc {} + 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 deleted file mode 100644 index 16d01e96a94..00000000000 --- a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb +++ /dev/null @@ -1,81 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# 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 deleted file mode 100644 index 9785db5c920..00000000000 --- a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb +++ /dev/null @@ -1,79 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# 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/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb new file mode 100644 index 00000000000..a11211a57dd --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinChannelBatch < ServiceJob + def execute(args) + with_service(::Chat::AutoJoinChannelBatch, **args) do + on_failed_contract do |contract| + Rails.logger.error(contract.errors.full_messages.join(", ")) + end + on_model_not_found(:channel) do + Rails.logger.error("Channel not found (id=#{result.contract.channel_id})") + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_join_channel_memberships.rb b/plugins/chat/app/jobs/regular/chat/auto_join_channel_memberships.rb new file mode 100644 index 00000000000..fbb57ded563 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_join_channel_memberships.rb @@ -0,0 +1,81 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinChannelMemberships < ::Jobs::Base + def execute(args) + channel = + ::Chat::Channel.includes(:chatable).find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel&.chatable + + processed = + ::Chat::UserChatChannelMembership.where( + chat_channel: channel, + following: true, + join_mode: ::Chat::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( + ::Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: starts_at, + ends_at: ends_at, + ) + + processed += batch.size + end + + # The Jobs::Chat::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::ChannelMembershipManager.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 +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_category_updated.rb b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_category_updated.rb new file mode 100644 index 00000000000..fa1e6504fbf --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_category_updated.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoRemoveMembershipHandleCategoryUpdated < ::Jobs::Base + def execute(args) + ::Chat::AutoRemove::HandleCategoryUpdated.call(**args) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_chat_allowed_groups_change.rb b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_chat_allowed_groups_change.rb new file mode 100644 index 00000000000..94ac518b35d --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_chat_allowed_groups_change.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoRemoveMembershipHandleChatAllowedGroupsChange < ::Jobs::Base + def execute(args) + ::Chat::AutoRemove::HandleChatAllowedGroupsChange.call(**args) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_destroyed_group.rb b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_destroyed_group.rb new file mode 100644 index 00000000000..e24f87247ee --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_destroyed_group.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoRemoveMembershipHandleDestroyedGroup < ::Jobs::Base + def execute(args) + ::Chat::AutoRemove::HandleDestroyedGroup.call(**args) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_user_removed_from_group.rb b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_user_removed_from_group.rb new file mode 100644 index 00000000000..bd599a840d2 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_remove_membership_handle_user_removed_from_group.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoRemoveMembershipHandleUserRemovedFromGroup < ::Jobs::Base + def execute(args) + ::Chat::AutoRemove::HandleUserRemovedFromGroup.call(**args) + end + 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 00000000000..49594fb87dc --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_archive.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelArchive < ::Jobs::Base + sidekiq_options retry: false + + def execute(args = {}) + channel_archive = ::Chat::ChannelArchive.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 + + if channel_archive.complete? + channel_archive.chat_channel.update!(status: :archived) + + ::Chat::Publisher.publish_archive_status( + channel_archive.chat_channel, + archive_status: :success, + archived_messages: channel_archive.archived_messages, + archive_topic_id: channel_archive.destination_topic_id, + total_messages: channel_archive.total_messages, + ) + + return + end + + ::DistributedMutex.synchronize( + "archive_chat_channel_#{channel_archive.chat_channel_id}", + validity: 20.minutes, + ) { ::Chat::ChannelArchiveService.new(channel_archive).execute } + end + 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 00000000000..1c47cb99078 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_delete.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelDelete < ::Jobs::Base + def execute(args = {}) + chat_channel = ::Chat::Channel.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}") + ::Chat::Message.transaction do + webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel) + ::Chat::WebhookEvent.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}") + ::Chat::Draft.where(chat_channel: chat_channel).delete_all + ::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all + + ::Rails.logger.debug( + "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", + ) + chat_messages = ::Chat::Message.where(chat_channel: chat_channel) + delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any? + end + end + + def delete_messages_and_related_records(chat_channel, chat_messages) + message_ids = chat_messages.pluck(:id) + + ::Chat::Message.transaction do + ::Chat::Mention.where(chat_message_id: message_ids).delete_all + ::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all + ::Chat::MessageReaction.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 + ::UploadReference.where( + target_id: message_ids, + target_type: ::Chat::Message.polymorphic_name, + ).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/delete_user_messages.rb b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb new file mode 100644 index 00000000000..a97d1d55c38 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteUserMessages < ::Jobs::Base + def execute(args) + return if args[:user_id].nil? + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.with_deleted.where(user_id: args[:user_id]), + ) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/kick_users_from_channel.rb b/plugins/chat/app/jobs/regular/chat/kick_users_from_channel.rb new file mode 100644 index 00000000000..aa67049c4c3 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/kick_users_from_channel.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class KickUsersFromChannel < Jobs::Base + def execute(args) + return if !::Chat::Channel.exists?(id: args[:channel_id]) + return if args[:user_ids].blank? + ::Chat::Publisher.publish_kick_users(args[:channel_id], args[:user_ids]) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/mark_all_channel_threads_read.rb b/plugins/chat/app/jobs/regular/chat/mark_all_channel_threads_read.rb new file mode 100644 index 00000000000..bc190b25c89 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/mark_all_channel_threads_read.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class MarkAllChannelThreadsRead < Jobs::Base + sidekiq_options queue: "critical" + + def execute(args = {}) + channel = ::Chat::Channel.find_by(id: args[:channel_id]) + return if channel.blank? + channel.mark_all_threads_as_read + 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 00000000000..3388d17973a --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyMentioned < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.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 = + ::Chat::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?, + } + + data[:chat_thread_id] = @chat_message.thread_id if @chat_message.in_thread? + + 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:) + post_url = + if @chat_message.in_thread? + @chat_message.thread.relative_url + else + "#{@chat_channel.relative_url}/#{@chat_message.id}" + end + + payload = { + notification_type: ::Notification.types[:chat_mention], + username: @creator.username, + tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + post_url: post_url, + } + + 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, mention, mention_type) + notification_data = build_data_for(membership, identifier_type: mention_type) + is_read = ::Chat::Notifier.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, + ) + + mention.update!(notification: notification) + end + + def send_notifications(membership, mention_type) + payload = build_payload_for(membership, identifier_type: mention_type) + + if !membership.desktop_notifications_never? && !membership.muted? + ::MessageBus.publish( + "/chat/notification-alert/#{membership.user_id}", + payload, + user_ids: [membership.user_id], + ) + end + + if !membership.mobile_notifications_never? && !membership.muted? + ::PostAlerter.push_notification(membership.user, payload) + end + end + + def process_mentions(user_ids, mention_type) + memberships = get_memberships(user_ids) + + memberships.each do |membership| + mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message) + if mention.present? + create_notification!(membership, mention, mention_type) + send_notifications(membership, mention_type) + end + end + 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 00000000000..4b62af0afce --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_watching.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyWatching < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.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 = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + members = + ::Chat::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 + return unless user.guardian.can_join_chat_channel?(@chat_channel) + return if ::Chat::Notifier.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::Notifier.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 +end diff --git a/plugins/chat/app/jobs/regular/chat/process_message.rb b/plugins/chat/app/jobs/regular/chat/process_message.rb new file mode 100644 index 00000000000..33fcc43b565 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/process_message.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ProcessMessage < ::Jobs::Base + def execute(args = {}) + ::DistributedMutex.synchronize( + "jobs_chat_process_message_#{args[:chat_message_id]}", + validity: 10.minutes, + ) do + chat_message = ::Chat::Message.find_by(id: args[:chat_message_id]) + return if !chat_message + processor = ::Chat::MessageProcessor.new(chat_message) + processor.run! + + if args[:is_dirty] || processor.dirty? + chat_message.update( + cooked: processor.html, + cooked_version: ::Chat::Message::BAKED_VERSION, + ) + ::Chat::Publisher.publish_processed!(chat_message) + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb new file mode 100644 index 00000000000..4724f5a6f94 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class SendMessageNotifications < ::Jobs::Base + def execute(args) + reason = args[:reason] + valid_reasons = %w[new edit] + return unless valid_reasons.include?(reason) + + return if (timestamp = args[:timestamp]).blank? + + return if (message = ::Chat::Message.find_by(id: args[:chat_message_id])).nil? + + if reason == "new" + ::Chat::Notifier.new(message, timestamp).notify_new + elsif reason == "edit" + ::Chat::Notifier.new(message, timestamp).notify_edit + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb new file mode 100644 index 00000000000..8608fd305fb --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class UpdateChannelUserCount < Jobs::Base + def execute(args = {}) + channel = ::Chat::Channel.find_by(id: args[:chat_channel_id]) + return if channel.blank? + return if !channel.user_count_stale + + channel.update!( + user_count: ::Chat::ChannelMembershipsQuery.count(channel), + user_count_stale: false, + ) + + ::Chat::Publisher.publish_chat_channel_metadata(channel) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/update_thread_reply_count.rb b/plugins/chat/app/jobs/regular/chat/update_thread_reply_count.rb new file mode 100644 index 00000000000..5e15cb35808 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/update_thread_reply_count.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class UpdateThreadReplyCount < Jobs::Base + def execute(args = {}) + thread = ::Chat::Thread.find_by(id: args[:thread_id]) + return if thread.blank? + return if thread.replies_count_cache_recently_updated? + + Discourse.redis.setex( + ::Chat::Thread.replies_count_cache_updated_at_redis_key(thread.id), + 5.minutes.from_now.to_i, + Time.zone.now.to_i, + ) + thread.set_replies_count_cache(thread.replies.count, update_db: true) + end + 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 deleted file mode 100644 index 33e270dd220..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_archive.rb +++ /dev/null @@ -1,38 +0,0 @@ -# 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 - - if channel_archive.complete? - channel_archive.chat_channel.update!(status: :archived) - - ChatPublisher.publish_archive_status( - channel_archive.chat_channel, - archive_status: :success, - archived_messages: channel_archive.archived_messages, - archive_topic_id: channel_archive.destination_topic_id, - total_messages: channel_archive.total_messages, - ) - - return - end - - 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 deleted file mode 100644 index ac89be4db99..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_delete.rb +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index d6fa48e3320..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb +++ /dev/null @@ -1,148 +0,0 @@ -# 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 deleted file mode 100644 index 4ac3fca4fcf..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_watching.rb +++ /dev/null @@ -1,84 +0,0 @@ -# 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? && guardian.can_join_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/delete_user_messages.rb b/plugins/chat/app/jobs/regular/delete_user_messages.rb deleted file mode 100644 index 22c35624ef9..00000000000 --- a/plugins/chat/app/jobs/regular/delete_user_messages.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DeleteUserMessages < ::Jobs::Base - def execute(args) - return if args[:user_id].nil? - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.with_deleted.where(user_id: args[:user_id]), - ) - 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 deleted file mode 100644 index 612978bb23f..00000000000 --- a/plugins/chat/app/jobs/regular/process_chat_message.rb +++ /dev/null @@ -1,22 +0,0 @@ -# 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/send_message_notifications.rb b/plugins/chat/app/jobs/regular/send_message_notifications.rb deleted file mode 100644 index 5fa778467e4..00000000000 --- a/plugins/chat/app/jobs/regular/send_message_notifications.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class SendMessageNotifications < ::Jobs::Base - def execute(args) - reason = args[:reason] - valid_reasons = %w[new edit] - return unless valid_reasons.include?(reason) - - return if (timestamp = args[:timestamp]).blank? - - return if (message = ChatMessage.find_by(id: args[:chat_message_id])).nil? - - if reason == "new" - Chat::ChatNotifier.new(message, timestamp).notify_new - elsif reason == "edit" - Chat::ChatNotifier.new(message, timestamp).notify_edit - 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 deleted file mode 100644 index 0790a52e167..00000000000 --- a/plugins/chat/app/jobs/regular/update_channel_user_count.rb +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 061a3dce8db..00000000000 --- a/plugins/chat/app/jobs/scheduled/auto_join_users.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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/chat/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb new file mode 100644 index 00000000000..5d2c50938bf --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinUsers < ::Jobs::Scheduled + every 1.hour + + def execute(_args) + return if !SiteSetting.chat_enabled + + ::Chat::Channel + .where(auto_join_users: true) + .each do |channel| + ::Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb new file mode 100644 index 00000000000..0a706b3875b --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteOldMessages < ::Jobs::Scheduled + daily at: 0.hours + + def execute(args = {}) + return if !SiteSetting.chat_enabled + + delete_public_channel_messages + delete_dm_channel_messages + end + + private + + def delete_public_channel_messages + return unless valid_day_value?(:chat_channel_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_public_channel.with_deleted.created_before( + ::SiteSetting.chat_channel_retention_days.days.ago, + ), + ) + end + + def delete_dm_channel_messages + return unless valid_day_value?(:chat_dm_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_dm_channel.with_deleted.created_before( + ::SiteSetting.chat_dm_retention_days.days.ago, + ), + ) + end + + def valid_day_value?(setting_name) + (::SiteSetting.public_send(setting_name) || 0).positive? + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb new file mode 100644 index 00000000000..4b88c7122d3 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class EmailNotifications < ::Jobs::Scheduled + every 5.minutes + + def execute(args = {}) + return if !SiteSetting.chat_enabled + + ::Chat::Mailer.send_unread_mentions_summary + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb new file mode 100644 index 00000000000..5879ebec095 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class PeriodicalUpdates < ::Jobs::Scheduled + every 15.minutes + + def execute(args = nil) + return if !SiteSetting.chat_enabled + + # TODO: Add rebaking of old messages (baked_version < + # Chat::Message::BAKED_VERSION or baked_version IS NULL) + ::Chat::Channel.ensure_consistency! + ::Chat::Thread.ensure_consistency! + nil + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb deleted file mode 100644 index c7ca56fcb15..00000000000 --- a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatPeriodicalUpdates < ::Jobs::Scheduled - every 15.minutes - - def execute(args = nil) - # TODO: Add rebaking of old messages (baked_version < - # ChatMessage::BAKED_VERSION or baked_version IS NULL) - ChatChannel.ensure_consistency! - nil - 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 deleted file mode 100644 index 0fbc06141be..00000000000 --- a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb +++ /dev/null @@ -1,38 +0,0 @@ -# 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 - - private - - def delete_public_channel_messages - return unless valid_day_value?(:chat_channel_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_public_channel.with_deleted.created_before( - SiteSetting.chat_channel_retention_days.days.ago, - ), - ) - end - - def delete_dm_channel_messages - return unless valid_day_value?(:chat_dm_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_dm_channel.with_deleted.created_before( - SiteSetting.chat_dm_retention_days.days.ago, - ), - ) - end - - def valid_day_value?(setting_name) - (SiteSetting.public_send(setting_name) || 0).positive? - 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 deleted file mode 100644 index 470c6aa2152..00000000000 --- a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index 8880732b8e5..00000000000 --- a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Jobs - # TODO (martin) Move into ChatChannel.ensure_consistency! so it - # is run with ChatPeriodicalUpdates - 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/jobs/service_job.rb b/plugins/chat/app/jobs/service_job.rb new file mode 100644 index 00000000000..e2af50f8641 --- /dev/null +++ b/plugins/chat/app/jobs/service_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ServiceJob < ::Jobs::Base + include Chat::WithServiceHelper + + def run_service(service, dependencies) + @_result = service.call(dependencies) + end +end diff --git a/plugins/chat/app/models/category_channel.rb b/plugins/chat/app/models/category_channel.rb deleted file mode 100644 index b205e82b4ac..00000000000 --- a/plugins/chat/app/models/category_channel.rb +++ /dev/null @@ -1,45 +0,0 @@ -# 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 self.slug.present? - # 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 -end diff --git a/plugins/chat/app/models/chat/category_channel.rb b/plugins/chat/app/models/chat/category_channel.rb new file mode 100644 index 00000000000..bf23f561cde --- /dev/null +++ b/plugins/chat/app/models/chat/category_channel.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Chat + class CategoryChannel < Channel + 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 self.slug.present? + # 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 + 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 00000000000..077a7d7f79e --- /dev/null +++ b/plugins/chat/app/models/chat/channel.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +module Chat + class Channel < ActiveRecord::Base + include Trashable + include TypeMappable + + # TODO (martin) Remove once we are using last_message instead, + # should be around August 2023. + self.ignored_columns = %w[last_message_sent_at] + self.table_name = "chat_channels" + + belongs_to :chatable, polymorphic: true + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :chatable_id, + inverse_of: :direct_message_channel, + optional: true + + has_many :chat_messages, class_name: "Chat::Message", foreign_key: :chat_channel_id + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + foreign_key: :chat_channel_id + has_many :threads, class_name: "Chat::Thread", foreign_key: :channel_id + has_one :chat_channel_archive, class_name: "Chat::ChannelArchive", foreign_key: :chat_channel_id + belongs_to :last_message, + class_name: "Chat::Message", + foreign_key: :last_message_id, + optional: true + + 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, if: :slug_changed? + before_validation :generate_auto_slug + + scope :with_categories, + -> { + joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + } + scope :public_channels, + -> { + with_categories.where(chatable_type: public_channel_chatable_types).where( + "categories.id IS NOT NULL", + ) + } + + delegate :empty?, to: :chat_messages, prefix: true + + class << self + def sti_class_mapping = + { + "CategoryChannel" => Chat::CategoryChannel, + "DirectMessageChannel" => Chat::DirectMessageChannel, + } + + def polymorphic_class_mapping = { "DirectMessage" => Chat::DirectMessage } + + def editable_statuses + statuses.filter { |k, _| !%w[read_only archived].include?(k) } + end + + def public_channel_chatable_types + %w[Category] + end + + def direct_channel_chatable_types + %w[DirectMessage] + end + + def chatable_types + public_channel_chatable_types + direct_channel_chatable_types + end + + def find_by_id_or_slug(id) + with_categories.find_by( + "chat_channels.id = :id OR categories.slug = :slug OR chat_channels.slug = :slug", + id: Integer(id, exception: false), + slug: id.to_s.downcase, + ) + 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::ChannelMembershipManager.new(self).follow(user) + end + + def remove(user) + Chat::ChannelMembershipManager.new(self).unfollow(user) + end + + def url + "#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def relative_url + "#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def update_last_message_id! + self.update!(last_message_id: self.latest_not_deleted_message_id) + end + + def self.ensure_consistency! + update_message_counts + update_user_counts + end + + def joined_by?(user) + user.user_chat_channel_memberships.strict_loading.any? do |membership| + predicate = membership.chat_channel_id == id + predicate = predicate && membership.following if public_channel? + predicate + end + end + + def self.update_message_counts + # NOTE: Chat::Channel#messages_count is not updated every time + # a message is created or deleted in a channel, so it should not + # be displayed in the UI. It is updated eventually via Jobs::Chat::PeriodicalUpdates + DB.exec <<~SQL + UPDATE chat_channels channels + SET messages_count = subquery.messages_count + FROM ( + SELECT COUNT(*) AS messages_count, chat_channel_id + FROM chat_messages + WHERE chat_messages.deleted_at IS NULL + GROUP BY chat_channel_id + ) subquery + WHERE channels.id = subquery.chat_channel_id + AND channels.deleted_at IS NULL + AND subquery.messages_count != channels.messages_count + SQL + end + + def self.update_user_counts + updated_channel_ids = DB.query_single(<<~SQL, statuses: [statuses[:open], statuses[:closed]]) + UPDATE chat_channels channels + SET user_count = subquery.user_count, user_count_stale = false + FROM ( + SELECT COUNT(DISTINCT user_chat_channel_memberships.id) AS user_count, user_chat_channel_memberships.chat_channel_id + FROM user_chat_channel_memberships + INNER JOIN users ON users.id = user_chat_channel_memberships.user_id + WHERE users.active + AND (users.suspended_till IS NULL OR users.suspended_till <= CURRENT_TIMESTAMP) + AND NOT users.staged + AND user_chat_channel_memberships.following + GROUP BY user_chat_channel_memberships.chat_channel_id + ) subquery + WHERE channels.id = subquery.chat_channel_id + AND channels.deleted_at IS NULL + AND subquery.user_count != channels.user_count + AND channels.status IN (:statuses) + RETURNING channels.id; + SQL + + Chat::Channel + .where(id: updated_channel_ids) + .find_each { |channel| ::Chat::Publisher.publish_chat_channel_metadata(channel) } + end + + def latest_not_deleted_message_id(anchor_message_id: nil) + DB.query_single(<<~SQL, channel_id: self.id, anchor_message_id: anchor_message_id).first + SELECT chat_messages.id + FROM chat_messages + LEFT JOIN chat_threads original_message_threads ON original_message_threads.original_message_id = chat_messages.id + WHERE chat_channel_id = :channel_id + AND deleted_at IS NULL + -- this is so only the original message of a thread is counted not all thread replies + AND (chat_messages.thread_id IS NULL OR original_message_threads.id IS NOT NULL) + #{anchor_message_id ? "AND chat_messages.id < :anchor_message_id" : ""} + ORDER BY chat_messages.created_at DESC, chat_messages.id DESC + LIMIT 1 + SQL + end + + def mark_all_threads_as_read(user: nil) + return if !self.threading_enabled + + DB.exec(<<~SQL, channel_id: self.id) + UPDATE user_chat_thread_memberships + SET last_read_message_id = chat_threads.last_message_id + FROM chat_threads + WHERE user_chat_thread_memberships.thread_id = chat_threads.id + #{user ? "AND user_chat_thread_memberships.user_id = #{user.id}" : ""} + AND ( + user_chat_thread_memberships.last_read_message_id < chat_threads.last_message_id OR + user_chat_thread_memberships.last_read_message_id IS NULL + ) + SQL + 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, + }, + ) + + Chat::Publisher.publish_channel_status(self) + end + + def duplicate_slug? + Chat::Channel.where(slug: self.slug).where.not(id: self.id).any? + end + 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 +# allow_channel_wide_mentions :boolean default(TRUE), not null +# messages_count :integer default(0), not null +# threading_enabled :boolean default(FALSE), not null +# last_message_id :bigint +# +# 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_last_message_id (last_message_id) +# index_chat_channels_on_messages_count (messages_count) +# index_chat_channels_on_slug (slug) UNIQUE +# 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 similarity index 60% rename from plugins/chat/app/models/chat_channel_archive.rb rename to plugins/chat/app/models/chat/channel_archive.rb index 057af4e5bf9..e8c88b4b932 100644 --- a/plugins/chat/app/models/chat_channel_archive.rb +++ b/plugins/chat/app/models/chat/channel_archive.rb @@ -1,21 +1,24 @@ # frozen_string_literal: true -class ChatChannelArchive < ActiveRecord::Base - belongs_to :chat_channel - belongs_to :archived_by, class_name: "User" +module Chat + class ChannelArchive < ActiveRecord::Base + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :archived_by, class_name: "User" + belongs_to :destination_topic, class_name: "Topic" - belongs_to :destination_topic, class_name: "Topic" + self.table_name = "chat_channel_archives" - def complete? - self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? - end + def complete? + self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? + end - def failed? - !complete? && self.archive_error.present? - end + def failed? + !complete? && self.archive_error.present? + end - def new_topic? - self.destination_topic_title.present? + def new_topic? + self.destination_topic_title.present? + end end end diff --git a/plugins/chat/app/models/chat/deleted_user.rb b/plugins/chat/app/models/chat/deleted_user.rb new file mode 100644 index 00000000000..b97d775500d --- /dev/null +++ b/plugins/chat/app/models/chat/deleted_user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DeletedUser < 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 +end diff --git a/plugins/chat/app/models/chat/direct_message.rb b/plugins/chat/app/models/chat/direct_message.rb new file mode 100644 index 00000000000..c5c6864a018 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Chat + class DirectMessage < ActiveRecord::Base + self.table_name = "direct_message_channels" + + include Chatable + include TypeMappable + + has_many :direct_message_users, + class_name: "Chat::DirectMessageUser", + foreign_key: :direct_message_channel_id + has_many :users, through: :direct_message_users + + has_one :direct_message_channel, as: :chatable, class_name: "Chat::DirectMessageChannel" + + class << self + def polymorphic_class_mapping = { "DirectMessage" => Chat::DirectMessage } + + def 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 + 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 do |user| + user || Chat::DeletedUser.new + end + + # direct message to self + if users.empty? + return I18n.t("chat.channel.dm_title.single_user", username: "@#{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", + comma_separated_usernames: + usernames_formatted[0..4].join(I18n.t("word_connector.comma")), + count: usernames_formatted.length - 5, + ) + ) + end + + I18n.t( + "chat.channel.dm_title.multi_user", + comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")), + ) + end + 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/chat/direct_message_channel.rb b/plugins/chat/app/models/chat/direct_message_channel.rb new file mode 100644 index 00000000000..44dd3bf3bc4 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message_channel.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageChannel < Channel + 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 +end diff --git a/plugins/chat/app/models/direct_message_user.rb b/plugins/chat/app/models/chat/direct_message_user.rb similarity index 64% rename from plugins/chat/app/models/direct_message_user.rb rename to plugins/chat/app/models/chat/direct_message_user.rb index f8cfc6664ff..aae155f6210 100644 --- a/plugins/chat/app/models/direct_message_user.rb +++ b/plugins/chat/app/models/chat/direct_message_user.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true -class DirectMessageUser < ActiveRecord::Base - belongs_to :direct_message, foreign_key: :direct_message_channel_id - belongs_to :user +module Chat + class DirectMessageUser < ActiveRecord::Base + self.table_name = "direct_message_users" + + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :direct_message_channel_id + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_draft.rb b/plugins/chat/app/models/chat/draft.rb similarity index 52% rename from plugins/chat/app/models/chat_draft.rb rename to plugins/chat/app/models/chat/draft.rb index 7dc1b7feeb0..6b1dc2d59e7 100644 --- a/plugins/chat/app/models/chat_draft.rb +++ b/plugins/chat/app/models/chat/draft.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class ChatDraft < ActiveRecord::Base - belongs_to :user - belongs_to :chat_channel +module Chat + class Draft < ActiveRecord::Base + belongs_to :user + belongs_to :chat_channel, class_name: "Chat::Channel" - validate :data_length - def data_length - if self.data && self.data.length > SiteSetting.max_chat_draft_length - self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + self.table_name = "chat_drafts" + + validate :data_length + def data_length + if self.data && self.data.length > SiteSetting.max_chat_draft_length + self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + end end end end diff --git a/plugins/chat/app/models/incoming_chat_webhook.rb b/plugins/chat/app/models/chat/incoming_webhook.rb similarity index 61% rename from plugins/chat/app/models/incoming_chat_webhook.rb rename to plugins/chat/app/models/chat/incoming_webhook.rb index e71b539a037..cb76ffebc66 100644 --- a/plugins/chat/app/models/incoming_chat_webhook.rb +++ b/plugins/chat/app/models/chat/incoming_webhook.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class IncomingChatWebhook < ActiveRecord::Base - belongs_to :chat_channel - has_many :chat_webhook_events +module Chat + class IncomingWebhook < ActiveRecord::Base + self.table_name = "incoming_chat_webhooks" - before_create { self.key = SecureRandom.hex(12) } + belongs_to :chat_channel, class_name: "Chat::Channel" + has_many :chat_webhook_events, class_name: "Chat::WebhookEvent" - def url - "#{Discourse.base_url}/chat/hooks/#{key}.json" + before_create { self.key = SecureRandom.hex(12) } + + def url + "#{Discourse.base_url}/chat/hooks/#{key}.json" + end end end diff --git a/plugins/chat/app/models/chat_mention.rb b/plugins/chat/app/models/chat/mention.rb similarity index 67% rename from plugins/chat/app/models/chat_mention.rb rename to plugins/chat/app/models/chat/mention.rb index e334acae473..ab3bbee9925 100644 --- a/plugins/chat/app/models/chat_mention.rb +++ b/plugins/chat/app/models/chat/mention.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true -class ChatMention < ActiveRecord::Base - belongs_to :user - belongs_to :chat_message - belongs_to :notification +module Chat + class Mention < ActiveRecord::Base + self.table_name = "chat_mentions" + + belongs_to :user + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :notification, dependent: :destroy + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb new file mode 100644 index 00000000000..7e7aabc6958 --- /dev/null +++ b/plugins/chat/app/models/chat/message.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +module Chat + class Message < ActiveRecord::Base + include Trashable + include TypeMappable + + self.table_name = "chat_messages" + + BAKED_VERSION = 2 + + attribute :has_oneboxes, default: false + + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :user + belongs_to :in_reply_to, class_name: "Chat::Message" + belongs_to :last_editor, class_name: "User" + belongs_to :thread, class_name: "Chat::Thread", optional: true + + has_many :replies, + class_name: "Chat::Message", + foreign_key: "in_reply_to_id", + dependent: :nullify + has_many :revisions, + class_name: "Chat::MessageRevision", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :reactions, + class_name: "Chat::MessageReaction", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :bookmarks, + -> { + unscope(where: :bookmarkable_type).where( + bookmarkable_type: Chat::Message.polymorphic_name, + ) + }, + as: :bookmarkable, + dependent: :destroy + has_many :upload_references, + -> { unscope(where: :target_type).where(target_type: Chat::Message.polymorphic_name) }, + dependent: :destroy, + foreign_key: :target_id + has_many :uploads, through: :upload_references, class_name: "::Upload" + + has_one :chat_webhook_event, + dependent: :destroy, + class_name: "Chat::WebhookEvent", + foreign_key: :chat_message_id + has_many :chat_mentions, + dependent: :destroy, + class_name: "Chat::Mention", + foreign_key: :chat_message_id + + scope :in_public_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.public_channel_chatable_types, + }, + ) + } + scope :in_dm_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.direct_channel_chatable_types, + }, + ) + } + scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } + scope :uncooked, -> { where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) } + + before_save { ensure_last_editor_id } + + validate :validate_message + + def self.polymorphic_class_mapping = { "ChatMessage" => Chat::Message } + + def validate_message + self.message = + TextCleaner.clean(self.message, strip_whitespaces: true, strip_zero_width_spaces: true) + + WatchedWordsValidator.new(attributes: [:message]).validate(self) + + if self.new_record? || self.changed.include?("message") + Chat::DuplicateMessageValidator.new(self).validate + end + + if uploads.empty? && message_too_short? + self.errors.add( + :base, + I18n.t( + "chat.errors.minimum_length_not_met", + count: SiteSetting.chat_minimum_message_length, + ), + ) + end + + if message_too_long? + self.errors.add( + :base, + I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length), + ) + end + end + + def excerpt(max_length: 50) + # 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, max_length) + end + + def censored_excerpt(max_length: 50) + WordWatcher.censor(excerpt(max_length: max_length)) + 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 + upload_markdown = + self + .upload_references + .includes(:upload) + .order(:created_at) + .map(&:to_markdown) + .reject(&:empty?) + + return self.message if upload_markdown.empty? + + return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present? + + upload_markdown.join("\n") + end + + def cook + ensure_last_editor_id + + self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) + self.cooked_version = BAKED_VERSION + + invalidate_parsed_mentions + end + + def rebake!(invalidate_oneboxes: false, priority: nil) + ensure_last_editor_id + + previous_cooked = self.cooked + new_cooked = + self.class.cook( + message, + invalidate_oneboxes: invalidate_oneboxes, + user_id: self.last_editor_id, + ) + 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(Jobs::Chat::ProcessMessage, args) + end + + MARKDOWN_FEATURES = %w[ + anchor + bbcode-block + bbcode-inline + code + category-hashtag + censored + chat-transcript + discourse-local-dates + emoji + emojiShortcuts + inlineEmoji + html-img + hashtag-autocomplete + 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 = {}) + # A rule in our Markdown pipeline may have Guardian checks that require a + # user to be present. The last editing user of the message will be more + # generally up to date than the creating user. For example, we use + # this when cooking #hashtags to determine whether we should render + # the found hashtag based on whether the user can access the channel it + # is referencing. + cooked = + PrettyText.cook( + message, + features_override: + MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, + markdown_it_rules: MARKDOWN_IT_RULES, + force_quote_link: true, + user_id: opts[:user_id], + hashtag_context: "chat-composer", + ) + + 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/c/-/#{self.chat_channel_id}/#{self.id}" + end + + def create_mentions + insert_mentions(parsed_mentions.all_mentioned_users_ids) + end + + def update_mentions + mentioned_user_ids = parsed_mentions.all_mentioned_users_ids + + old_mentions = chat_mentions.pluck(:user_id) + updated_mentions = mentioned_user_ids + mentioned_user_ids_to_drop = old_mentions - updated_mentions + mentioned_user_ids_to_add = updated_mentions - old_mentions + + delete_mentions(mentioned_user_ids_to_drop) + insert_mentions(mentioned_user_ids_to_add) + end + + def in_thread? + self.thread_id.present? + end + + def thread_reply? + in_thread? && !thread_om? + end + + def thread_om? + in_thread? && self.thread.original_message_id == self.id + end + + def parsed_mentions + @parsed_mentions ||= Chat::ParsedMentions.new(self) + end + + def invalidate_parsed_mentions + @parsed_mentions = nil + end + + private + + def delete_mentions(user_ids) + chat_mentions.where(user_id: user_ids).destroy_all + end + + def insert_mentions(user_ids) + return if user_ids.empty? + + now = Time.zone.now + mentions = [] + User + .where(id: user_ids) + .find_each do |user| + mentions << { + chat_message_id: self.id, + user_id: user.id, + created_at: now, + updated_at: now, + } + end + + Chat::Mention.insert_all(mentions) + end + + def message_too_short? + message.length < SiteSetting.chat_minimum_message_length + end + + def message_too_long? + message.length > SiteSetting.chat_maximum_message_length + end + + def ensure_last_editor_id + self.last_editor_id ||= self.user_id + end + 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 +# thread_id :integer +# +# Indexes +# +# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) +# idx_chat_messages_by_thread_id_not_deleted (thread_id) WHERE (deleted_at IS NULL) +# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) +# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) +# index_chat_messages_on_last_editor_id (last_editor_id) +# index_chat_messages_on_thread_id (thread_id) +# diff --git a/plugins/chat/app/models/chat_message_reaction.rb b/plugins/chat/app/models/chat/message_reaction.rb similarity index 69% rename from plugins/chat/app/models/chat_message_reaction.rb rename to plugins/chat/app/models/chat/message_reaction.rb index f101b2ec353..3b378dd0481 100644 --- a/plugins/chat/app/models/chat_message_reaction.rb +++ b/plugins/chat/app/models/chat/message_reaction.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageReaction < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageReaction < ActiveRecord::Base + self.table_name = "chat_message_reactions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_message_revision.rb b/plugins/chat/app/models/chat/message_revision.rb similarity index 74% rename from plugins/chat/app/models/chat_message_revision.rb rename to plugins/chat/app/models/chat/message_revision.rb index e13cf507e17..3b01ee03339 100644 --- a/plugins/chat/app/models/chat_message_revision.rb +++ b/plugins/chat/app/models/chat/message_revision.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageRevision < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageRevision < ActiveRecord::Base + self.table_name = "chat_message_revisions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/reviewable_message.rb b/plugins/chat/app/models/chat/reviewable_message.rb new file mode 100644 index 00000000000..a7a0b4713e1 --- /dev/null +++ b/plugins/chat/app/models/chat/reviewable_message.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Chat + class ReviewableMessage < Reviewable + def serializer + Chat::ReviewableMessageSerializer + end + + 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, + } + end + + def self.score_to_silence_user + sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) + end + + def chat_message + @chat_message ||= (target || Chat::Message.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") + + unless chat_message.deleted_at? + build_action(actions, :delete_and_agree, icon: "far-trash-alt") + end + 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? + + UserSilencer.unsilence(chat_message_creator) + + 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 +end diff --git a/plugins/chat/app/models/chat/thread.rb b/plugins/chat/app/models/chat/thread.rb new file mode 100644 index 00000000000..2dc62e7df10 --- /dev/null +++ b/plugins/chat/app/models/chat/thread.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Chat + class Thread < ActiveRecord::Base + EXCERPT_LENGTH = 150 + MAX_TITLE_LENGTH = 100 + + include Chat::ThreadCache + + self.table_name = "chat_threads" + + belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel" + belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" + belongs_to :original_message, + -> { with_deleted }, + foreign_key: "original_message_id", + class_name: "Chat::Message" + + has_many :chat_messages, + -> { + where("deleted_at IS NULL").order( + "chat_messages.created_at ASC, chat_messages.id ASC", + ) + }, + foreign_key: :thread_id, + primary_key: :id, + class_name: "Chat::Message" + has_many :user_chat_thread_memberships + belongs_to :last_message, + class_name: "Chat::Message", + foreign_key: :last_message_id, + optional: true + + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + + validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH } + + # Since the `replies` for the thread can all be deleted, to avoid errors + # in lists and previews of the thread, we can consider the original message + # as the last message in this case as a fallback. + before_create { self.last_message_id = self.original_message_id } + + def add(user) + Chat::UserChatThreadMembership.find_or_create_by!(user: user, thread: self) + end + + def remove(user) + Chat::UserChatThreadMembership.find_by(user: user, thread: self)&.destroy + end + + def membership_for(user) + user_chat_thread_memberships.find_by(user: user) + end + + def mark_read_for_user!(user, last_read_message_id: nil) + membership_for(user)&.update!( + last_read_message_id: last_read_message_id || self.last_message_id, + ) + end + + def replies + self.chat_messages.where.not(id: self.original_message_id).order("created_at ASC, id ASC") + end + + def url + "#{channel.url}/t/#{self.id}" + end + + def relative_url + "#{channel.relative_url}/t/#{self.id}" + end + + def excerpt + original_message.excerpt(max_length: EXCERPT_LENGTH) + end + + def update_last_message_id! + self.update!(last_message_id: self.latest_not_deleted_message_id) + end + + def latest_not_deleted_message_id(anchor_message_id: nil) + DB.query_single( + <<~SQL, + SELECT id FROM chat_messages + WHERE chat_channel_id = :channel_id + AND thread_id = :thread_id + AND deleted_at IS NULL + #{anchor_message_id ? "AND id < :anchor_message_id" : ""} + ORDER BY created_at DESC, id DESC + LIMIT 1 + SQL + channel_id: self.channel_id, + thread_id: self.id, + anchor_message_id: anchor_message_id, + ).first + end + + def self.grouped_messages(thread_ids: nil, message_ids: nil, include_original_message: true) + DB.query(<<~SQL, message_ids: message_ids, thread_ids: thread_ids) + SELECT thread_id, + array_agg(chat_messages.id ORDER BY chat_messages.created_at, chat_messages.id) AS thread_message_ids, + chat_threads.original_message_id + FROM chat_messages + INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + WHERE thread_id IS NOT NULL + #{thread_ids ? "AND thread_id IN (:thread_ids)" : ""} + #{message_ids ? "AND chat_messages.id IN (:message_ids)" : ""} + #{include_original_message ? "" : "AND chat_messages.id != chat_threads.original_message_id"} + GROUP BY thread_id, chat_threads.original_message_id; + SQL + end + + def self.ensure_consistency! + update_counts + end + + def self.update_counts + # NOTE: Chat::Thread#replies_count is not updated every time + # a message is created or deleted in a channel, the UI will lag + # behind unless it is kept in sync with MessageBus. The count + # has 1 subtracted from it to account for the original message. + # + # It is updated eventually via Jobs::Chat::PeriodicalUpdates. In + # future we may want to update this more frequently. + updated_thread_ids = DB.query_single <<~SQL + UPDATE chat_threads threads + SET replies_count = subquery.replies_count + FROM ( + SELECT COUNT(*) - 1 AS replies_count, thread_id + FROM chat_messages + WHERE chat_messages.deleted_at IS NULL AND thread_id IS NOT NULL + GROUP BY thread_id + ) subquery + WHERE threads.id = subquery.thread_id + AND subquery.replies_count != threads.replies_count + RETURNING threads.id AS thread_id; + SQL + return if updated_thread_ids.empty? + self.clear_caches!(updated_thread_ids) + end + end +end + +# == Schema Information +# +# Table name: chat_threads +# +# id :bigint not null, primary key +# channel_id :integer not null +# original_message_id :integer not null +# original_message_user_id :integer not null +# status :integer default("open"), not null +# title :string +# created_at :datetime not null +# updated_at :datetime not null +# replies_count :integer default(0), not null +# last_message_id :bigint +# +# Indexes +# +# index_chat_threads_on_channel_id (channel_id) +# index_chat_threads_on_channel_id_and_status (channel_id,status) +# index_chat_threads_on_last_message_id (last_message_id) +# index_chat_threads_on_original_message_id (original_message_id) +# index_chat_threads_on_original_message_user_id (original_message_user_id) +# index_chat_threads_on_replies_count (replies_count) +# index_chat_threads_on_status (status) +# diff --git a/plugins/chat/app/models/chat/threads_view.rb b/plugins/chat/app/models/chat/threads_view.rb new file mode 100644 index 00000000000..fc98ea8ace9 --- /dev/null +++ b/plugins/chat/app/models/chat/threads_view.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Chat + class ThreadsView + attr_reader :user, :channel, :threads, :tracking, :memberships, :load_more_url + + def initialize(channel:, threads:, user:, tracking:, memberships:, load_more_url:) + @channel = channel + @threads = threads + @user = user + @tracking = tracking + @memberships = memberships + @load_more_url = load_more_url + end + end +end diff --git a/plugins/chat/app/models/chat/tracking_state_report.rb b/plugins/chat/app/models/chat/tracking_state_report.rb new file mode 100644 index 00000000000..b15f273f9c3 --- /dev/null +++ b/plugins/chat/app/models/chat/tracking_state_report.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Chat + # Represents a report of the tracking state for a user + # across threads and channels. This is returned by + # Chat::TrackingStateReportQuery. + class TrackingStateReport + attr_accessor :channel_tracking, :thread_tracking + + class TrackingStateInfo + attr_accessor :unread_count, :mention_count, :last_reply_created_at + + def initialize(info) + @unread_count = info.present? ? info[:unread_count] : 0 + @mention_count = info.present? ? info[:mention_count] : 0 + @last_reply_created_at = info.present? ? info[:last_reply_created_at] : nil + end + + def to_hash + to_h + end + + def to_h + { + unread_count: unread_count, + mention_count: mention_count, + last_reply_created_at: last_reply_created_at, + } + end + end + + def initialize + @channel_tracking = {} + @thread_tracking = {} + end + + def find_channel(channel_id) + TrackingStateInfo.new(channel_tracking[channel_id]) + end + + def find_thread(thread_id) + TrackingStateInfo.new(thread_tracking[thread_id]) + end + + def find_channel_threads(channel_id) + thread_tracking.inject({}) do |result, (thread_id, thread)| + if thread[:channel_id] == channel_id + result.merge(thread_id => TrackingStateInfo.new(thread)) + else + result + end + end + end + + def find_channel_thread_overviews(channel_id) + thread_tracking.inject({}) do |result, (thread_id, thread)| + if thread[:channel_id] == channel_id + result.merge(thread_id => thread[:last_reply_created_at]) + else + result + end + end + end + + def thread_unread_overview_by_channel + thread_tracking.inject({}) do |acc, tt| + thread_id = tt.first + data = tt.second + + acc[data[:channel_id]] = {} if !acc[data[:channel_id]] + acc[data[:channel_id]][thread_id] = data[:last_reply_created_at] + acc + end + end + end +end diff --git a/plugins/chat/app/models/user_chat_channel_membership.rb b/plugins/chat/app/models/chat/user_chat_channel_membership.rb similarity index 63% rename from plugins/chat/app/models/user_chat_channel_membership.rb rename to plugins/chat/app/models/chat/user_chat_channel_membership.rb index 643dcdb1a6e..c2e59caa7b2 100644 --- a/plugins/chat/app/models/user_chat_channel_membership.rb +++ b/plugins/chat/app/models/chat/user_chat_channel_membership.rb @@ -1,18 +1,19 @@ # frozen_string_literal: true -class UserChatChannelMembership < ActiveRecord::Base - NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } +module Chat + class UserChatChannelMembership < ActiveRecord::Base + self.table_name = "user_chat_channel_memberships" - belongs_to :user - belongs_to :chat_channel - belongs_to :last_read_message, class_name: "ChatMessage", optional: true + NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } - 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 } + belongs_to :user + belongs_to :last_read_message, class_name: "Chat::Message", optional: true + belongs_to :chat_channel, class_name: "Chat::Channel", foreign_key: :chat_channel_id - attribute :unread_count, default: 0 - attribute :unread_mentions, default: 0 + 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 } + end end # == Schema Information @@ -31,6 +32,7 @@ end # updated_at :datetime not null # last_unread_mention_when_emailed_id :integer # join_mode :integer default("manual"), not null +# last_viewed_at :datetime not null # # Indexes # diff --git a/plugins/chat/app/models/chat/user_chat_thread_membership.rb b/plugins/chat/app/models/chat/user_chat_thread_membership.rb new file mode 100644 index 00000000000..49d2bdfd181 --- /dev/null +++ b/plugins/chat/app/models/chat/user_chat_thread_membership.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + class UserChatThreadMembership < ActiveRecord::Base + self.table_name = "user_chat_thread_memberships" + + belongs_to :user + belongs_to :last_read_message, class_name: "Chat::Message", optional: true + belongs_to :thread, class_name: "Chat::Thread", foreign_key: :thread_id + + enum :notification_level, Chat::NotificationLevels.all + end +end + +# == Schema Information +# +# Table name: user_chat_thread_memberships +# +# id :bigint not null, primary key +# user_id :bigint not null +# thread_id :bigint not null +# last_read_message_id :bigint +# notification_level :integer default("tracking"), not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# user_chat_thread_unique_memberships (user_id,thread_id) UNIQUE +# diff --git a/plugins/chat/app/models/chat_webhook_event.rb b/plugins/chat/app/models/chat/webhook_event.rb similarity index 58% rename from plugins/chat/app/models/chat_webhook_event.rb rename to plugins/chat/app/models/chat/webhook_event.rb index acda4ffd9b0..fe2aecb2bcf 100644 --- a/plugins/chat/app/models/chat_webhook_event.rb +++ b/plugins/chat/app/models/chat/webhook_event.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true -class ChatWebhookEvent < ActiveRecord::Base - belongs_to :chat_message - belongs_to :incoming_chat_webhook +module Chat + class WebhookEvent < ActiveRecord::Base + self.table_name = "chat_webhook_events" - delegate :username, to: :incoming_chat_webhook - delegate :emoji, to: :incoming_chat_webhook + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :incoming_chat_webhook, class_name: "Chat::IncomingWebhook" + + delegate :username, to: :incoming_chat_webhook + delegate :emoji, to: :incoming_chat_webhook + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb deleted file mode 100644 index 389a16a4c12..00000000000 --- a/plugins/chat/app/models/chat_channel.rb +++ /dev/null @@ -1,175 +0,0 @@ -# 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, if: :slug_changed? - 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}/chat/channel/#{self.id}/#{self.slug || "-"}" - end - - def relative_url - "#{Discourse.base_path}/chat/channel/#{self.id}/#{self.slug || "-"}" - end - - def self.ensure_consistency! - update_counts - end - - # TODO (martin) Move UpdateUserCountsForChatChannels into here - def self.update_counts - # NOTE: ChatChannel#messages_count is not updated every time - # a message is created or deleted in a channel, so it should not - # be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates - DB.exec <<~SQL - UPDATE chat_channels channels - SET messages_count = subquery.messages_count - FROM ( - SELECT COUNT(*) AS messages_count, chat_channel_id - FROM chat_messages - WHERE chat_messages.deleted_at IS NULL - GROUP BY chat_channel_id - ) subquery - WHERE channels.id = subquery.chat_channel_id - AND channels.deleted_at IS NULL - AND subquery.messages_count != channels.messages_count - SQL - 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 -# allow_channel_wide_mentions :boolean default(TRUE), not null -# user_count_stale :boolean default(FALSE), not null -# slug :string -# type :string -# -# Indexes -# -# index_chat_channels_on_messages_count (messages_count) -# 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) UNIQUE -# index_chat_channels_on_status (status) -# diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb deleted file mode 100644 index f7688a8a13d..00000000000 --- a/plugins/chat/app/models/chat_message.rb +++ /dev/null @@ -1,257 +0,0 @@ -# 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 { ensure_last_editor_id } - - def validate_message(has_uploads:) - WatchedWordsValidator.new(attributes: [:message]).validate(self) - - if self.new_record? || self.changed.include?("message") - Chat::DuplicateMessageValidator.new(self).validate - end - - 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 - - if message_too_long? - self.errors.add( - :base, - I18n.t("chat.errors.message_too_long", maximum: SiteSetting.chat_maximum_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 - ensure_last_editor_id - - self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) - self.cooked_version = BAKED_VERSION - end - - def rebake!(invalidate_oneboxes: false, priority: nil) - ensure_last_editor_id - - previous_cooked = self.cooked - new_cooked = - self.class.cook( - message, - invalidate_oneboxes: invalidate_oneboxes, - user_id: self.last_editor_id, - ) - 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 - hashtag-autocomplete - 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 = {}) - # A rule in our Markdown pipeline may have Guardian checks that require a - # user to be present. The last editing user of the message will be more - # generally up to date than the creating user. For example, we use - # this when cooking #hashtags to determine whether we should render - # the found hashtag based on whether the user can access the channel it - # is referencing. - cooked = - PrettyText.cook( - message, - features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, - markdown_it_rules: MARKDOWN_IT_RULES, - force_quote_link: true, - user_id: opts[:user_id], - hashtag_context: "chat-composer", - ) - - 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 - - def message_too_long? - message.length > SiteSetting.chat_maximum_message_length - end - - def ensure_last_editor_id - self.last_editor_id ||= self.user_id - 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_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) -# index_chat_messages_on_last_editor_id (last_editor_id) -# diff --git a/plugins/chat/app/models/chat_upload.rb b/plugins/chat/app/models/chat_upload.rb deleted file mode 100644 index 3382328bfd7..00000000000 --- a/plugins/chat/app/models/chat_upload.rb +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index 9df0df18ddf..00000000000 --- a/plugins/chat/app/models/chat_view.rb +++ /dev/null @@ -1,87 +0,0 @@ -# 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/concerns/chat/chatable.rb b/plugins/chat/app/models/concerns/chat/chatable.rb new file mode 100644 index 00000000000..02614235428 --- /dev/null +++ b/plugins/chat/app/models/concerns/chat/chatable.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Chat + 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 + + def create_chat_channel(**args) + channel_class.create(args.merge(chatable: self)) + end + + private + + def channel_class + case self + when Chat::DirectMessage + Chat::DirectMessageChannel + when Category + Chat::CategoryChannel + else + raise("Unknown chatable #{self}") + end + end + end +end diff --git a/plugins/chat/app/models/concerns/chat/thread_cache.rb b/plugins/chat/app/models/concerns/chat/thread_cache.rb new file mode 100644 index 00000000000..97fbad5a4da --- /dev/null +++ b/plugins/chat/app/models/concerns/chat/thread_cache.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Chat + module ThreadCache + extend ActiveSupport::Concern + + class_methods do + def replies_count_cache_updated_at_redis_key(id) + "chat_thread:replies_count_cache_updated_at:#{id}" + end + + def replies_count_cache_redis_key(id) + "chat_thread:replies_count_cache:#{id}" + end + + def clear_caches!(ids) + ids = Array.wrap(ids) + keys_to_delete = + ids + .map do |id| + [replies_count_cache_redis_key(id), replies_count_cache_updated_at_redis_key(id)] + end + .flatten + Discourse.redis.del(keys_to_delete) + end + end + + def replies_count_cache_recently_updated? + replies_count_cache_updated_at.after?(5.minutes.ago) + end + + def replies_count_cache_updated_at + Time.at( + Discourse.redis.get(Chat::Thread.replies_count_cache_updated_at_redis_key(self.id)).to_i, + in: Time.zone, + ) + end + + def replies_count_cache + redis_cache = Discourse.redis.get(Chat::Thread.replies_count_cache_redis_key(self.id))&.to_i + + # If the cache is not present for whatever reason, set it to the current value, + # otherwise INCR/DECR will be way off. No need to enqueue the job or publish, + # since this is likely fetched by a serializer. + if !redis_cache.present? + set_replies_count_redis_cache(self.replies_count) + self.replies_count + else + redis_cache != self.replies_count ? redis_cache : self.replies_count + end + end + + def set_replies_count_cache(value, update_db: false) + self.update!(replies_count: value) if update_db + set_replies_count_redis_cache(value) + thread_reply_count_cache_changed + end + + def set_replies_count_redis_cache(value) + Discourse.redis.setex( + Chat::Thread.replies_count_cache_redis_key(self.id), + 5.minutes.from_now.to_i, + value, + ) + end + + def increment_replies_count_cache + Discourse.redis.incr(Chat::Thread.replies_count_cache_redis_key(self.id)) + thread_reply_count_cache_changed + end + + def decrement_replies_count_cache + Discourse.redis.decr(Chat::Thread.replies_count_cache_redis_key(self.id)) + thread_reply_count_cache_changed + end + + def thread_reply_count_cache_changed + Jobs.enqueue_in(5.seconds, Jobs::Chat::UpdateThreadReplyCount, thread_id: self.id) + end + end +end diff --git a/plugins/chat/app/models/concerns/chat/type_mappable.rb b/plugins/chat/app/models/concerns/chat/type_mappable.rb new file mode 100644 index 00000000000..ab8dff1a799 --- /dev/null +++ b/plugins/chat/app/models/concerns/chat/type_mappable.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Chat + module TypeMappable + extend ActiveSupport::Concern + + class_methods do + def sti_class_mapping = {} + def polymorphic_class_mapping = {} + + # the model used when loading type column + def sti_class_for(name) + sti_class_mapping[name] || super + end + + # the type column value + def sti_name + sti_class_mapping.invert[self] || super + end + + # the model used when loading *_type column (e.g. 'chatable_type') + def polymorphic_class_for(name) + polymorphic_class_mapping[name] || super + end + + # the *_type column value (e.g. 'chatable_type') + def polymorphic_name + polymorphic_class_mapping.invert[self] || super + end + end + end +end diff --git a/plugins/chat/app/models/concerns/chatable.rb b/plugins/chat/app/models/concerns/chatable.rb deleted file mode 100644 index 2128a7cf4e4..00000000000 --- a/plugins/chat/app/models/concerns/chatable.rb +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 3d6222a4a9b..00000000000 --- a/plugins/chat/app/models/deleted_chat_user.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 40ad99c4723..00000000000 --- a/plugins/chat/app/models/direct_message.rb +++ /dev/null @@ -1,56 +0,0 @@ -# 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 deleted file mode 100644 index 9d116643d7e..00000000000 --- a/plugins/chat/app/models/direct_message_channel.rb +++ /dev/null @@ -1,29 +0,0 @@ -# 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/reviewable_chat_message.rb b/plugins/chat/app/models/reviewable_chat_message.rb deleted file mode 100644 index 75f03f305c7..00000000000 --- a/plugins/chat/app/models/reviewable_chat_message.rb +++ /dev/null @@ -1,149 +0,0 @@ -# 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, - } - 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? - - UserSilencer.unsilence(chat_message_creator) - - 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/policies/chat/direct_message_channel/can_communicate_all_parties_policy.rb b/plugins/chat/app/policies/chat/direct_message_channel/can_communicate_all_parties_policy.rb new file mode 100644 index 00000000000..76b7ef5a83f --- /dev/null +++ b/plugins/chat/app/policies/chat/direct_message_channel/can_communicate_all_parties_policy.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy < PolicyBase + delegate :target_users, :user_comm_screener, to: :context + + def call + acting_user_can_message_all_target_users? && + acting_user_not_preventing_messages_from_any_target_users? && + acting_user_not_ignoring_any_target_users? && acting_user_not_muting_any_target_users? + end + + def reason + if !acting_user_can_message_all_target_users? + I18n.t("chat.errors.not_accepting_dms", username: actor_cannot_message_user.username) + elsif !acting_user_not_preventing_messages_from_any_target_users? + I18n.t( + "chat.errors.actor_preventing_target_user_from_dm", + username: actor_disallowing_pm_user.username, + ) + elsif !acting_user_not_ignoring_any_target_users? + I18n.t("chat.errors.actor_ignoring_target_user", username: actor_ignoring_user.username) + elsif !acting_user_not_muting_any_target_users? + I18n.t("chat.errors.actor_muting_target_user", username: actor_muting_user.username) + end + end + + private + + def acting_user_can_message_all_target_users? + @acting_user_can_message_all_target_users ||= + user_comm_screener.preventing_actor_communication.none? + end + + def acting_user_not_preventing_messages_from_any_target_users? + @acting_user_not_preventing_messages_from_any_target_users ||= + !user_comm_screener.actor_disallowing_any_pms?(target_users_without_self.map(&:id)) + end + + def acting_user_not_ignoring_any_target_users? + @acting_user_not_ignoring_any_target_users ||= actor_ignoring_user.blank? + end + + def acting_user_not_muting_any_target_users? + @acting_user_not_muting_any_target_users ||= actor_muting_user.blank? + end + + def actor_cannot_message_user + target_users_without_self.find do |user| + user.id == user_comm_screener.preventing_actor_communication.first + end + end + + def actor_disallowing_pm_user + target_users_without_self.find do |target_user| + user_comm_screener.actor_disallowing_pms?(target_user.id) + end + end + + def actor_ignoring_user + target_users_without_self.find do |target_user| + user_comm_screener.actor_ignoring?(target_user.id) + end + end + + def actor_muting_user + target_users_without_self.find do |target_user| + user_comm_screener.actor_muting?(target_user.id) + end + end + + def target_users_without_self + @target_users_without_self ||= target_users.reject { |user| user.id == guardian.user.id } + end +end diff --git a/plugins/chat/app/policies/chat/direct_message_channel/max_users_excess_policy.rb b/plugins/chat/app/policies/chat/direct_message_channel/max_users_excess_policy.rb new file mode 100644 index 00000000000..86124ff2435 --- /dev/null +++ b/plugins/chat/app/policies/chat/direct_message_channel/max_users_excess_policy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Chat::DirectMessageChannel::MaxUsersExcessPolicy < PolicyBase + delegate :target_users, to: :context + + def call + guardian.is_staff? || + target_users_without_self.size <= SiteSetting.chat_max_direct_message_users + end + + def reason + return I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self") if no_dm? + I18n.t( + "chat.errors.over_chat_max_direct_message_users", + count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting user + ) + end + + private + + def no_dm? + SiteSetting.chat_max_direct_message_users.zero? + end + + def target_users_without_self + target_users.reject { |user| user.id == guardian.user.id } + end +end diff --git a/plugins/chat/app/policies/policy_base.rb b/plugins/chat/app/policies/policy_base.rb new file mode 100644 index 00000000000..90f79aa106a --- /dev/null +++ b/plugins/chat/app/policies/policy_base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PolicyBase + attr_reader :context + + delegate :guardian, to: :context + + def initialize(context) + @context = context + end + + def call + raise "Not implemented" + end + + def reason + raise "Not implemented" + end +end 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 00000000000..294ca724c89 --- /dev/null +++ b/plugins/chat/app/queries/chat/channel_memberships_query.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipsQuery + def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) + query = + Chat::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: channel, count_only: true) + end + end +end diff --git a/plugins/chat/app/queries/chat/channel_unreads_query.rb b/plugins/chat/app/queries/chat/channel_unreads_query.rb new file mode 100644 index 00000000000..972ce7942fd --- /dev/null +++ b/plugins/chat/app/queries/chat/channel_unreads_query.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Chat + ## + # Handles counting unread messages and mentions for a list of channels. + # This is used for unread indicators in the chat UI. By default only the + # channels that the user is a member of will be counted and returned in + # the result. + class ChannelUnreadsQuery + # NOTE: This is arbitrary at this point in time, we may want to increase + # or decrease this as we find performance issues. + MAX_CHANNELS = 1000 + + ## + # @param channel_ids [Array] The IDs of the channels to count. + # @param user_id [Integer] The ID of the user to count for. + # @param include_missing_memberships [Boolean] Whether to include channels + # that the user is not a member of. These counts will always be 0. + # @param include_read [Boolean] Whether to include channels that the user + # is a member of where they have read all the messages. This overrides + # include_missing_memberships. + def self.call(channel_ids:, user_id:, include_missing_memberships: false, include_read: true) + sql = <<~SQL + SELECT ( + SELECT COUNT(*) AS unread_count + FROM chat_messages + INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + LEFT JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + WHERE chat_channels.id = memberships.chat_channel_id + AND user_chat_channel_memberships.user_id = :user_id + AND chat_messages.id > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND chat_messages.deleted_at IS NULL + AND (chat_messages.thread_id IS NULL OR chat_messages.id = chat_threads.original_message_id) + AND NOT user_chat_channel_memberships.muted + ) AS unread_count, + ( + SELECT COUNT(*) AS mention_count + FROM notifications + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.user_id = :user_id + INNER JOIN chat_messages ON (data::json->>'chat_message_id')::bigint = chat_messages.id + LEFT JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + WHERE NOT read + AND user_chat_channel_memberships.chat_channel_id = memberships.chat_channel_id + AND notifications.user_id = :user_id + AND notifications.notification_type = :notification_type + AND (data::json->>'chat_message_id')::bigint > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND (data::json->>'chat_channel_id')::bigint = memberships.chat_channel_id + AND (chat_messages.thread_id IS NULL OR chat_messages.id = chat_threads.original_message_id) + ) AS mention_count, + memberships.chat_channel_id AS channel_id + FROM user_chat_channel_memberships AS memberships + WHERE memberships.user_id = :user_id AND memberships.chat_channel_id IN (:channel_ids) + GROUP BY memberships.chat_channel_id + #{include_missing_memberships ? "" : "LIMIT :limit"} + SQL + + sql = <<~SQL if !include_read + SELECT * FROM ( + #{sql} + ) AS channel_tracking + WHERE (unread_count > 0 OR mention_count > 0) + SQL + + sql += <<~SQL if include_missing_memberships && include_read + UNION ALL + SELECT 0 AS unread_count, 0 AS mention_count, chat_channels.id AS channel_id + FROM chat_channels + LEFT JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + AND user_chat_channel_memberships.user_id = :user_id + WHERE chat_channels.id IN (:channel_ids) AND user_chat_channel_memberships.id IS NULL + GROUP BY chat_channels.id + LIMIT :limit + SQL + + DB.query( + sql, + channel_ids: channel_ids, + user_id: user_id, + notification_type: Notification.types[:chat_mention], + limit: MAX_CHANNELS, + ) + end + end +end diff --git a/plugins/chat/app/queries/chat/messages_query.rb b/plugins/chat/app/queries/chat/messages_query.rb new file mode 100644 index 00000000000..8a888ec6a22 --- /dev/null +++ b/plugins/chat/app/queries/chat/messages_query.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module Chat + # Queries messages for a specific channel. This can be used in two modes: + # + # 1. Query messages around a target_message_id or target_date. This is used + # when a user needs to jump to the middle of a messages stream or load + # around a target. There is no pagination or direction + # here, just a limit on past and future messages. + # 2. Query messages with paginations and direction. This is used for normal + # scrolling of the messages stream of a channel. + # + # In both scenarios a thread_id can be provided to only get messages related + # to that thread within the channel. + # + # It is assumed that the user's permission to view the channel has already been + # established by the caller. + class MessagesQuery + PAST_MESSAGE_LIMIT = 25 + FUTURE_MESSAGE_LIMIT = 25 + PAST = "past" + FUTURE = "future" + VALID_DIRECTIONS = [PAST, FUTURE] + MAX_PAGE_SIZE = 50 + + # @param channel [Chat::Channel] The channel to query messages within. + # @param guardian [Guardian] The guardian to use for permission checks. + # @param thread_id [Integer] (optional) The thread ID to filter messages by. + # @param target_message_id [Integer] (optional) The message ID to query around. + # It is assumed that the caller already checked if this exists. + # @param target_date [String] (optional) The date to query around. + # @param include_thread_messages [Boolean] (optional) Whether to include messages + # that are linked to a thread. + # @param page_size [Integer] (optional) The number of messages to fetch when not + # using the target_message_id param. + # @param direction [String] (optional) The direction to fetch messages in when not + # using the target_message_id param. Must be valid. If not provided, only the + # latest messages for the channel are loaded. + def self.call( + channel:, + guardian:, + thread_id: nil, + target_message_id: nil, + include_thread_messages: false, + page_size: PAST_MESSAGE_LIMIT + FUTURE_MESSAGE_LIMIT, + direction: nil, + target_date: nil + ) + messages = base_query(channel: channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(channel.chatable) + if thread_id.present? + include_thread_messages = true + messages = messages.where(thread_id: thread_id) + end + messages = messages.where(<<~SQL, channel_id: channel.id) if !include_thread_messages + chat_messages.thread_id IS NULL OR chat_messages.id IN ( + SELECT original_message_id + FROM chat_threads + WHERE chat_threads.channel_id = :channel_id + ) + SQL + + if target_message_id.present? && direction.blank? + query_around_target(target_message_id, channel, messages) + else + if target_date.present? + query_by_date(target_date, channel, messages) + else + query_paginated_messages(direction, page_size, channel, messages, target_message_id) + end + end + end + + def self.base_query(channel:) + query = + Chat::Message + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(user: :primary_group) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + .includes(thread: %i[original_message last_message]) + .where(chat_channel_id: channel.id) + + if SiteSetting.enable_user_status + query = query.includes(user: :user_status) + query = query.includes(chat_mentions: { user: :user_status }) + else + query = query.includes(chat_mentions: :user) + end + + query + end + + def self.query_around_target(target_message_id, channel, messages) + target_message = base_query(channel: channel).with_deleted.find_by(id: target_message_id) + + past_messages = + messages + .where("created_at < ?", target_message.created_at) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + .to_a + + future_messages = + messages + .where("created_at > ?", target_message.created_at) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + .to_a + + can_load_more_past = past_messages.size == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.size == FUTURE_MESSAGE_LIMIT + + { + past_messages: past_messages, + future_messages: future_messages, + target_message: target_message, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + } + end + + def self.query_paginated_messages( + direction, + page_size, + channel, + messages, + target_message_id = nil + ) + page_size = [page_size || MAX_PAGE_SIZE, MAX_PAGE_SIZE].min + + if target_message_id.present? + condition = direction == PAST ? "<" : ">" + messages = messages.where("id #{condition} ?", target_message_id.to_i) + end + + order = direction == FUTURE ? "ASC" : "DESC" + messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a + + 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 + + { + messages: direction == FUTURE ? messages : messages.reverse, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + } + end + + def self.query_by_date(target_date, channel, messages) + past_messages = + messages + .where("created_at <= ?", target_date) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + .to_a + + future_messages = + messages + .where("created_at > ?", target_date) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + .to_a + + can_load_more_past = past_messages.size == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.size == FUTURE_MESSAGE_LIMIT + + { + target_message_id: future_messages.first&.id, + past_messages: past_messages, + future_messages: future_messages, + target_date: target_date, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + } + end + end +end diff --git a/plugins/chat/app/queries/chat/thread_participant_query.rb b/plugins/chat/app/queries/chat/thread_participant_query.rb new file mode 100644 index 00000000000..7c1d7d0488a --- /dev/null +++ b/plugins/chat/app/queries/chat/thread_participant_query.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Chat + # Builds a query to find the total count of participants for one + # or more threads (on a per-thread basis), as well as up to 3 + # participants in the thread. The participants will be made up + # of: + # + # - Participant 1 & 2 - The most frequent participants in the thread. + # - Participant 3 - The most recent participant in the thread. + # + # This result should be cached to avoid unnecessary queries, + # since the participants will not often change for a thread, + # and if there is a delay in updating them based on message + # count it is not a big deal. + class ThreadParticipantQuery + # @param thread_ids [Array] The IDs of the threads to query. + # @return [Hash] A hash of thread IDs to participant data. + def self.call(thread_ids:) + return {} if thread_ids.blank? + + # We only want enough data for BasicUserSerializer, since the participants + # are just showing username & avatar. + thread_participant_stats = DB.query(<<~SQL, thread_ids: thread_ids) + SELECT thread_participant_stats.*, users.username, users.name, users.uploaded_avatar_id FROM ( + SELECT chat_messages.thread_id, chat_messages.user_id, COUNT(*) AS message_count, + ROW_NUMBER() OVER (PARTITION BY chat_messages.thread_id ORDER BY COUNT(*) DESC) AS row_number + FROM chat_messages + INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id + AND user_chat_thread_memberships.user_id = chat_messages.user_id + WHERE chat_messages.thread_id IN (:thread_ids) + AND chat_messages.deleted_at IS NULL + GROUP BY chat_messages.thread_id, chat_messages.user_id + ) AS thread_participant_stats + INNER JOIN users ON users.id = thread_participant_stats.user_id + ORDER BY thread_participant_stats.thread_id ASC, thread_participant_stats.message_count DESC, thread_participant_stats.user_id ASC + SQL + + most_recent_participants = DB.query(<<~SQL, thread_ids: thread_ids) + SELECT DISTINCT ON (thread_id) chat_messages.thread_id, chat_messages.user_id, + users.username, users.name, users.uploaded_avatar_id + FROM chat_messages + INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id + AND user_chat_thread_memberships.user_id = chat_messages.user_id + INNER JOIN users ON users.id = chat_messages.user_id + WHERE chat_messages.thread_id IN (:thread_ids) + AND chat_messages.deleted_at IS NULL + ORDER BY chat_messages.thread_id ASC, chat_messages.created_at DESC + SQL + most_recent_participants = + most_recent_participants.reduce({}) do |hash, mrm| + hash[mrm.thread_id] = { + id: mrm.user_id, + username: mrm.username, + name: mrm.name, + uploaded_avatar_id: mrm.uploaded_avatar_id, + } + hash + end + + thread_participants = {} + thread_participant_stats.each do |thread_participant_stat| + thread_id = thread_participant_stat.thread_id + thread_participants[thread_id] ||= {} + thread_participants[thread_id][:users] ||= [] + thread_participants[thread_id][:total_count] ||= 0 + + # If we want to return more of the top N users in the thread we + # can just increase the number here. + if thread_participants[thread_id][:users].length < 2 && + thread_participant_stat.user_id != most_recent_participants[thread_id][:id] + thread_participants[thread_id][:users].push( + { + id: thread_participant_stat.user_id, + username: thread_participant_stat.username, + name: thread_participant_stat.name, + uploaded_avatar_id: thread_participant_stat.uploaded_avatar_id, + }, + ) + end + + thread_participants[thread_id][:total_count] += 1 + end + + # Always put the most recent participant at the end of the array. + most_recent_participants.each do |thread_id, user| + thread_participants[thread_id][:users].push(user) + end + + thread_participants + end + end +end diff --git a/plugins/chat/app/queries/chat/thread_unreads_query.rb b/plugins/chat/app/queries/chat/thread_unreads_query.rb new file mode 100644 index 00000000000..fc724ff440e --- /dev/null +++ b/plugins/chat/app/queries/chat/thread_unreads_query.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Chat + ## + # Handles counting unread messages scoped to threads for a list + # of channels. A list of thread IDs can be provided to further focus the query. + # Alternatively, a list of thread IDs can be provided by itself to only get + # specific threads regardless of channel. + # + # This is used for unread indicators in the chat UI. By default only the + # threads that the user is a member of will be counted and returned in + # the result. Only threads inside a channel that has threading_enabled + # will be counted. + class ThreadUnreadsQuery + # NOTE: This is arbitrary at this point in time, we may want to increase + # or decrease this as we find performance issues. + MAX_THREADS = 3000 + + ## + # @param channel_ids [Array] (Optional) The IDs of the channels to count threads for. + # If only this is provided, all threads across the channels provided will be counted. + # @param thread_ids [Array] (Optional) The IDs of the threads to count. If this + # is used in tandem with channel_ids, it will just further filter the results of + # the thread counts from those channels. + # @param user_id [Integer] The ID of the user to count for. + # @param include_missing_memberships [Boolean] Whether to include threads + # that the user is not a member of. These counts will always be 0. + # @param include_read [Boolean] Whether to include threads that the user + # is a member of where they have read all the messages. This overrides + # include_missing_memberships. + def self.call( + channel_ids: nil, + thread_ids: nil, + user_id:, + include_missing_memberships: false, + include_read: true + ) + return [] if channel_ids.blank? && thread_ids.blank? + + sql = <<~SQL + SELECT ( + SELECT COUNT(*) AS unread_count + FROM chat_messages + INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id + INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id AND chat_threads.channel_id = chat_messages.chat_channel_id + INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id + INNER JOIN chat_messages AS original_message ON original_message.id = chat_threads.original_message_id + AND chat_messages.thread_id = memberships.thread_id + AND chat_messages.user_id != :user_id + AND user_chat_thread_memberships.user_id = :user_id + AND chat_messages.id > COALESCE(user_chat_thread_memberships.last_read_message_id, 0) + AND chat_messages.deleted_at IS NULL + AND chat_messages.thread_id IS NOT NULL + AND chat_messages.id != chat_threads.original_message_id + AND chat_channels.threading_enabled + AND user_chat_thread_memberships.notification_level NOT IN (:quiet_notification_levels) + AND original_message.deleted_at IS NULL + ) AS unread_count, + 0 AS mention_count, + chat_threads.channel_id, + memberships.thread_id + FROM user_chat_thread_memberships AS memberships + INNER JOIN chat_threads ON chat_threads.id = memberships.thread_id + WHERE memberships.user_id = :user_id + #{channel_ids.present? ? "AND chat_threads.channel_id IN (:channel_ids)" : ""} + #{thread_ids.present? ? "AND chat_threads.id IN (:thread_ids)" : ""} + GROUP BY memberships.thread_id, chat_threads.channel_id + #{include_missing_memberships ? "" : "LIMIT :limit"} + SQL + + sql = <<~SQL if !include_read + SELECT * FROM ( + #{sql} + ) AS thread_tracking + WHERE (unread_count > 0 OR mention_count > 0) + SQL + + sql += <<~SQL if include_missing_memberships && include_read + UNION ALL + SELECT 0 AS unread_count, 0 AS mention_count, chat_threads.channel_id, chat_threads.id AS thread_id + FROM chat_channels + INNER JOIN chat_threads ON chat_threads.channel_id = chat_channels.id + LEFT JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id + AND user_chat_thread_memberships.user_id = :user_id + WHERE user_chat_thread_memberships.id IS NULL + #{channel_ids.present? ? "AND chat_threads.channel_id IN (:channel_ids)" : ""} + #{thread_ids.present? ? "AND chat_threads.id IN (:thread_ids)" : ""} + GROUP BY chat_threads.id + LIMIT :limit + SQL + + DB.query( + sql, + channel_ids: channel_ids, + thread_ids: thread_ids, + user_id: user_id, + notification_type: ::Notification.types[:chat_mention], + limit: MAX_THREADS, + quiet_notification_levels: [ + ::Chat::UserChatThreadMembership.notification_levels[:muted], + ::Chat::UserChatThreadMembership.notification_levels[:normal], + ], + ) + end + end +end diff --git a/plugins/chat/app/queries/chat/tracking_state_report_query.rb b/plugins/chat/app/queries/chat/tracking_state_report_query.rb new file mode 100644 index 00000000000..21e1d21fa38 --- /dev/null +++ b/plugins/chat/app/queries/chat/tracking_state_report_query.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Chat + # This class is responsible for querying the user's current tracking + # (read/unread) state based on membership for one or more channels + # and/or one or more threads. + # + # Only channels with threading_enabled set to true will have thread + # tracking queried. + # + # The unread counts are based on the user's last_read_message_id for + # each membership, as well as the notification_level (in the case of + # thread memberships) and the following/muted settings (in the case of + # channel memberships). + # + # @param guardian [Guardian] The current user's guardian + # @param channel_ids [Array] The channel IDs to query. Must be provided + # if thread_ids are not. + # @param thread_ids [Array] The thread IDs to query. Must be provided + # if channel_ids are not. If channel_ids are also provided then these just further + # filter results. + # @param include_missing_memberships [Boolean] If true, will include channels + # and threads where the user does not have a UserChatXMembership record, + # with zeroed out unread counts. + # @param include_threads [Boolean] If true, will include thread tracking + # state in the query, otherwise only channel tracking will be queried. + # @param include_read [Boolean] If true, will include tracking state where + # the user has 0 unread messages. If false, will only include tracking state + # where the user has > 0 unread messages. If include_missing_memberships is + # also true, this overrides that option. + class TrackingStateReportQuery + def self.call( + guardian:, + channel_ids: nil, + thread_ids: nil, + include_missing_memberships: false, + include_threads: false, + include_read: true, + include_last_reply_details: false + ) + report = ::Chat::TrackingStateReport.new + + if channel_ids.blank? + report.channel_tracking = {} + else + report.channel_tracking = + ::Chat::ChannelUnreadsQuery + .call( + channel_ids: channel_ids, + user_id: guardian.user.id, + include_missing_memberships: include_missing_memberships, + include_read: include_read, + ) + .map do |ct| + [ct.channel_id, { mention_count: ct.mention_count, unread_count: ct.unread_count }] + end + .to_h + end + + if !include_threads || (thread_ids.blank? && channel_ids.blank?) + report.thread_tracking = {} + else + tracking = + ::Chat::ThreadUnreadsQuery.call( + channel_ids: channel_ids, + thread_ids: thread_ids, + user_id: guardian.user.id, + include_missing_memberships: include_missing_memberships, + include_read: include_read, + ) + + last_reply_details = + DB.query(<<~SQL, tracking.map(&:thread_id)) if include_last_reply_details + SELECT chat_threads.id AS thread_id, last_message.created_at + FROM chat_threads + INNER JOIN chat_messages AS last_message ON last_message.id = chat_threads.last_message_id + WHERE chat_threads.id IN (?) + AND last_message.deleted_at IS NULL + SQL + + report.thread_tracking = + tracking + .map do |tt| + data = { + channel_id: tt.channel_id, + mention_count: tt.mention_count, + unread_count: tt.unread_count, + } + + if include_last_reply_details + data[:last_reply_created_at] = last_reply_details + .find { |details| details.thread_id == tt.thread_id } + &.created_at + end + + [tt.thread_id, data] + end + .to_h + end + + report + end + end +end diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb deleted file mode 100644 index a257e0c0697..00000000000 --- a/plugins/chat/app/queries/chat_channel_memberships_query.rb +++ /dev/null @@ -1,47 +0,0 @@ -# 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 deleted file mode 100644 index c8af0dc2f19..00000000000 --- a/plugins/chat/app/serializers/admin_chat_index_serializer.rb +++ /dev/null @@ -1,14 +0,0 @@ -# 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/base_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb deleted file mode 100644 index 90cb7827eed..00000000000 --- a/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class BaseChatChannelMembershipSerializer < ApplicationSerializer - attributes :following, - :muted, - :desktop_notification_level, - :mobile_notification_level, - :chat_channel_id, - :last_read_message_id, - :unread_count, - :unread_mentions -end diff --git a/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb new file mode 100644 index 00000000000..1a36d6cc584 --- /dev/null +++ b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Chat + class AdminChatIndexSerializer < ApplicationSerializer + has_many :chat_channels, serializer: Chat::ChannelSerializer, embed: :objects + has_many :incoming_chat_webhooks, serializer: Chat::IncomingWebhookSerializer, embed: :objects + + def chat_channels + object[:chat_channels] + end + + def incoming_chat_webhooks + object[:incoming_chat_webhooks] + end + end +end diff --git a/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb new file mode 100644 index 00000000000..3b3b910ceeb --- /dev/null +++ b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Chat + class BaseChannelMembershipSerializer < ApplicationSerializer + attributes :following, + :muted, + :desktop_notification_level, + :mobile_notification_level, + :chat_channel_id, + :last_read_message_id, + :last_viewed_at + end +end diff --git a/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb new file mode 100644 index 00000000000..1f85493126e --- /dev/null +++ b/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Chat + class BaseThreadMembershipSerializer < ApplicationSerializer + attributes :notification_level, :thread_id, :last_read_message_id + + def notification_level + Chat::UserChatThreadMembership.notification_levels[object.notification_level] || + Chat::UserChatThreadMembership.notification_levels["normal"] + end + 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 00000000000..31c3eebe286 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_index_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelIndexSerializer < ::Chat::StructuredChannelSerializer + attributes :global_presence_channel_state + + def global_presence_channel_state + PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) + end + 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 00000000000..196f9f78e29 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_search_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelSearchSerializer < ::Chat::StructuredChannelSerializer + has_many :users, serializer: BasicUserSerializer, embed: :objects + + def users + object[:users] + end + 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 00000000000..9ed2dcde225 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_serializer.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module Chat + class ChannelSerializer < ApplicationSerializer + attributes :id, + :auto_join_users, + :allow_channel_wide_mentions, + :chatable, + :chatable_id, + :chatable_type, + :chatable_url, + :description, + :title, + :slug, + :status, + :archive_failed, + :archive_completed, + :archived_messages, + :total_messages, + :archive_topic_id, + :memberships_count, + :current_user_membership, + :meta, + :threading_enabled + + has_one :last_message, serializer: Chat::LastMessageSerializer, embed: :objects + + def initialize(object, opts) + super(object, opts) + + @opts = 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" + Chat::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? + !object.direct_message_channel? && scope.is_staff? && 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 include_current_user_membership? + @current_user_membership.present? + end + + def current_user_membership + @current_user_membership.chat_channel = object + + Chat::BaseChannelMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + def meta + ids = { + channel_message_bus_last_id: channel_message_bus_last_id, + new_messages: new_messages_message_bus_id, + new_mentions: new_mentions_message_bus_id, + } + + ids[:kick] = kick_message_bus_id if !object.direct_message_channel? + data = { message_bus_last_ids: ids } + + if @opts.key?(:can_join_chat_channel) + data[:can_join_chat_channel] = @opts[:can_join_chat_channel] + else + data[:can_join_chat_channel] = scope.can_join_chat_channel?(object) + end + + data[:can_flag] = scope.can_flag_in_chat_channel?( + object, + post_allowed_category_ids: @opts[:post_allowed_category_ids], + ) + data[:user_silenced] = !scope.can_create_chat_message? + data[:can_moderate] = scope.can_moderate_chat?(object.chatable) + data[:can_delete_self] = scope.can_delete_own_chats?(object.chatable) + data[:can_delete_others] = scope.can_delete_other_chats?(object.chatable) + + data + 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? + + private + + def channel_message_bus_last_id + @opts[:channel_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.root_message_bus_channel(object.id)) + end + + def new_messages_message_bus_id + @opts[:new_messages_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_messages_message_bus_channel(object.id)) + end + + def new_mentions_message_bus_id + @opts[:new_mentions_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_mentions_message_bus_channel(object.id)) + end + + def kick_message_bus_id + @opts[:kick_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.kick_users_message_bus_channel(object.id)) + end + end +end diff --git a/plugins/chat/app/serializers/chat/chatable_user_serializer.rb b/plugins/chat/app/serializers/chat/chatable_user_serializer.rb new file mode 100644 index 00000000000..1509174439e --- /dev/null +++ b/plugins/chat/app/serializers/chat/chatable_user_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + class ChatableUserSerializer < ::Chat::UserWithCustomFieldsAndStatusSerializer + attributes :can_chat, :has_chat_enabled + + def can_chat + SiteSetting.chat_enabled && scope.can_chat? + end + + def has_chat_enabled + can_chat && object.user_option&.chat_enabled + end + end +end diff --git a/plugins/chat/app/serializers/chat/chatables_serializer.rb b/plugins/chat/app/serializers/chat/chatables_serializer.rb new file mode 100644 index 00000000000..eada1f105cf --- /dev/null +++ b/plugins/chat/app/serializers/chat/chatables_serializer.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Chat + class ChatablesSerializer < ::ApplicationSerializer + attributes :users + attributes :direct_message_channels + attributes :category_channels + + def users + (object.users || []) + .map do |user| + { + identifier: "u-#{user.id}", + model: ::Chat::ChatableUserSerializer.new(user, scope: scope, root: false), + type: "user", + } + end + .as_json + end + + def direct_message_channels + (object.direct_message_channels || []) + .map do |channel| + { + identifier: "c-#{channel.id}", + type: "channel", + model: + ::Chat::ChannelSerializer.new( + channel, + scope: scope, + root: false, + membership: channel_membership(channel.id), + ), + } + end + .as_json + end + + def category_channels + (object.category_channels || []) + .map do |channel| + { + identifier: "c-#{channel.id}", + type: "channel", + model: + ::Chat::ChannelSerializer.new( + channel, + scope: scope, + root: false, + membership: channel_membership(channel.id), + ), + } + end + .as_json + end + + private + + def channel_membership(channel_id) + object.memberships.find { |membership| membership.chat_channel_id == channel_id } + end + end +end diff --git a/plugins/chat/app/serializers/chat/direct_message_serializer.rb b/plugins/chat/app/serializers/chat/direct_message_serializer.rb new file mode 100644 index 00000000000..2362dec1313 --- /dev/null +++ b/plugins/chat/app/serializers/chat/direct_message_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageSerializer < ApplicationSerializer + attributes :id + + has_many :users, serializer: Chat::ChatableUserSerializer, embed: :objects + + def users + users = object.direct_message_users.map(&:user).map { |u| u || Chat::DeletedUser.new } + + return users - [scope.user] if users.count > 1 + users + end + end +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 00000000000..b5163bd248f --- /dev/null +++ b/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + class InReplyToSerializer < ApplicationSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + + attributes :id, :cooked, :excerpt + + def excerpt + object.censored_excerpt + end + + def user + object.user || Chat::DeletedUser.new + end + end +end diff --git a/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb new file mode 100644 index 00000000000..65518d077a8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhookSerializer < ApplicationSerializer + has_one :chat_channel, serializer: Chat::ChannelSerializer, embed: :objects + + attributes :id, :name, :description, :emoji, :url, :username, :updated_at + end +end diff --git a/plugins/chat/app/serializers/chat/last_message_serializer.rb b/plugins/chat/app/serializers/chat/last_message_serializer.rb new file mode 100644 index 00000000000..625d365054f --- /dev/null +++ b/plugins/chat/app/serializers/chat/last_message_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + class LastMessageSerializer < ::ApplicationSerializer + # NOTE: The channel last message does not need to serialize relations + # etc. at this point in time, since the only thing we are using is + # created_at. In future we may want to serialize more for this, at which + # point we need to check existing code so we don't introduce N1s. + attributes *Chat::MessageSerializer::BASIC_ATTRIBUTES + + def created_at + object.created_at.iso8601 + end + 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 00000000000..891fd5aea87 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +module Chat + class MessageSerializer < ::ApplicationSerializer + BASIC_ATTRIBUTES = %i[ + id + message + cooked + created_at + excerpt + deleted_at + deleted_by_id + thread_id + chat_channel_id + ] + attributes( + *( + BASIC_ATTRIBUTES + + %i[ + mentioned_users + reactions + bookmark + available_flags + user_flag_status + reviewable_id + edited + thread + ] + ), + ) + + has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + has_one :in_reply_to, serializer: Chat::InReplyToSerializer, embed: :objects + has_many :uploads, serializer: ::UploadSerializer, embed: :objects + + def mentioned_users + object + .chat_mentions + .map(&:user) + .compact + .sort_by(&:id) + .map { |user| BasicUserWithStatusSerializer.new(user, root: false) } + .as_json + end + + def channel + @channel ||= @options.dig(:chat_channel) || object.chat_channel + end + + def user + object.user || Chat::DeletedUser.new + end + + def excerpt + object.censored_excerpt + end + + def reactions + object + .reactions + .group_by(&:emoji) + .map do |emoji, reactions| + next unless Emoji.exists?(emoji) + + users = reactions.take(5).map(&:user) + + { + emoji: emoji, + count: reactions.count, + users: + ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, + reacted: users_reactions.include?(emoji), + } + end + .compact + 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 created_at + object.created_at.iso8601 + end + + def deleted_at + object.user ? object.deleted_at.iso8601 : 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] + + 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 + + def include_thread? + include_thread_id? && object.thread_om? && object.thread.present? + end + + def include_thread_id? + channel.threading_enabled + end + + def thread + Chat::ThreadSerializer.new( + object.thread, + scope: scope, + membership: @options[:thread_memberships]&.find { |m| m.thread_id == object.thread.id }, + participants: @options[:thread_participants]&.dig(object.thread.id), + include_thread_preview: true, + include_thread_original_message: @options[:include_thread_original_message], + root: false, + ) + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_user_serializer.rb b/plugins/chat/app/serializers/chat/message_user_serializer.rb new file mode 100644 index 00000000000..92d222a6782 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_user_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Chat + class MessageUserSerializer < BasicUserWithStatusSerializer + attributes :moderator?, :admin?, :staff?, :moderator?, :new_user?, :primary_group_name + + def moderator? + !!(object&.moderator?) + end + + def admin? + !!(object&.admin?) + end + + def staff? + !!(object&.staff?) + end + + def new_user? + object.trust_level == TrustLevel[0] + end + + def primary_group_name + return nil unless object && object.primary_group_id + object.primary_group.name if object.primary_group + end + end +end diff --git a/plugins/chat/app/serializers/chat/messages_serializer.rb b/plugins/chat/app/serializers/chat/messages_serializer.rb new file mode 100644 index 00000000000..0646328bdfe --- /dev/null +++ b/plugins/chat/app/serializers/chat/messages_serializer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Chat + class MessagesSerializer < ::ApplicationSerializer + attributes :messages, :tracking, :meta + + def initialize(object, opts) + super(object, opts) + @opts = opts + end + + def messages + object.messages.map do |message| + ::Chat::MessageSerializer.new( + message, + scope: scope, + root: false, + include_thread_preview: true, + include_thread_original_message: true, + thread_participants: object.thread_participants, + thread_memberships: object.thread_memberships, + **@opts, + ) + end + end + + def tracking + object.tracking || {} + end + + def meta + { + target_message_id: object.target_message_id, + can_load_more_future: object.can_load_more_future, + can_load_more_past: object.can_load_more_past, + } + end + end +end diff --git a/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb new file mode 100644 index 00000000000..41f74e31c81 --- /dev/null +++ b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_dependency "reviewable_serializer" + +module Chat + class ReviewableMessageSerializer < ReviewableSerializer + target_attributes :cooked + payload_attributes :transcript_topic_id, :message_cooked + attributes :target_id + + has_one :chat_channel, serializer: Chat::ChannelSerializer, root: false, embed: :objects + + def chat_channel + object.chat_message.chat_channel + end + + def target_id + object.target&.id + end + end +end diff --git a/plugins/chat/app/serializers/chat/structured_channel_serializer.rb b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb new file mode 100644 index 00000000000..03993c942a0 --- /dev/null +++ b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Chat + class StructuredChannelSerializer < ApplicationSerializer + attributes :public_channels, :direct_message_channels, :tracking, :meta, :unread_thread_overview + + def tracking + object[:tracking] + end + + def unread_thread_overview + object[:unread_thread_overview] + end + + def public_channels + object[:public_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + kick_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.kick_users_message_bus_channel(channel.id)], + channel_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.root_message_bus_channel(channel.id)], + # NOTE: This is always true because the public channels passed into this serializer + # have been fetched with [Chat::ChannelFetcher], which only returns channels that + # the user has access to based on category permissions. + can_join_chat_channel: true, + post_allowed_category_ids: @options[:post_allowed_category_ids], + ) + end + end + + def direct_message_channels + object[:direct_message_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + channel_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.root_message_bus_channel(channel.id)], + ) + end + end + + def channel_membership(channel_id) + return if scope.anonymous? + object[:memberships].find { |membership| membership.chat_channel_id == channel_id } + end + + def meta + last_ids = { + channel_metadata: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], + channel_edits: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], + channel_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], + new_channel: chat_message_bus_last_ids[Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], + archive_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], + } + + if !scope.anonymous? + user_tracking_state_last_id = + chat_message_bus_last_ids[ + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id) + ] + + last_ids[:user_tracking_state] = user_tracking_state_last_id if user_tracking_state_last_id + end + + { message_bus_last_ids: last_ids } + end + + private + + def chat_message_bus_last_ids + @chat_message_bus_last_ids ||= + begin + message_bus_channels = [ + Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + ] + + if !scope.anonymous? + message_bus_channels.push( + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id), + ) + end + + object[:public_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.kick_users_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.root_message_bus_channel(channel.id)) + end + + object[:direct_message_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.root_message_bus_channel(channel.id)) + end + + MessageBus.last_ids(*message_bus_channels) + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_list_serializer.rb b/plugins/chat/app/serializers/chat/thread_list_serializer.rb new file mode 100644 index 00000000000..fd5be6d0a8f --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_list_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Chat + class ThreadListSerializer < ApplicationSerializer + attributes :meta, :threads, :tracking + + def threads + object.threads.map do |thread| + ::Chat::ThreadSerializer.new( + thread, + scope: scope, + membership: object.memberships.find { |m| m.thread_id == thread.id }, + include_thread_preview: true, + include_thread_original_message: true, + root: nil, + ) + end + end + + def tracking + object.tracking + end + + def meta + { channel_id: object.channel.id, load_more_url: object.load_more_url } + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb new file mode 100644 index 00000000000..67f5f067c06 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + class ThreadOriginalMessageSerializer < ::ApplicationSerializer + attributes :id, + :message, + :cooked, + :created_at, + :excerpt, + :chat_channel_id, + :deleted_at, + :mentioned_users + + def excerpt + object.censored_excerpt + end + + def mentioned_users + object + .chat_mentions + .map(&:user) + .compact + .sort_by(&:id) + .map { |user| BasicUserWithStatusSerializer.new(user, root: false) } + .as_json + end + + has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects + end +end diff --git a/plugins/chat/app/serializers/chat/thread_preview_serializer.rb b/plugins/chat/app/serializers/chat/thread_preview_serializer.rb new file mode 100644 index 00000000000..72f97282678 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_preview_serializer.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Chat + class ThreadPreviewSerializer < ApplicationSerializer + attributes :last_reply_created_at, + :last_reply_excerpt, + :last_reply_id, + :participant_count, + :reply_count + has_many :participant_users, serializer: BasicUserSerializer, embed: :objects + has_one :last_reply_user, serializer: BasicUserSerializer, embed: :objects + + def initialize(object, opts) + super(object, opts) + @participants = opts[:participants] + end + + def reply_count + object.replies_count_cache || 0 + end + + def last_reply_created_at + object.last_message.created_at.iso8601 + end + + def last_reply_id + object.last_message.id + end + + def last_reply_excerpt + object.last_message.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH) + end + + def last_reply_user + object.last_message.user + end + + def include_participant_data? + @participants.present? + end + + def include_participant_users? + include_participant_data? + end + + def include_participant_count? + include_participant_data? + end + + def participant_users + @participant_users ||= @participants[:users].map { |user| User.new(user) } + end + + def participant_count + @participants[:total_count] + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_serializer.rb b/plugins/chat/app/serializers/chat/thread_serializer.rb new file mode 100644 index 00000000000..8ba6372a2da --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_serializer.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Chat + class ThreadSerializer < ApplicationSerializer + has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects + + attributes :id, + :title, + :status, + :channel_id, + :meta, + :reply_count, + :current_user_membership, + :preview + + def initialize(object, opts) + super(object, opts) + @opts = opts + + # Avoids an N1 to re-load the thread in the serializer for original_message. + object.original_message&.thread = object + @current_user_membership = opts[:membership] + end + + def include_original_message? + @opts[:include_thread_original_message].presence || true + end + + def meta + { message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } } + end + + def reply_count + object.replies_count_cache || 0 + end + + def include_preview? + @opts[:include_thread_preview] + end + + def preview + Chat::ThreadPreviewSerializer.new( + object, + scope: scope, + root: false, + participants: @opts[:participants], + ).as_json + end + + def include_current_user_membership? + @current_user_membership.present? + end + + def current_user_membership + @current_user_membership.thread = object + + Chat::BaseThreadMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + private + + def thread_message_bus_last_id + @opts[:thread_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.thread_message_bus_channel(object.channel_id, object.id)) + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb new file mode 100644 index 00000000000..8dd02a7ffa1 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class UserChannelMembershipSerializer < BaseChannelMembershipSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + + def user + object.user + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb new file mode 100644 index 00000000000..f1ba24bdc16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Chat + class UserMessageBookmarkSerializer < 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 +end diff --git a/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb new file mode 100644 index 00000000000..d9589d730b8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + 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 +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 00000000000..309cd06bf16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/webhook_event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Chat + class WebhookEventSerializer < ApplicationSerializer + attributes :username, :emoji + end +end diff --git a/plugins/chat/app/serializers/chat_channel_index_serializer.rb b/plugins/chat/app/serializers/chat_channel_index_serializer.rb deleted file mode 100644 index 59c555a90f7..00000000000 --- a/plugins/chat/app/serializers/chat_channel_index_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index cf5bc083cc9..00000000000 --- a/plugins/chat/app/serializers/chat_channel_search_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index e6707acfd6b..00000000000 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelSerializer < ApplicationSerializer - attributes :id, - :auto_join_users, - :allow_channel_wide_mentions, - :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, - :meta - - def initialize(object, opts) - super(object, opts) - - @opts = 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? - !object.direct_message_channel? && scope.is_staff? && 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 include_current_user_membership? - @current_user_membership.present? - end - - def current_user_membership - @current_user_membership.chat_channel = object - - BaseChatChannelMembershipSerializer.new( - @current_user_membership, - scope: scope, - root: false, - ).as_json - end - - def meta - { - message_bus_last_ids: { - new_messages: - @opts[:new_messages_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), - new_mentions: - @opts[:new_mentions_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_mentions_message_bus_channel(object.id)), - }, - } - 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 deleted file mode 100644 index 25cb08c8fde..00000000000 --- a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 0bcbd64c3d0..00000000000 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ /dev/null @@ -1,149 +0,0 @@ -# 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 deleted file mode 100644 index 566474ec408..00000000000 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ /dev/null @@ -1,33 +0,0 @@ -# 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), - channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.chat_channel.id}"), - } - 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 deleted file mode 100644 index 3fb674c653f..00000000000 --- a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 817902467dd..00000000000 --- a/plugins/chat/app/serializers/direct_message_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 7f097e62bfd..00000000000 --- a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 5c56d39fb70..00000000000 --- a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index e88b9a00b5d..00000000000 --- a/plugins/chat/app/serializers/structured_channel_serializer.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -class StructuredChannelSerializer < ApplicationSerializer - attributes :public_channels, :direct_message_channels, :meta - - def public_channels - object[:public_channels].map do |channel| - ChatChannelSerializer.new( - channel, - root: nil, - scope: scope, - membership: channel_membership(channel.id), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(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), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)], - ) - end - end - - def channel_membership(channel_id) - return if scope.anonymous? - object[:memberships].find { |membership| membership.chat_channel_id == channel_id } - end - - def meta - last_ids = { - channel_metadata: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], - channel_edits: chat_message_bus_last_ids[ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], - channel_status: chat_message_bus_last_ids[ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], - new_channel: chat_message_bus_last_ids[ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], - archive_status: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], - } - - if id = - chat_message_bus_last_ids[ - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id) - ] - last_ids[:user_tracking_state] = id - end - - { message_bus_last_ids: last_ids } - end - - private - - def chat_message_bus_last_ids - @chat_message_bus_last_ids ||= - begin - message_bus_channels = [ - ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - ] - - if !scope.anonymous? - message_bus_channels.push( - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id), - ) - end - - object[:public_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - object[:direct_message_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - MessageBus.last_ids(*message_bus_channels) - end - 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 deleted file mode 100644 index 18c26222e34..00000000000 --- a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class UserChatChannelMembershipSerializer < BaseChatChannelMembershipSerializer - 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 deleted file mode 100644 index 49f4c7af6f6..00000000000 --- a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index e0897abfd54..00000000000 --- a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb +++ /dev/null @@ -1,13 +0,0 @@ -# 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/action/calculate_memberships_for_removal.rb b/plugins/chat/app/services/chat/action/calculate_memberships_for_removal.rb new file mode 100644 index 00000000000..fa4f74615e5 --- /dev/null +++ b/plugins/chat/app/services/chat/action/calculate_memberships_for_removal.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Chat + module Action + # There is significant complexity around category channel permissions, + # since they are inferred from [CategoryGroup] records and their corresponding + # permission types. + # + # To be able to join and chat in a channel, a user must either be staff, + # or be in a group that has either `full` or `create_post` permissions + # via [CategoryGroup]. + # + # However, there is an edge case. If there are no [CategoryGroup] records + # for a given category, this means that the [Group::AUTO_GROUPS[:everyone]] + # group has `full` access to the channel, therefore everyone can post in + # the chat channel (so long as they are in one of the `SiteSetting.chat_allowed_groups`) + # + # Here, we can efficiently query the channel category permissions and figure + # out which of the users provided should have their [Chat::UserChatChannelMembership] + # records removed based on those security cases. + class CalculateMembershipsForRemoval + def self.call(scoped_users:, channel_ids: nil) + channel_permissions_map = + DB.query(<<~SQL, readonly: CategoryGroup.permission_types[:readonly]) + WITH category_group_channel_map AS ( + SELECT category_groups.group_id, + category_groups.permission_type, + chat_channels.id AS channel_id + FROM category_groups + INNER JOIN categories ON categories.id = category_groups.category_id + INNER JOIN chat_channels ON categories.id = chat_channels.chatable_id + AND chat_channels.chatable_type = 'Category' + ) + + SELECT chat_channels.id AS channel_id, + chat_channels.chatable_id AS category_id, + ( + SELECT string_agg(category_group_channel_map.group_id::varchar, ',') + FROM category_group_channel_map + WHERE category_group_channel_map.permission_type < :readonly AND + category_group_channel_map.channel_id = chat_channels.id + ) AS groups_with_write_permissions, + ( + SELECT string_agg(category_group_channel_map.group_id::varchar, ',') + FROM category_group_channel_map + WHERE category_group_channel_map.permission_type = :readonly AND + category_group_channel_map.channel_id = chat_channels.id + ) AS groups_with_readonly_permissions, + categories.read_restricted + FROM category_group_channel_map + INNER JOIN chat_channels ON chat_channels.id = category_group_channel_map.channel_id + INNER JOIN categories ON categories.id = chat_channels.chatable_id + WHERE chat_channels.chatable_type = 'Category' + #{channel_ids.present? ? "AND chat_channels.id IN (#{channel_ids.join(",")})" : ""} + GROUP BY chat_channels.id, chat_channels.chatable_id, categories.read_restricted + ORDER BY channel_id + SQL + + scoped_memberships = + Chat::UserChatChannelMembership + .joins(:chat_channel) + .where(user: scoped_users) + .where(chat_channel_id: channel_permissions_map.map(&:channel_id)) + + memberships_to_remove = [] + scoped_memberships.find_each do |membership| + scoped_user = scoped_users.find { |su| su.id == membership.user_id } + channel_permission = + channel_permissions_map.find { |cpm| cpm.channel_id == membership.chat_channel_id } + + # If there is no channel in the map, this means there are no + # category_groups for the channel. + # + # This in turn means the Everyone group with full permission + # is the only group that can access the channel (no category_group + # record is created in this case), we do not need to remove any users. + next if channel_permission.blank? + + group_ids_with_write_permission = + channel_permission.groups_with_write_permissions.to_s.split(",").map(&:to_i) + group_ids_with_read_permission = + channel_permission.groups_with_readonly_permissions.to_s.split(",").map(&:to_i) + + # None of the groups on the channel have permission to do anything + # more than read only, remove the membership. + if group_ids_with_write_permission.empty? && group_ids_with_read_permission.any? + memberships_to_remove << membership.id + next + end + + # At least one of the groups on the channel can create_post or + # has full permission, remove the membership if the user is in none + # of these groups. + if group_ids_with_write_permission.any? && + !scoped_user.in_any_groups?(group_ids_with_write_permission) + memberships_to_remove << membership.id + end + end + + memberships_to_remove + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/create_memberships_for_auto_join.rb b/plugins/chat/app/services/chat/action/create_memberships_for_auto_join.rb new file mode 100644 index 00000000000..eddeacf531d --- /dev/null +++ b/plugins/chat/app/services/chat/action/create_memberships_for_auto_join.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Chat + module Action + class CreateMembershipsForAutoJoin + def self.call(channel:, contract:) + query_args = { + chat_channel_id: channel.id, + start: contract.start_user_id, + end: contract.end_user_id, + suspended_until: Time.zone.now, + last_seen_at: 3.months.ago, + channel_category: channel.category.id, + permission_type: CategoryGroup.permission_types[:create_post], + everyone: Group::AUTO_GROUPS[:everyone], + mode: ::Chat::UserChatChannelMembership.join_modes[:automatic], + } + ::DB.query_single(<<~SQL, query_args) + 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 + + LEFT OUTER JOIN group_users gu ON gu.user_id = users.id + LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id AND + cg.permission_type <= :permission_type + + 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 IS NULL OR last_seen_at > :last_seen_at) AND + uo.chat_enabled AND + + (NOT EXISTS(SELECT 1 FROM category_groups WHERE category_id = :channel_category) + OR EXISTS (SELECT 1 FROM category_groups WHERE category_id = :channel_category AND group_id = :everyone AND permission_type <= :permission_type) + OR cg.category_id = :channel_category) + + ON CONFLICT DO NOTHING + + RETURNING user_chat_channel_memberships.user_id + SQL + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/mark_mentions_read.rb b/plugins/chat/app/services/chat/action/mark_mentions_read.rb new file mode 100644 index 00000000000..8d0207d63bb --- /dev/null +++ b/plugins/chat/app/services/chat/action/mark_mentions_read.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Chat + module Action + # When updating the read state of chat channel memberships, we also need + # to be sure to mark any mention-based notifications read at the same time. + class MarkMentionsRead + # @param [User] user The user that we are marking notifications read for. + # @param [Array] channel_ids The chat channels that are having their notifications + # marked as read. + # @param [Integer] message_id Optional, used to limit the max message ID to mark + # mentions read for in the channel. + # @param [Integer] thread_id Optional, if provided then all notifications related + # to messages in the thread will be marked as read. + def self.call(user, channel_ids:, message_id: nil, thread_id: nil) + ::Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: 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.chat_channel_id IN (?)", channel_ids) + .then do |notifications| + break notifications if message_id.blank? && thread_id.blank? + break notifications.where("chat_messages.id <= ?", message_id) if message_id.present? + if thread_id.present? + notifications.where( + "chat_messages.id IN (SELECT id FROM chat_messages WHERE thread_id = ?)", + thread_id, + ) + end + end + .update_all(read: true) + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/publish_auto_removed_user.rb b/plugins/chat/app/services/chat/action/publish_auto_removed_user.rb new file mode 100644 index 00000000000..8d3912ecba3 --- /dev/null +++ b/plugins/chat/app/services/chat/action/publish_auto_removed_user.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Chat + module Action + # All of the handlers that auto-remove users from chat + # (under services/auto_remove) need to publish which users + # were removed and from which channel, as well as logging + # this in staff actions so it's obvious why these users were + # removed. + class PublishAutoRemovedUser + # @param [Symbol] event_type What caused the users to be removed, + # each handler will define this, e.g. category_updated, user_removed_from_group + # @param [Hash] users_removed_map A hash with channel_id as its keys and an + # array of user_ids who were removed from the channel. + def self.call(event_type:, users_removed_map:) + return if users_removed_map.empty? + + users_removed_map.each do |channel_id, user_ids| + job_spacer = JobTimeSpacer.new + user_ids.in_groups_of(1000, false) do |user_id_batch| + job_spacer.enqueue( + Jobs::Chat::KickUsersFromChannel, + { channel_id: channel_id, user_ids: user_id_batch }, + ) + end + + if user_ids.any? + StaffActionLogger.new(Discourse.system_user).log_custom( + "chat_auto_remove_membership", + { users_removed: user_ids.length, channel_id: channel_id, event: event_type }, + ) + end + end + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/remove_memberships.rb b/plugins/chat/app/services/chat/action/remove_memberships.rb new file mode 100644 index 00000000000..6a2330ffe74 --- /dev/null +++ b/plugins/chat/app/services/chat/action/remove_memberships.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + module Action + class RemoveMemberships + def self.call(memberships:) + memberships + .destroy_all + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |obj, hash| + hash[obj.chat_channel_id] << obj.user_id + end + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/reset_channels_last_message_ids.rb b/plugins/chat/app/services/chat/action/reset_channels_last_message_ids.rb new file mode 100644 index 00000000000..ff3ea6d843b --- /dev/null +++ b/plugins/chat/app/services/chat/action/reset_channels_last_message_ids.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Chat + module Action + class ResetChannelsLastMessageIds + # @param [Array] last_message_ids The message IDs to match with the + # last_message_id in Chat::Channel which will be reset + # to NULL or the most recent non-deleted message in the channel to + # update read state. + # @param [Integer] channel_ids The channel IDs to update. This is used + # to scope the queries better. + def self.call(last_message_ids, channel_ids) + Chat::Channel + .where(id: channel_ids) + .where("last_message_id IN (?)", last_message_ids) + .find_in_batches { |channels| channels.each(&:update_last_message_id!) } + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/reset_user_last_read_channel_message.rb b/plugins/chat/app/services/chat/action/reset_user_last_read_channel_message.rb new file mode 100644 index 00000000000..f15a390d0c7 --- /dev/null +++ b/plugins/chat/app/services/chat/action/reset_user_last_read_channel_message.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Chat + module Action + class ResetUserLastReadChannelMessage + # @param [Array] last_read_message_ids The message IDs to match with the + # last_read_message_ids in UserChatChannelMembership which will be reset + # to NULL or the most recent non-deleted message in the channel to + # update read state. + # @param [Integer] channel_ids The channel IDs of the memberships to update, + # this is used to find the latest non-deleted message in the channel. + def self.call(last_read_message_ids, channel_ids) + sql = <<~SQL + -- update the last_read_message_id to the most recent + -- non-deleted message in the channel so unread counts are correct. + -- the cte row_number is necessary to only return a single row + -- for each channel to prevent additional data being returned + WITH cte AS ( + SELECT chat_channels.id AS chat_channel_id, last_message_id + FROM chat_channels + WHERE chat_channels.id IN (:channel_ids) + ) + UPDATE user_chat_channel_memberships + SET last_read_message_id = cte.last_message_id + FROM cte + WHERE user_chat_channel_memberships.last_read_message_id IN (:last_read_message_ids) + AND cte.chat_channel_id = user_chat_channel_memberships.chat_channel_id; + + -- then reset all last_read_message_ids to null + -- for the cases where all messages in the channel were + -- already deleted + UPDATE user_chat_channel_memberships + SET last_read_message_id = NULL + WHERE last_read_message_id IN (:last_read_message_ids); + SQL + + DB.exec(sql, last_read_message_ids: last_read_message_ids, channel_ids: channel_ids) + end + end + end +end diff --git a/plugins/chat/app/services/chat/action/reset_user_last_read_thread_message.rb b/plugins/chat/app/services/chat/action/reset_user_last_read_thread_message.rb new file mode 100644 index 00000000000..a4692e778aa --- /dev/null +++ b/plugins/chat/app/services/chat/action/reset_user_last_read_thread_message.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Chat + module Action + class ResetUserLastReadThreadMessage + # @param [Array] last_read_message_ids The message IDs to match with the + # last_read_message_ids in UserChatThreadMembership which will be reset + # to NULL or the most recent non-deleted message in the thread to + # update read state. + # @param [Integer] thread_ids The thread IDs of the memberships to update, + # this is used to find the latest non-deleted message in the thread. + def self.call(last_read_message_ids, thread_ids) + sql = <<~SQL + -- update the last_read_message_id to the most recent + -- non-deleted message in the thread so unread counts are correct. + -- the cte row_number is necessary to only return a single row + -- for each thread to prevent additional data being returned + WITH cte AS ( + SELECT * FROM ( + SELECT id, thread_id, row_number() OVER ( + PARTITION BY thread_id ORDER BY created_at DESC, id DESC + ) AS row_number + FROM chat_messages + WHERE deleted_at IS NULL AND thread_id IN (:thread_ids) AND chat_messages.id NOT IN ( + SELECT original_message_id FROM chat_threads WHERE thread_id IN (:thread_ids) + ) + ) AS recent_messages + WHERE recent_messages.row_number = 1 + ) + UPDATE user_chat_thread_memberships + SET last_read_message_id = cte.id + FROM cte + WHERE user_chat_thread_memberships.last_read_message_id IN (:last_read_message_ids) + AND cte.thread_id = user_chat_thread_memberships.thread_id; + + -- then reset all last_read_message_ids to null + -- for the cases where all messages in the thread were + -- already deleted + UPDATE user_chat_thread_memberships + SET last_read_message_id = NULL + WHERE last_read_message_id IN (:last_read_message_ids); + SQL + + DB.exec(sql, last_read_message_ids: last_read_message_ids, thread_ids: thread_ids) + end + end + end +end diff --git a/plugins/chat/app/services/chat/auto_join_channel_batch.rb b/plugins/chat/app/services/chat/auto_join_channel_batch.rb new file mode 100644 index 00000000000..7337835f48a --- /dev/null +++ b/plugins/chat/app/services/chat/auto_join_channel_batch.rb @@ -0,0 +1,68 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Chat + # Service responsible to create memberships for a channel and a section of user ids + # + # @example + # Chat::AutoJoinChannelBatch.call( + # channel_id: 1, + # start_user_id: 27, + # end_user_id: 58, + # ) + # + class AutoJoinChannelBatch + include Service::Base + + contract + model :channel + step :create_memberships + step :recalculate_user_count + step :publish_new_channel + + class Contract + # Backward-compatible attributes + attribute :chat_channel_id, :integer + attribute :starts_at, :integer + attribute :ends_at, :integer + + # New attributes + attribute :channel_id, :integer + attribute :start_user_id, :integer + attribute :end_user_id, :integer + + validates :channel_id, :start_user_id, :end_user_id, presence: true + validates :end_user_id, comparison: { greater_than_or_equal_to: :start_user_id } + + # TODO (joffrey): remove after migration is done + before_validation do + self.channel_id ||= chat_channel_id + self.start_user_id ||= starts_at + self.end_user_id ||= ends_at + end + end + + private + + def fetch_channel(contract:, **) + ::Chat::CategoryChannel.find_by(id: contract.channel_id, auto_join_users: true) + end + + def create_memberships(channel:, contract:, **) + context.added_user_ids = + ::Chat::Action::CreateMembershipsForAutoJoin.call(channel: channel, contract: contract) + end + + def recalculate_user_count(channel:, added_user_ids:, **) + # 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::AutoJoinChannelMemberships + return unless added_user_ids.one? + ::Chat::ChannelMembershipManager.new(channel).recalculate_user_count + end + + def publish_new_channel(channel:, added_user_ids:, **) + ::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: added_user_ids)) + end + end +end diff --git a/plugins/chat/app/services/chat/auto_remove/handle_category_updated.rb b/plugins/chat/app/services/chat/auto_remove/handle_category_updated.rb new file mode 100644 index 00000000000..f09bebb46a1 --- /dev/null +++ b/plugins/chat/app/services/chat/auto_remove/handle_category_updated.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Chat + module AutoRemove + # Fired from [Jobs::AutoRemoveMembershipHandleCategoryUpdated], which + # in turn is enqueued whenever the [DiscourseEvent] for :category_updated + # is triggered. Any users who can no longer access category-based channels + # based on category_groups and in turn group_users will be removed from + # those chat channels. + # + # If a user is in any groups that have the `full` or `create_post` + # [CategoryGroup#permission_types] or if the category has no groups remaining, + # then the user will remain in the channel. + class HandleCategoryUpdated + include Service::Base + + contract + step :assign_defaults + policy :chat_enabled + model :category + model :category_channel_ids + model :users + step :remove_users_without_channel_permission + step :publish + + class Contract + attribute :category_id, :integer + + validates :category_id, presence: true + end + + private + + def assign_defaults + context[:users_removed_map] = {} + end + + def chat_enabled + SiteSetting.chat_enabled + end + + def fetch_category(contract:, **) + Category.find_by(id: contract.category_id) + end + + def fetch_category_channel_ids(category:, **) + Chat::Channel.where(chatable: category).pluck(:id) + end + + def fetch_users(category_channel_ids:, **) + User + .real + .activated + .not_suspended + .not_staged + .joins(:user_chat_channel_memberships) + .where("user_chat_channel_memberships.chat_channel_id IN (?)", category_channel_ids) + .where("NOT admin AND NOT moderator") + end + + def remove_users_without_channel_permission(users:, category_channel_ids:, **) + memberships_to_remove = + Chat::Action::CalculateMembershipsForRemoval.call( + scoped_users: users, + channel_ids: category_channel_ids, + ) + + return if memberships_to_remove.blank? + + context[:users_removed_map] = Chat::Action::RemoveMemberships.call( + memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove), + ) + end + + def publish(users_removed_map:, **) + Chat::Action::PublishAutoRemovedUser.call( + event_type: :category_updated, + users_removed_map: users_removed_map, + ) + end + end + end +end diff --git a/plugins/chat/app/services/chat/auto_remove/handle_chat_allowed_groups_change.rb b/plugins/chat/app/services/chat/auto_remove/handle_chat_allowed_groups_change.rb new file mode 100644 index 00000000000..3da46ab23ed --- /dev/null +++ b/plugins/chat/app/services/chat/auto_remove/handle_chat_allowed_groups_change.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Chat + module AutoRemove + # Fired from [Jobs::AutoRemoveMembershipHandleChatAllowedGroupsChange], which + # in turn is enqueued whenever the [DiscourseEvent] for :site_setting_changed + # is triggered for the chat_allowed_groups setting. + # + # If any of the chat_allowed_groups is the everyone auto group then nothing + # needs to be done. + # + # Otherwise, if there are no longer any chat_allowed_groups, we have to + # remove all non-admin users from category channels. Otherwise we just + # remove the ones who are not in any of the chat_allowed_groups. + # + # Direct message channel memberships are intentionally left alone, + # these are private communications between two people. + class HandleChatAllowedGroupsChange + include Service::Base + + policy :chat_enabled + step :cast_new_allowed_groups_to_array + policy :not_everyone_allowed + model :users + model :memberships_to_remove + step :remove_users_outside_allowed_groups + step :publish + + private + + def chat_enabled + SiteSetting.chat_enabled + end + + def cast_new_allowed_groups_to_array(new_allowed_groups:, **) + context[:new_allowed_groups] = new_allowed_groups.to_s.split("|").map(&:to_i) + end + + def not_everyone_allowed(new_allowed_groups:, **) + !new_allowed_groups.include?(Group::AUTO_GROUPS[:everyone]) + end + + def fetch_users(new_allowed_groups:, **) + User + .real + .activated + .not_suspended + .not_staged + .where("NOT admin AND NOT moderator") + .joins(:user_chat_channel_memberships) + .distinct + .then do |users| + break users if new_allowed_groups.blank? + users.where(<<~SQL, new_allowed_groups) + users.id NOT IN ( + SELECT DISTINCT group_users.user_id + FROM group_users + WHERE group_users.group_id IN (?) + ) + SQL + end + end + + def fetch_memberships_to_remove(users:, **) + Chat::UserChatChannelMembership + .joins(:chat_channel) + .where(user_id: users.pluck(:id)) + .where.not(chat_channel: { type: "DirectMessageChannel" }) + end + + def remove_users_outside_allowed_groups(memberships_to_remove:, **) + context[:users_removed_map] = Chat::Action::RemoveMemberships.call( + memberships: memberships_to_remove, + ) + end + + def publish(users_removed_map:, **) + Chat::Action::PublishAutoRemovedUser.call( + event_type: :chat_allowed_groups_changed, + users_removed_map: users_removed_map, + ) + end + end + end +end diff --git a/plugins/chat/app/services/chat/auto_remove/handle_destroyed_group.rb b/plugins/chat/app/services/chat/auto_remove/handle_destroyed_group.rb new file mode 100644 index 00000000000..b8aa9a9e153 --- /dev/null +++ b/plugins/chat/app/services/chat/auto_remove/handle_destroyed_group.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module Chat + module AutoRemove + # Fired from [Jobs::AutoRemoveMembershipHandleUserRemovedFromGroup], which + # is in turn enqueued whenever the [DiscourseEvent] for :group_destroyed + # is triggered. + # + # The :group_destroyed event provides us with the user_ids of the former + # GroupUser records so we can scope this better. + # + # Since this could have potential wide-ranging impact, we have to check: + # * The chat_allowed_groups [SiteSetting], and if any of the scoped users + # are still allowed to use public chat channels based on this setting. + # * The channel permissions of all the category chat channels the users + # are a part of, based on [CategoryGroup] records + # + # If a user is in a groups that has the `full` or `create_post` + # [CategoryGroup#permission_types] or if the category has no groups remaining, + # then the user will remain in the channel. + class HandleDestroyedGroup + include Service::Base + + contract + step :assign_defaults + policy :chat_enabled + policy :not_everyone_allowed + model :scoped_users + step :remove_users_outside_allowed_groups + step :remove_users_without_channel_permission + step :publish + + class Contract + attribute :destroyed_group_user_ids + + validates :destroyed_group_user_ids, presence: true + end + + private + + def assign_defaults + context[:users_removed_map] = {} + end + + def chat_enabled + SiteSetting.chat_enabled + end + + def not_everyone_allowed + !SiteSetting.chat_allowed_groups_map.include?(Group::AUTO_GROUPS[:everyone]) + end + + def fetch_scoped_users(destroyed_group_user_ids:, **) + User + .real + .activated + .not_suspended + .not_staged + .includes(:group_users) + .where("NOT admin AND NOT moderator") + .where(id: destroyed_group_user_ids) + .joins(:user_chat_channel_memberships) + .distinct + end + + def remove_users_outside_allowed_groups(scoped_users:, **) + users = scoped_users + + # Remove any of these users from all category channels if they + # are not in any of the chat_allowed_groups or if there are no + # chat allowed groups. + if SiteSetting.chat_allowed_groups_map.any? + group_user_sql = <<~SQL + users.id NOT IN ( + SELECT DISTINCT group_users.user_id + FROM group_users + WHERE group_users.group_id IN (#{SiteSetting.chat_allowed_groups_map.join(",")}) + ) + SQL + users = users.where(group_user_sql) + end + + user_ids_to_remove = users.pluck(:id) + return if user_ids_to_remove.empty? + + memberships_to_remove = + Chat::UserChatChannelMembership + .joins(:chat_channel) + .where(user_id: user_ids_to_remove) + .where.not(chat_channel: { type: "DirectMessageChannel" }) + + return if memberships_to_remove.empty? + + context[:users_removed_map] = Chat::Action::RemoveMemberships.call( + memberships: memberships_to_remove, + ) + end + + def remove_users_without_channel_permission(scoped_users:, **) + memberships_to_remove = + Chat::Action::CalculateMembershipsForRemoval.call(scoped_users: scoped_users) + + return if memberships_to_remove.empty? + + context.merge( + users_removed_map: + Chat::Action::RemoveMemberships.call( + memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove), + ), + ) + end + + def publish(users_removed_map:, **) + Chat::Action::PublishAutoRemovedUser.call( + event_type: :destroyed_group, + users_removed_map: users_removed_map, + ) + end + end + end +end diff --git a/plugins/chat/app/services/chat/auto_remove/handle_user_removed_from_group.rb b/plugins/chat/app/services/chat/auto_remove/handle_user_removed_from_group.rb new file mode 100644 index 00000000000..dea3af483d5 --- /dev/null +++ b/plugins/chat/app/services/chat/auto_remove/handle_user_removed_from_group.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Chat + module AutoRemove + # Fired from [Jobs::AutoRemoveMembershipHandleUserRemovedFromGroup], which + # in turn is enqueued whenever the [DiscourseEvent] for :user_removed_from_group + # is triggered. + # + # Staff users will never be affected by this, they can always chat regardless + # of group permissions. + # + # Since this could have potential wide-ranging impact, we have to check: + # * The chat_allowed_groups [SiteSetting], and if the scoped user + # is still allowed to use public chat channels based on this setting. + # * The channel permissions of all the category chat channels the user + # is a part of, based on [CategoryGroup] records + # + # Direct message channel memberships are intentionally left alone, + # these are private communications between two people. + class HandleUserRemovedFromGroup + include Service::Base + + contract + step :assign_defaults + policy :chat_enabled + policy :not_everyone_allowed + model :user + policy :user_not_staff + step :remove_if_outside_chat_allowed_groups + step :remove_from_private_channels + step :publish + + class Contract + attribute :user_id, :integer + + validates :user_id, presence: true + end + + private + + def assign_defaults + context[:users_removed_map] = {} + end + + def chat_enabled + SiteSetting.chat_enabled + end + + def not_everyone_allowed + !SiteSetting.chat_allowed_groups_map.include?(Group::AUTO_GROUPS[:everyone]) + end + + def fetch_user(contract:, **) + User.find_by(id: contract.user_id) + end + + def user_not_staff(user:, **) + !user.staff? + end + + def remove_if_outside_chat_allowed_groups(user:, **) + if SiteSetting.chat_allowed_groups_map.empty? || + !GroupUser.exists?(group_id: SiteSetting.chat_allowed_groups_map, user: user) + memberships_to_remove = + Chat::UserChatChannelMembership + .joins(:chat_channel) + .where(user_id: user.id) + .where.not(chat_channel: { type: "DirectMessageChannel" }) + + return if memberships_to_remove.empty? + + context[:users_removed_map] = Chat::Action::RemoveMemberships.call( + memberships: memberships_to_remove, + ) + end + end + + def remove_from_private_channels(user:, **) + memberships_to_remove = + Chat::Action::CalculateMembershipsForRemoval.call(scoped_users: [user]) + + return if memberships_to_remove.empty? + + context.merge( + users_removed_map: + Chat::Action::RemoveMemberships.call( + memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove), + ), + ) + end + + def publish(users_removed_map:, **) + Chat::Action::PublishAutoRemovedUser.call( + event_type: :user_removed_from_group, + users_removed_map: users_removed_map, + ) + end + end + end +end diff --git a/plugins/chat/app/services/chat/create_category_channel.rb b/plugins/chat/app/services/chat/create_category_channel.rb new file mode 100644 index 00000000000..a9cf36d5c8b --- /dev/null +++ b/plugins/chat/app/services/chat/create_category_channel.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for creating a new category chat channel. + # + # @example + # Service::Chat::CreateCategoryChannel.call( + # guardian: guardian, + # name: "SuperChannel", + # description: "This is the best channel", + # slug: "super-channel", + # category_id: category.id, + # threading_enabled: true, + # ) + # + class CreateCategoryChannel + include Service::Base + + # @!method call(guardian:, **params_to_create) + # @param [Guardian] guardian + # @param [Hash] params_to_create + # @option params_to_create [String] name + # @option params_to_create [String] description + # @option params_to_create [String] slug + # @option params_to_create [Boolean] auto_join_users + # @option params_to_create [Integer] category_id + # @option params_to_create [Boolean] threading_enabled + # @return [Service::Base::Context] + + policy :public_channels_enabled + policy :can_create_channel + contract + model :category, :fetch_category + policy :category_channel_does_not_exist + transaction do + model :channel, :create_channel + model :membership, :create_membership + end + step :enforce_automatic_channel_memberships + + # @!visibility private + class Contract + attribute :name, :string + attribute :description, :string + attribute :slug, :string + attribute :category_id, :integer + attribute :auto_join_users, :boolean, default: false + attribute :threading_enabled, :boolean, default: false + + before_validation do + self.auto_join_users = auto_join_users.presence || false + self.threading_enabled = threading_enabled.presence || false + end + + validates :category_id, presence: true + validates :name, length: { maximum: SiteSetting.max_topic_title_length } + end + + private + + def public_channels_enabled + SiteSetting.enable_public_channels + end + + def can_create_channel(guardian:, **) + guardian.can_create_chat_channel? + end + + def fetch_category(contract:, **) + Category.find_by(id: contract.category_id) + end + + def category_channel_does_not_exist(category:, contract:, **) + !Chat::Channel.exists?(chatable: category, name: contract.name) + end + + def create_channel(category:, contract:, **) + category.create_chat_channel( + name: contract.name, + slug: contract.slug, + description: contract.description, + user_count: 1, + auto_join_users: contract.auto_join_users, + threading_enabled: contract.threading_enabled, + ) + end + + def create_membership(channel:, guardian:, **) + channel.user_chat_channel_memberships.create(user: guardian.user, following: true) + end + + def enforce_automatic_channel_memberships(channel:, **) + return if !channel.auto_join_users? + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end +end diff --git a/plugins/chat/app/services/chat/create_direct_message_channel.rb b/plugins/chat/app/services/chat/create_direct_message_channel.rb new file mode 100644 index 00000000000..1b5600bcf53 --- /dev/null +++ b/plugins/chat/app/services/chat/create_direct_message_channel.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for creating a new direct message chat channel. + # The guardian passed in is the "acting user" when creating the channel + # and deciding whether the actor can communicate with the users that + # are passed in. + # + # @example + # Service::Chat::CreateDirectMessageChannel.call( + # guardian: guardian, + # target_usernames: ["bob", "alice"] + # ) + # + class CreateDirectMessageChannel + include Service::Base + + # @!method call(guardian:, **params_to_create) + # @param [Guardian] guardian + # @param [Hash] params_to_create + # @option params_to_create [Array] target_usernames + # @return [Service::Base::Context] + + policy :can_create_direct_message + contract + model :target_users + policy :satisfies_dms_max_users_limit, + class_name: Chat::DirectMessageChannel::MaxUsersExcessPolicy + model :user_comm_screener + policy :actor_allows_dms + policy :targets_allow_dms_from_user, + class_name: Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy + model :direct_message, :fetch_or_create_direct_message + model :channel, :fetch_or_create_channel + step :update_memberships + + # @!visibility private + class Contract + attribute :target_usernames, :array + validates :target_usernames, presence: true + end + + private + + def can_create_direct_message(guardian:, **) + guardian.can_create_direct_message? + end + + def fetch_target_users(guardian:, contract:, **) + User.where(username: [guardian.user.username, *contract.target_usernames]).to_a + end + + def fetch_user_comm_screener(target_users:, guardian:, **) + UserCommScreener.new(acting_user: guardian.user, target_user_ids: target_users.map(&:id)) + end + + def actor_allows_dms(user_comm_screener:, **) + !user_comm_screener.actor_disallowing_all_pms? + end + + def fetch_or_create_direct_message(target_users:, **) + Chat::DirectMessage.for_user_ids(target_users.map(&:id)) || + Chat::DirectMessage.create(user_ids: target_users.map(&:id)) + end + + def fetch_or_create_channel(direct_message:, **) + Chat::DirectMessageChannel.find_or_create_by(chatable: direct_message) + end + + def update_memberships(channel:, target_users:, **) + always_level = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + memberships = + target_users.map do |user| + { + user_id: user.id, + chat_channel_id: channel.id, + muted: false, + following: false, + desktop_notification_level: always_level, + mobile_notification_level: always_level, + created_at: Time.zone.now, + updated_at: Time.zone.now, + } + end + + Chat::UserChatChannelMembership.upsert_all( + memberships, + unique_by: %i[user_id chat_channel_id], + ) + end + end +end diff --git a/plugins/chat/app/services/chat/list_channel_messages.rb b/plugins/chat/app/services/chat/list_channel_messages.rb new file mode 100644 index 00000000000..6c1f5155a49 --- /dev/null +++ b/plugins/chat/app/services/chat/list_channel_messages.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module Chat + # List messages of a channel before and after a specific target (id, date), + # or fetching paginated messages from last read. + # + # @example + # Chat::ListChannelMessages.call(channel_id: 2, guardian: guardian, **optional_params) + # + class ListChannelMessages + include Service::Base + + # @!method call(guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + + model :channel + policy :can_view_channel + step :fetch_optional_membership + step :enabled_threads? + step :determine_target_message_id + policy :target_message_exists + step :fetch_messages + step :fetch_thread_ids + step :fetch_tracking + step :fetch_thread_participants + step :fetch_thread_memberships + step :update_membership_last_viewed_at + + class Contract + attribute :channel_id, :integer + validates :channel_id, presence: true + + attribute :page_size, :integer + validates :page_size, + numericality: { + less_than_or_equal_to: ::Chat::MessagesQuery::MAX_PAGE_SIZE, + only_integer: true, + }, + allow_nil: true + + # If this is not present, then we just fetch messages with page_size + # and direction. + attribute :target_message_id, :integer # (optional) + attribute :direction, :string # (optional) + attribute :fetch_from_last_read, :boolean # (optional) + attribute :target_date, :string # (optional) + + validates :direction, + inclusion: { + in: Chat::MessagesQuery::VALID_DIRECTIONS, + }, + allow_nil: true + end + + private + + def fetch_channel(contract:, **) + ::Chat::Channel.strict_loading.includes(:chatable).find_by(id: contract.channel_id) + end + + def fetch_optional_membership(channel:, guardian:, **) + context.membership = channel.membership_for(guardian.user) + end + + def enabled_threads?(channel:, **) + context.enabled_threads = channel.threading_enabled + end + + def can_view_channel(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) + end + + def determine_target_message_id(contract:, **) + if contract.fetch_from_last_read + context.target_message_id = context.membership&.last_read_message_id + else + context.target_message_id = contract.target_message_id + end + end + + def target_message_exists(channel:, guardian:, **) + return true if context.target_message_id.blank? + target_message = + Chat::Message.with_deleted.find_by(id: context.target_message_id, chat_channel: channel) + return false if target_message.blank? + return true if !target_message.trashed? + target_message.user_id == guardian.user.id || guardian.is_staff? + end + + def fetch_messages(channel:, contract:, guardian:, enabled_threads:, **) + messages_data = + ::Chat::MessagesQuery.call( + channel: channel, + guardian: guardian, + target_message_id: context.target_message_id, + include_thread_messages: !enabled_threads, + page_size: contract.page_size || Chat::MessagesQuery::MAX_PAGE_SIZE, + direction: contract.direction, + target_date: contract.target_date, + ) + + context.can_load_more_past = messages_data[:can_load_more_past] + context.can_load_more_future = messages_data[:can_load_more_future] + context.target_message_id = messages_data[:target_message_id] + + messages_data[:target_message] = ( + if enabled_threads && messages_data[:target_message]&.thread_reply? + [] + else + [messages_data[:target_message]] + end + ) + + context.messages = [ + messages_data[:messages], + messages_data[:past_messages]&.reverse, + messages_data[:target_message], + messages_data[:future_messages], + ].flatten.compact + end + + def fetch_tracking(guardian:, enabled_threads:, **) + context.tracking = {} + + return if !enabled_threads || !context.thread_ids.present? + + context.tracking = + ::Chat::TrackingStateReportQuery.call( + guardian: guardian, + thread_ids: context.thread_ids, + include_threads: true, + ) + end + + def fetch_thread_ids(messages:, **) + context.thread_ids = messages.map(&:thread_id).compact.uniq + end + + def fetch_thread_participants(messages:, **) + return if context.thread_ids.empty? + + context.thread_participants = + ::Chat::ThreadParticipantQuery.call(thread_ids: context.thread_ids) + end + + def fetch_thread_memberships(guardian:, **) + return if context.thread_ids.empty? + + context.thread_memberships = + ::Chat::UserChatThreadMembership.where( + thread_id: context.thread_ids, + user_id: guardian.user.id, + ) + end + + def update_membership_last_viewed_at(guardian:, **) + context.membership&.update!(last_viewed_at: Time.zone.now) + end + end +end diff --git a/plugins/chat/app/services/chat/list_channel_thread_messages.rb b/plugins/chat/app/services/chat/list_channel_thread_messages.rb new file mode 100644 index 00000000000..b592787e09f --- /dev/null +++ b/plugins/chat/app/services/chat/list_channel_thread_messages.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Chat + # List messages of a thread before and after a specific target (id, date), + # or fetching paginated messages from last read. + # + # @example + # Chat::ListThreadMessages.call(thread_id: 2, guardian: guardian, **optional_params) + # + class ListChannelThreadMessages + include Service::Base + + # @!method call(guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @option optional_params [Integer] thread_id + # @option optional_params [Integer] channel_id + # @return [Service::Base::Context] + + contract + + model :thread + policy :ensure_thread_enabled + policy :can_view_thread + step :fetch_optional_membership + step :determine_target_message_id + policy :target_message_exists + step :fetch_messages + + class Contract + attribute :thread_id, :integer + validates :thread_id, presence: true + + # If this is not present, then we just fetch messages with page_size + # and direction. + attribute :target_message_id, :integer # (optional) + attribute :direction, :string # (optional) + attribute :page_size, :integer # (optional) + attribute :fetch_from_last_read, :boolean # (optional) + attribute :target_date, :string # (optional) + + validates :direction, + inclusion: { + in: Chat::MessagesQuery::VALID_DIRECTIONS, + }, + allow_nil: true + validates :page_size, + numericality: { + less_than_or_equal_to: Chat::MessagesQuery::MAX_PAGE_SIZE, + only_integer: true, + }, + allow_nil: true + end + + private + + def fetch_optional_membership(thread:, guardian:, **) + context.membership = thread.membership_for(guardian.user) + end + + def fetch_thread(contract:, **) + ::Chat::Thread.strict_loading.includes(channel: :chatable).find_by(id: contract.thread_id) + end + + def ensure_thread_enabled(thread:, **) + thread.channel.threading_enabled + end + + def can_view_thread(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def determine_target_message_id(contract:, membership:, guardian:, **) + if contract.fetch_from_last_read + context.target_message_id = membership&.last_read_message_id + else + context.target_message_id = contract.target_message_id + end + end + + def target_message_exists(contract:, guardian:, **) + return true if context.target_message_id.blank? + target_message = + ::Chat::Message.with_deleted.find_by( + id: context.target_message_id, + thread_id: contract.thread_id, + ) + return false if target_message.blank? + return true if !target_message.trashed? + target_message.user_id == guardian.user.id || guardian.is_staff? + end + + def fetch_messages(thread:, guardian:, contract:, **) + messages_data = + ::Chat::MessagesQuery.call( + channel: thread.channel, + guardian: guardian, + target_message_id: context.target_message_id, + thread_id: thread.id, + page_size: contract.page_size || Chat::MessagesQuery::MAX_PAGE_SIZE, + direction: contract.direction, + target_date: contract.target_date, + ) + + context.can_load_more_past = messages_data[:can_load_more_past] + context.can_load_more_future = messages_data[:can_load_more_future] + + context.messages = [ + messages_data[:messages], + messages_data[:past_messages]&.reverse, + messages_data[:target_message], + messages_data[:future_messages], + ].flatten.compact + end + end +end diff --git a/plugins/chat/app/services/chat/lookup_channel_threads.rb b/plugins/chat/app/services/chat/lookup_channel_threads.rb new file mode 100644 index 00000000000..60255561906 --- /dev/null +++ b/plugins/chat/app/services/chat/lookup_channel_threads.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Chat + # Gets a list of threads for a channel to be shown in an index. + # In future pagination and filtering will be added -- for now + # we just want to return N threads ordered by the latest + # message that a user has sent in a thread. + # + # Only threads that the user is a member of with a notification level + # of normal or tracking will be returned. + # + # @example + # Chat::LookupChannelThreads.call(channel_id: 2, guardian: guardian, limit: 5, offset: 2) + # + class LookupChannelThreads + include Service::Base + + THREADS_LIMIT = 10 + + # @!method call(channel_id:, guardian:, limit: nil, offset: nil) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [Integer] limit + # @param [Integer] offset + # @return [Service::Base::Context] + + contract + step :set_limit + step :set_offset + model :channel + policy :threading_enabled_for_channel + policy :can_view_channel + model :threads + step :fetch_tracking + step :fetch_memberships + step :build_load_more_url + + # @!visibility private + class Contract + attribute :channel_id, :integer + validates :channel_id, presence: true + + attribute :limit, :integer + attribute :offset, :integer + end + + private + + def set_limit(contract:, **) + context.limit = (contract.limit || THREADS_LIMIT).to_i.clamp(1, THREADS_LIMIT) + end + + def set_offset(contract:, **) + context.offset = [contract.offset || 0, 0].max + end + + def fetch_channel(contract:, **) + ::Chat::Channel.strict_loading.includes(:chatable).find_by(id: contract.channel_id) + end + + def threading_enabled_for_channel(channel:, **) + channel.threading_enabled + end + + def can_view_channel(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) + end + + def fetch_threads(guardian:, channel:, **) + ::Chat::Thread + .strict_loading + .includes( + :channel, + :user_chat_thread_memberships, + original_message_user: :user_status, + last_message: [ + :uploads, + :chat_webhook_event, + :chat_channel, + chat_mentions: { + user: :user_status, + }, + user: :user_status, + ], + original_message: [ + :uploads, + :chat_webhook_event, + :chat_channel, + chat_mentions: { + user: :user_status, + }, + user: :user_status, + ], + ) + .joins(:user_chat_thread_memberships, :original_message) + .joins( + "LEFT JOIN chat_messages AS last_message ON last_message.id = chat_threads.last_message_id", + ) + .where("user_chat_thread_memberships.user_id = ?", guardian.user.id) + .where( + "user_chat_thread_memberships.notification_level IN (?)", + [ + ::Chat::UserChatThreadMembership.notification_levels[:normal], + ::Chat::UserChatThreadMembership.notification_levels[:tracking], + ], + ) + .where("chat_threads.channel_id = ?", channel.id) + .where("last_message.deleted_at IS NULL") + .limit(context.limit) + .offset(context.offset) + .order( + "CASE WHEN ( + chat_threads.last_message_id > user_chat_thread_memberships.last_read_message_id OR + user_chat_thread_memberships.last_read_message_id IS NULL + ) THEN 0 ELSE 1 END, last_message.created_at DESC", + ) + end + + def fetch_tracking(guardian:, threads:, **) + context.tracking = + ::Chat::TrackingStateReportQuery.call( + guardian: guardian, + thread_ids: threads.map(&:id), + include_threads: true, + ).thread_tracking + end + + def fetch_memberships(guardian:, threads:, **) + context.memberships = + ::Chat::UserChatThreadMembership.where( + thread_id: threads.map(&:id), + user_id: guardian.user.id, + ) + end + + def build_load_more_url(contract:, **) + load_more_params = { offset: context.offset + context.limit }.to_query + context.load_more_url = + ::URI::HTTP.build( + path: "/chat/api/channels/#{contract.channel_id}/threads", + query: load_more_params, + ).request_uri + end + end +end diff --git a/plugins/chat/app/services/chat/lookup_thread.rb b/plugins/chat/app/services/chat/lookup_thread.rb new file mode 100644 index 00000000000..789665ae588 --- /dev/null +++ b/plugins/chat/app/services/chat/lookup_thread.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Chat + # Finds a thread within a channel. The thread_id and channel_id must + # match, and the channel must specifically have threading enabled. + # + # @example + # Chat::LookupThread.call(thread_id: 88, channel_id: 2, guardian: guardian) + # + class LookupThread + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :thread, :fetch_thread + policy :invalid_access + policy :threading_enabled_for_channel + step :fetch_membership + step :fetch_participants + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + + validates :thread_id, :channel_id, presence: true + end + + private + + def fetch_thread(contract:, **) + Chat::Thread.includes( + :channel, + original_message_user: :user_status, + original_message: :chat_webhook_event, + ).find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def invalid_access(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + + def fetch_membership(thread:, guardian:, **) + context.membership = thread.membership_for(guardian.user) + end + + def fetch_participants(thread:, **) + context.participants = ::Chat::ThreadParticipantQuery.call(thread_ids: [thread.id])[thread.id] + end + end +end diff --git a/plugins/chat/app/services/chat/mark_all_user_channels_read.rb b/plugins/chat/app/services/chat/mark_all_user_channels_read.rb new file mode 100644 index 00000000000..0f88cdc1101 --- /dev/null +++ b/plugins/chat/app/services/chat/mark_all_user_channels_read.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for marking all the channels that a user is a + # member of _and following_ as read, including mentions. + # + # @example + # Chat::MarkAllUserChannelsRead.call(guardian: guardian) + # + class MarkAllUserChannelsRead + include ::Service::Base + + # @!method call(guardian:) + # @param [Guardian] guardian + # @return [Service::Base::Context] + + transaction do + step :update_last_read_message_ids + step :mark_associated_mentions_as_read + step :publish_user_tracking_state + end + + private + + def update_last_read_message_ids(guardian:, **) + updated_memberships = DB.query(<<~SQL, user_id: guardian.user.id) + UPDATE user_chat_channel_memberships + SET last_read_message_id = chat_channels.last_message_id + FROM chat_channels + WHERE user_chat_channel_memberships.chat_channel_id = chat_channels.id AND + chat_channels.last_message_id > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) AND + user_chat_channel_memberships.user_id = :user_id AND + user_chat_channel_memberships.following + RETURNING user_chat_channel_memberships.id AS membership_id, + user_chat_channel_memberships.chat_channel_id AS channel_id, + user_chat_channel_memberships.last_read_message_id; + SQL + context[:updated_memberships] = updated_memberships + end + + def mark_associated_mentions_as_read(guardian:, updated_memberships:, **) + return if updated_memberships.empty? + + ::Chat::Action::MarkMentionsRead.call( + guardian.user, + channel_ids: updated_memberships.map(&:channel_id), + ) + end + + def publish_user_tracking_state(guardian:, updated_memberships:, **) + data = + updated_memberships.each_with_object({}) do |membership, data_hash| + data_hash[membership.channel_id] = { + last_read_message_id: membership.last_read_message_id, + membership_id: membership.membership_id, + } + end + Chat::Publisher.publish_bulk_user_tracking_state!(guardian.user, data) + end + end +end diff --git a/plugins/chat/app/services/chat/message_destroyer.rb b/plugins/chat/app/services/chat/message_destroyer.rb new file mode 100644 index 00000000000..6262a0df9bb --- /dev/null +++ b/plugins/chat/app/services/chat/message_destroyer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Chat + class MessageDestroyer + def destroy_in_batches(chat_messages_query, batch_size: 200) + chat_messages_query + .in_batches(of: batch_size) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id, :chat_channel_id) + destroyed_message_ids = destroyed_ids.map(&:first).uniq + destroyed_message_channel_ids = destroyed_ids.map(&:second).uniq + + # This needs to be done before reset_last_read so we can lean on the last_message_id + # there. + reset_last_message_ids(destroyed_message_ids, destroyed_message_channel_ids) + + reset_last_read(destroyed_message_ids, destroyed_message_channel_ids) + delete_flags(destroyed_message_ids) + end + end + + private + + def reset_last_message_ids(destroyed_message_ids, destroyed_message_channel_ids) + ::Chat::Action::ResetChannelsLastMessageIds.call( + destroyed_message_ids, + destroyed_message_channel_ids, + ) + end + + def reset_last_read(destroyed_message_ids, destroyed_message_channel_ids) + ::Chat::Action::ResetUserLastReadChannelMessage.call( + destroyed_message_ids, + destroyed_message_channel_ids, + ) + end + + def delete_flags(message_ids) + Chat::ReviewableMessage.where(target_id: message_ids).destroy_all + end + 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 00000000000..5d5a39bdadd --- /dev/null +++ b/plugins/chat/app/services/chat/publisher.rb @@ -0,0 +1,495 @@ +# frozen_string_literal: true + +module Chat + module Publisher + def self.new_messages_message_bus_channel(chat_channel_id) + "#{root_message_bus_channel(chat_channel_id)}/new-messages" + end + + def self.root_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}" + end + + def self.thread_message_bus_channel(chat_channel_id, thread_id) + "#{root_message_bus_channel(chat_channel_id)}/thread/#{thread_id}" + end + + def self.calculate_publish_targets(channel, message, staged_thread_id: nil) + return [root_message_bus_channel(channel.id)] if !allow_publish_to_thread?(channel) + + if message.thread_om? + [ + root_message_bus_channel(channel.id), + thread_message_bus_channel(channel.id, message.thread_id), + ] + elsif staged_thread_id || message.thread_reply? + targets = [thread_message_bus_channel(channel.id, message.thread_id)] + targets << thread_message_bus_channel(channel.id, staged_thread_id) if staged_thread_id + targets + else + [root_message_bus_channel(channel.id)] + end + end + + def self.allow_publish_to_thread?(channel) + channel.threading_enabled + end + + def self.publish_new!(chat_channel, chat_message, staged_id, staged_thread_id: nil) + message_bus_targets = + calculate_publish_targets(chat_channel, chat_message, staged_thread_id: staged_thread_id) + publish_to_targets!( + message_bus_targets, + chat_channel, + serialize_message_with_type(chat_message, :sent).merge( + staged_id: staged_id, + staged_thread_id: staged_thread_id, + ), + ) + + if !chat_message.thread_reply? || !allow_publish_to_thread?(chat_channel) + MessageBus.publish( + self.new_messages_message_bus_channel(chat_channel.id), + { + type: "channel", + channel_id: chat_channel.id, + thread_id: chat_message.thread_id, + message: + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: false }, + ).as_json, + }, + ) + end + + if chat_message.thread_reply? && allow_publish_to_thread?(chat_channel) + MessageBus.publish( + self.new_messages_message_bus_channel(chat_channel.id), + { + type: "thread", + channel_id: chat_channel.id, + thread_id: chat_message.thread_id, + message: + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: false }, + ).as_json, + }, + permissions(chat_channel), + ) + + publish_thread_original_message_metadata!(chat_message.thread) + end + end + + def self.publish_thread_original_message_metadata!(thread) + preview = + ::Chat::ThreadPreviewSerializer.new( + thread, + participants: ::Chat::ThreadParticipantQuery.call(thread_ids: [thread.id])[thread.id], + root: false, + ).as_json + publish_to_channel!( + thread.channel, + { + type: :update_thread_original_message, + original_message_id: thread.original_message_id, + preview: preview.as_json, + }, + ) + end + + def self.publish_thread_created!(chat_channel, chat_message, thread_id, staged_thread_id) + publish_to_channel!( + chat_channel, + serialize_message_with_type( + chat_message, + :thread_created, + { thread_id: thread_id, staged_thread_id: staged_thread_id }, + ), + ) + end + + def self.publish_processed!(chat_message) + chat_channel = chat_message.chat_channel + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + publish_to_targets!( + message_bus_targets, + chat_channel, + { type: :processed, chat_message: { id: chat_message.id, cooked: chat_message.cooked } }, + ) + end + + def self.publish_edit!(chat_channel, chat_message) + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + publish_to_targets!( + message_bus_targets, + chat_channel, + serialize_message_with_type(chat_message, :edit), + ) + end + + def self.publish_refresh!(chat_channel, chat_message) + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + publish_to_targets!( + message_bus_targets, + chat_channel, + serialize_message_with_type(chat_message, :refresh), + ) + end + + def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + publish_to_targets!( + message_bus_targets, + chat_channel, + { + action: action, + user: BasicUserSerializer.new(user, root: false).as_json, + emoji: emoji, + type: :reaction, + chat_message_id: chat_message.id, + }, + ) + end + + def self.publish_presence!(chat_channel, user, typ) + raise NotImplementedError + end + + def self.publish_delete!(chat_channel, chat_message) + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + latest_not_deleted_message_id = + if chat_message.thread_reply? && chat_channel.threading_enabled + chat_message.thread.latest_not_deleted_message_id(anchor_message_id: chat_message.id) + else + chat_channel.latest_not_deleted_message_id(anchor_message_id: chat_message.id) + end + publish_to_targets!( + message_bus_targets, + chat_channel, + { + type: "delete", + deleted_id: chat_message.id, + deleted_at: chat_message.deleted_at, + deleted_by_id: chat_message.deleted_by_id, + latest_not_deleted_message_id: latest_not_deleted_message_id, + }, + ) + end + + def self.publish_bulk_delete!(chat_channel, deleted_message_ids) + channel_permissions = permissions(chat_channel) + Chat::Thread + .grouped_messages(message_ids: deleted_message_ids) + .each do |group| + MessageBus.publish( + thread_message_bus_channel(chat_channel.id, group.thread_id), + { + type: :bulk_delete, + deleted_ids: group.thread_message_ids, + deleted_at: Time.zone.now, + }, + channel_permissions, + ) + + # Don't need to publish to the main channel if the messages deleted + # were a part of the thread (except the original message ID, since + # that shows in the main channel). + deleted_message_ids = + deleted_message_ids - (group.thread_message_ids - [group.original_message_id]) + end + + return if deleted_message_ids.empty? + + publish_to_channel!( + chat_channel, + { type: :bulk_delete, deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, + ) + end + + def self.publish_restore!(chat_channel, chat_message) + message_bus_targets = calculate_publish_targets(chat_channel, chat_message) + publish_to_targets!( + message_bus_targets, + chat_channel, + serialize_message_with_type(chat_message, :restore), + ) + end + + def self.publish_flag!(chat_message, user, reviewable, score) + message_bus_targets = calculate_publish_targets(chat_message.chat_channel, chat_message) + + # Publish to user who created flag + publish_to_targets!( + message_bus_targets, + chat_message.chat_channel, + { + type: :self_flagged, + user_flag_status: score.status_for_database, + chat_message_id: chat_message.id, + }, + permissions: { + user_ids: [user.id], + }, + ) + + # Publish flag with link to reviewable to staff + publish_to_targets!( + message_bus_targets, + chat_message.chat_channel, + { type: :flag, chat_message_id: chat_message.id, reviewable_id: reviewable.id }, + permissions: { + group_ids: [Group::AUTO_GROUPS[:staff]], + }, + ) + end + + def self.publish_to_channel!(channel, payload) + MessageBus.publish( + root_message_bus_channel(channel.id), + payload.as_json, + permissions(channel), + ) + end + + def self.publish_to_targets!(targets, channel, payload, permissions: nil) + targets.each do |message_bus_channel| + MessageBus.publish( + message_bus_channel, + payload.as_json, + permissions || permissions(channel), + ) + end + end + + def self.serialize_message_with_type(chat_message, type, options = {}) + Chat::MessageSerializer + .new(chat_message, { scope: anonymous_guardian, root: :chat_message }) + .as_json + .merge(type: type) + .merge(options) + end + + def self.user_tracking_state_message_bus_channel(user_id) + "/chat/user-tracking-state/#{user_id}" + end + + def self.publish_user_tracking_state!(user, channel, message) + data = { + channel_id: channel.id, + last_read_message_id: message.id, + thread_id: message.thread_id, + } + + channel_tracking_data = + Chat::TrackingStateReportQuery.call( + guardian: user.guardian, + channel_ids: [channel.id], + include_missing_memberships: true, + ).find_channel(channel.id) + + data.merge!(channel_tracking_data) + + # Need the thread unread overview if channel has threading enabled + # and a message is sent in the thread. We also need to pass the actual + # thread tracking state. + if channel.threading_enabled && message.thread_reply? + data[:unread_thread_overview] = ::Chat::TrackingStateReportQuery.call( + guardian: user.guardian, + channel_ids: [channel.id], + include_threads: true, + include_read: false, + include_last_reply_details: true, + ).find_channel_thread_overviews(channel.id) + + data[:thread_tracking] = ::Chat::TrackingStateReportQuery.call( + guardian: user.guardian, + thread_ids: [message.thread_id], + include_threads: true, + include_missing_memberships: true, + ).find_thread(message.thread_id) + end + + MessageBus.publish( + self.user_tracking_state_message_bus_channel(user.id), + data.as_json, + user_ids: [user.id], + ) + end + + def self.bulk_user_tracking_state_message_bus_channel(user_id) + "/chat/bulk-user-tracking-state/#{user_id}" + end + + def self.publish_bulk_user_tracking_state!(user, channel_last_read_map) + tracking_data = + Chat::TrackingState.call( + guardian: Guardian.new(user), + channel_ids: channel_last_read_map.keys, + include_missing_memberships: true, + ) + if tracking_data.failure? + raise StandardError, + "Tracking service failed when trying to publish bulk tracking state:\n\n#{tracking_data.inspect_steps}" + end + + channel_last_read_map.each do |key, value| + channel_last_read_map[key] = value.merge(tracking_data.report.find_channel(key)) + end + + MessageBus.publish( + self.bulk_user_tracking_state_message_bus_channel(user.id), + channel_last_read_map.as_json, + user_ids: [user.id], + ) + end + + def self.new_mentions_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/new-mentions" + end + + def self.kick_users_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/kick" + end + + def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) + MessageBus.publish( + self.new_mentions_message_bus_channel(chat_channel_id), + { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, + user_ids: [user_id], + ) + end + + NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" + + def self.publish_new_channel(chat_channel, users) + Chat::UserChatChannelMembership + .includes(:user) + .where(chat_channel: chat_channel, user: users) + .find_in_batches do |memberships| + memberships.each do |membership| + serialized_channel = + Chat::ChannelSerializer.new( + chat_channel, + scope: membership.user.guardian, # We need a guardian here for direct messages + root: :channel, + membership: membership, + ).as_json + + MessageBus.publish( + NEW_CHANNEL_MESSAGE_BUS_CHANNEL, + serialized_channel, + user_ids: [membership.user.id], + ) + end + end + end + + def self.publish_inaccessible_mentions( + user_id, + chat_message, + cannot_chat_users, + without_membership, + too_many_members, + mentions_disabled + ) + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: :mention_warning, + chat_message_id: chat_message.id, + cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, + without_membership: + without_membership.map { |u| { username: u.username, id: u.id } }.as_json, + groups_with_too_many_members: too_many_members.map(&:name).as_json, + group_mentions_disabled: mentions_disabled.map(&:name).as_json, + }, + user_ids: [user_id], + ) + end + + def self.publish_kick_users(channel_id, user_ids) + MessageBus.publish( + kick_users_message_bus_channel(channel_id), + { channel_id: channel_id }, + user_ids: user_ids, + ) + end + + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" + + def self.publish_chat_channel_edit(chat_channel, acting_user) + MessageBus.publish( + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + { + chat_channel_id: chat_channel.id, + name: chat_channel.title(acting_user), + description: chat_channel.description, + slug: chat_channel.slug, + }, + permissions(chat_channel), + ) + end + + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" + + def self.publish_channel_status(chat_channel) + MessageBus.publish( + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, status: chat_channel.status }, + permissions(chat_channel), + ) + end + + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" + + def self.publish_chat_channel_metadata(chat_channel) + MessageBus.publish( + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, + permissions(chat_channel), + ) + end + + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" + + def self.publish_archive_status( + chat_channel, + archive_status:, + archived_messages:, + archive_topic_id:, + total_messages: + ) + MessageBus.publish( + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + { + 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 + + def self.publish_notice(user_id:, channel_id:, text_content:) + payload = { type: "notice", text_content: text_content, channel_id: channel_id } + + MessageBus.publish("/chat/#{channel_id}", payload, user_ids: [user_id]) + 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 +end diff --git a/plugins/chat/app/services/chat/restore_message.rb b/plugins/chat/app/services/chat/restore_message.rb new file mode 100644 index 00000000000..e388b687fa8 --- /dev/null +++ b/plugins/chat/app/services/chat/restore_message.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for restoring a trashed chat message + # for a channel and ensuring that the client and read state is + # updated. + # + # @example + # Chat::RestoreMessage.call(message_id: 2, channel_id: 1, guardian: guardian) + # + class RestoreMessage + include Service::Base + + # @!method call(message_id:, channel_id:, guardian:) + # @param [Integer] message_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :message + policy :invalid_access + transaction do + step :restore_message + step :update_last_message_ids + step :update_thread_reply_cache + end + step :publish_events + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :channel_id, :integer + validates :message_id, presence: true + validates :channel_id, presence: true + end + + private + + def fetch_message(contract:, **) + Chat::Message + .with_deleted + .includes(chat_channel: :chatable) + .find_by(id: contract.message_id, chat_channel_id: contract.channel_id) + end + + def invalid_access(guardian:, message:, **) + guardian.can_restore_chat?(message, message.chat_channel.chatable) + end + + def restore_message(message:, **) + message.recover! + end + + def update_thread_reply_cache(message:, **) + message.thread&.increment_replies_count_cache + end + + def update_last_message_ids(message:, **) + message.thread&.update_last_message_id! + message.chat_channel.update_last_message_id! + end + + def publish_events(guardian:, message:, **) + DiscourseEvent.trigger(:chat_message_restored, message, message.chat_channel, guardian.user) + Chat::Publisher.publish_restore!(message.chat_channel, message) + + if message.thread.present? + Chat::Publisher.publish_thread_original_message_metadata!(message.thread) + end + end + end +end diff --git a/plugins/chat/app/services/chat/search_chatable.rb b/plugins/chat/app/services/chat/search_chatable.rb new file mode 100644 index 00000000000..d0edb6435dd --- /dev/null +++ b/plugins/chat/app/services/chat/search_chatable.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Chat + # Returns a list of chatables (users, category channels, direct message channels) that can be chatted with. + # + # @example + # Chat::SearchChatable.call(term: "@bob", guardian: guardian) + # + class SearchChatable + include Service::Base + + # @!method call(term:, guardian:) + # @param [String] term + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + step :set_mode + step :clean_term + step :fetch_memberships + step :fetch_users + step :fetch_category_channels + step :fetch_direct_message_channels + + # @!visibility private + class Contract + attribute :term, default: "" + end + + private + + def set_mode + context.mode = + if context.contract.term&.start_with?("#") + :channel + elsif context.contract.term&.start_with?("@") + :user + else + :all + end + end + + def clean_term(contract:, **) + context.term = contract.term.downcase&.gsub(/^#+/, "")&.gsub(/^@+/, "")&.strip + end + + def fetch_memberships(guardian:, **) + context.memberships = ::Chat::ChannelMembershipManager.all_for_user(guardian.user) + end + + def fetch_users(guardian:, **) + return unless guardian.can_create_direct_message? + return if context.mode == :channel + context.users = search_users(context.term, guardian) + end + + def fetch_category_channels(guardian:, **) + return if context.mode == :user + return if !SiteSetting.enable_public_channels + + context.category_channels = + ::Chat::ChannelFetcher.secured_public_channel_search( + guardian, + filter_on_category_name: false, + match_filter_on_starts_with: false, + filter: context.term, + status: :open, + limit: 10, + ) + end + + def fetch_direct_message_channels(guardian:, **args) + return if context.mode == :user + + user_ids = nil + if context.term.length > 0 + user_ids = + (context.users.nil? ? search_users(context.term, guardian) : context.users).map(&:id) + end + + channels = + ::Chat::ChannelFetcher.secured_direct_message_channels_search( + guardian.user.id, + guardian, + limit: 10, + user_ids: user_ids, + ) || [] + + if user_ids.present? && context.mode == :all + channels = + channels.reject do |channel| + channel_user_ids = channel.allowed_user_ids - [guardian.user.id] + channel.allowed_user_ids.length == 1 && + user_ids.include?(channel.allowed_user_ids.first) || + channel_user_ids.length == 1 && user_ids.include?(channel_user_ids.first) + end + end + + context.direct_message_channels = channels + end + + def search_users(term, guardian) + user_search = ::UserSearch.new(term, limit: 10) + + if term.blank? + user_search.scoped_users.includes(:user_option) + else + user_search.search.includes(:user_option) + end + end + end +end diff --git a/plugins/chat/app/services/chat/tracking_state.rb b/plugins/chat/app/services/chat/tracking_state.rb new file mode 100644 index 00000000000..17cec53be51 --- /dev/null +++ b/plugins/chat/app/services/chat/tracking_state.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Chat + # Produces the current tracking state for a user for one or more + # chat channels. This can be further filtered by providing one or + # more thread IDs for the channel. + # + # The goal of this class is to provide an easy way to get + # tracking state for: + # + # * A single channel + # * A single thread + # * Multiple channels and threads + # + # This is limited to 500 channels and 2000 threads by default, + # over time we can re-examine this if we find the need to. + # + # The user must be a member of these channels -- any channels + # they are not a member of will always return 0 for unread/mention + # counts at all times. + # + # Only channels with threads enabled will return thread tracking state. + # + # @example + # Chat::TrackingState.call(channel_ids: [2, 3], thread_ids: [6, 7], guardian: guardian) + # + class TrackingState + include Service::Base + + # @!method call(thread_ids:, channel_ids:, guardian:) + # @param [Integer] thread_ids + # @param [Integer] channel_ids + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + step :cast_thread_and_channel_ids_to_integer + model :report + + # @!visibility private + class Contract + attribute :channel_ids, default: [] + attribute :thread_ids, default: [] + attribute :include_missing_memberships, default: false + attribute :include_threads, default: false + attribute :include_read, default: true + end + + private + + def cast_thread_and_channel_ids_to_integer(contract:, **) + contract.thread_ids = contract.thread_ids.map(&:to_i) + contract.channel_ids = contract.channel_ids.map(&:to_i) + end + + def fetch_report(contract:, guardian:, **) + ::Chat::TrackingStateReportQuery.call( + guardian: guardian, + channel_ids: contract.channel_ids, + thread_ids: contract.thread_ids, + include_missing_memberships: contract.include_missing_memberships, + include_threads: contract.include_threads, + include_read: contract.include_read, + ) + end + end +end diff --git a/plugins/chat/app/services/chat/trash_channel.rb b/plugins/chat/app/services/chat/trash_channel.rb new file mode 100644 index 00000000000..33a80b2c018 --- /dev/null +++ b/plugins/chat/app/services/chat/trash_channel.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for trashing a chat channel. + # Note the slug is modified to prevent collisions. + # + # @example + # Chat::TrashChannel.call(channel_id: 2, guardian: guardian) + # + class TrashChannel + include Service::Base + + # @!method call(channel_id:, guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + DELETE_CHANNEL_LOG_KEY = "chat_channel_delete" + + model :channel, :fetch_channel + policy :invalid_access + transaction do + step :prevents_slug_collision + step :soft_delete_channel + step :log_channel_deletion + end + step :enqueue_delete_channel_relations_job + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def invalid_access(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + end + + def prevents_slug_collision(channel:, **) + channel.update!( + slug: + "#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate( + SiteSetting.max_topic_title_length, + omission: "", + ), + ) + end + + def soft_delete_channel(guardian:, channel:, **) + channel.trash!(guardian.user) + end + + def log_channel_deletion(guardian:, channel:, **) + StaffActionLogger.new(guardian.user).log_custom( + DELETE_CHANNEL_LOG_KEY, + { chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) }, + ) + end + + def enqueue_delete_channel_relations_job(channel:, **) + Jobs.enqueue(Jobs::Chat::ChannelDelete, chat_channel_id: channel.id) + end + end +end diff --git a/plugins/chat/app/services/chat/trash_message.rb b/plugins/chat/app/services/chat/trash_message.rb new file mode 100644 index 00000000000..2d3b6d7a6c5 --- /dev/null +++ b/plugins/chat/app/services/chat/trash_message.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for trashing a chat message + # for a channel and ensuring that the client and read state is + # updated. + # + # @example + # Chat::TrashMessage.call(message_id: 2, channel_id: 1, guardian: guardian) + # + class TrashMessage + include Service::Base + + # @!method call(message_id:, channel_id:, guardian:) + # @param [Integer] message_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :message + policy :invalid_access + transaction do + step :trash_message + step :destroy_notifications + step :update_last_message_ids + step :update_tracking_state + step :update_thread_reply_cache + end + step :publish_events + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :channel_id, :integer + validates :message_id, presence: true + validates :channel_id, presence: true + end + + private + + def fetch_message(contract:, **) + Chat::Message.includes(chat_channel: :chatable).find_by( + id: contract.message_id, + chat_channel_id: contract.channel_id, + ) + end + + def invalid_access(guardian:, message:, **) + guardian.can_delete_chat?(message, message.chat_channel.chatable) + end + + def trash_message(message:, guardian:, **) + message.trash!(guardian.user) + end + + def destroy_notifications(message:, **) + ids = Chat::Mention.where(chat_message: message).pluck(:notification_id) + Notification.where(id: ids).destroy_all + Chat::Mention.where(chat_message: message).update_all(notification_id: nil) + end + + def update_tracking_state(message:, **) + ::Chat::Action::ResetUserLastReadChannelMessage.call([message.id], [message.chat_channel_id]) + if message.thread_id.present? + ::Chat::Action::ResetUserLastReadThreadMessage.call([message.id], [message.thread_id]) + end + end + + def update_thread_reply_cache(message:, **) + message.thread&.decrement_replies_count_cache + end + + def update_last_message_ids(message:, **) + message.thread&.update_last_message_id! + message.chat_channel.update_last_message_id! + end + + def publish_events(guardian:, message:, **) + DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, guardian.user) + Chat::Publisher.publish_delete!(message.chat_channel, message) + + if message.thread.present? + Chat::Publisher.publish_thread_original_message_metadata!(message.thread) + end + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel.rb b/plugins/chat/app/services/chat/update_channel.rb new file mode 100644 index 00000000000..fdd7cd5f85e --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel's name, slug, and description. + # + # For a CategoryChannel, the settings for auto_join_users, allow_channel_wide_mentions + # and threading_enabled are also editable. + # + # @example + # Service::Chat::UpdateChannel.call( + # channel_id: 2, + # guardian: guardian, + # name: "SuperChannel", + # description: "This is the best channel", + # slug: "super-channel", + # threading_enabled: true, + # ) + # + class UpdateChannel + include Service::Base + + # @!method call(channel_id:, guardian:, **params_to_edit) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [Hash] params_to_edit + # @option params_to_edit [String,nil] name + # @option params_to_edit [String,nil] description + # @option params_to_edit [String,nil] slug + # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users + # with permission to see the category should automatically join the channel. + # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. + # @return [Service::Base::Context] + + model :channel, :fetch_channel + policy :no_direct_message_channel + policy :check_channel_permission + contract default_values_from: :channel + step :update_channel + step :mark_all_threads_as_read_if_needed + step :publish_channel_update + step :auto_join_users_if_needed + + # @!visibility private + class Contract + attribute :name, :string + attribute :description, :string + attribute :slug, :string + attribute :threading_enabled, :boolean, default: false + attribute :auto_join_users, :boolean, default: false + attribute :allow_channel_wide_mentions, :boolean, default: true + + before_validation do + assign_attributes( + attributes.symbolize_keys.slice(:name, :description, :slug).transform_values(&:presence), + ) + end + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def no_direct_message_channel(channel:, **) + !channel.direct_message_channel? + end + + def check_channel_permission(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? + end + + def update_channel(channel:, contract:, **) + channel.assign_attributes(contract.attributes) + context.threading_enabled_changed = channel.threading_enabled_changed? + channel.save! + end + + def mark_all_threads_as_read_if_needed(channel:, **) + return if !(context.threading_enabled_changed && channel.threading_enabled) + Jobs.enqueue(Jobs::Chat::MarkAllChannelThreadsRead, channel_id: channel.id) + end + + def publish_channel_update(channel:, guardian:, **) + Chat::Publisher.publish_chat_channel_edit(channel, guardian.user) + end + + def auto_join_users_if_needed(channel:, **) + return unless channel.auto_join_users? + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel_status.rb b/plugins/chat/app/services/chat/update_channel_status.rb new file mode 100644 index 00000000000..f3e84185942 --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel_status.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel status. + # + # @example + # Chat::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open") + # + class UpdateChannelStatus + include Service::Base + + # @!method call(channel_id:, guardian:, status:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [String] status + # @return [Service::Base::Context] + + model :channel, :fetch_channel + contract + policy :check_channel_permission + step :change_status + + # @!visibility private + class Contract + attribute :status + validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def check_channel_permission(guardian:, channel:, status:, **) + guardian.can_preview_chat_channel?(channel) && + guardian.can_change_channel_status?(channel, status.to_sym) + end + + def change_status(channel:, status:, guardian:, **) + channel.public_send("#{status}!", guardian.user) + end + end +end diff --git a/plugins/chat/app/services/chat/update_thread.rb b/plugins/chat/app/services/chat/update_thread.rb new file mode 100644 index 00000000000..b4ccad8673e --- /dev/null +++ b/plugins/chat/app/services/chat/update_thread.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Chat + # Updates a thread. The thread_id and channel_id must + # match, and the channel must specifically have threading enabled. + # + # Only the thread title can be updated. + # + # @example + # Chat::UpdateThread.call(thread_id: 88, channel_id: 2, guardian: guardian, title: "Restaurant for Saturday") + # + class UpdateThread + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:, **params_to_edit) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @option params_to_edit [String,nil] title + # @return [Service::Base::Context] + + contract + model :thread, :fetch_thread + policy :can_view_channel + policy :can_edit_thread + policy :threading_enabled_for_channel + step :update + step :publish_metadata + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + attribute :title, :string + + validates :thread_id, :channel_id, presence: true + validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH } + end + + private + + def fetch_thread(contract:, **) + Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def can_view_channel(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def can_edit_thread(guardian:, thread:, **) + guardian.can_edit_thread?(thread) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + + def update(thread:, contract:, **) + thread.update(title: contract.title) + fail!(thread.errors.full_messages.join(", ")) if thread.invalid? + end + + def publish_metadata(thread:, **) + Chat::Publisher.publish_thread_original_message_metadata!(thread) + end + end +end diff --git a/plugins/chat/app/services/chat/update_thread_notification_settings.rb b/plugins/chat/app/services/chat/update_thread_notification_settings.rb new file mode 100644 index 00000000000..2f1c30e3d6d --- /dev/null +++ b/plugins/chat/app/services/chat/update_thread_notification_settings.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Chat + # Updates the thread notification level for a user, or if the thread + # does not exist, adds the user as a member of the thread before setting + # the notification level. + # + # @example + # Chat::UpdateThreadNotificationSettings.call( + # thread_id: 88, + # channel_id: 2, + # guardian: guardian, + # notification_level: notification_level, + # ) + # + class UpdateThreadNotificationSettings + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:, notification_level:) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Integer] notification_level + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :thread, :fetch_thread + policy :can_view_channel + policy :threading_enabled_for_channel + transaction { step :create_or_update_membership } + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + attribute :notification_level, :integer + + validates :thread_id, :channel_id, :notification_level, presence: true + + validates :notification_level, + inclusion: { + in: Chat::UserChatThreadMembership.notification_levels.values, + } + end + + private + + def fetch_thread(contract:, **) + Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def can_view_channel(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + + def create_or_update_membership(thread:, guardian:, contract:, **) + membership = thread.membership_for(guardian.user) + if !membership + membership = thread.add(guardian.user) + membership.update!(last_read_message_id: thread.last_message_id) + end + membership.update!(notification_level: contract.notification_level) + context.membership = membership + end + end +end diff --git a/plugins/chat/app/services/chat/update_user_last_read.rb b/plugins/chat/app/services/chat/update_user_last_read.rb new file mode 100644 index 00000000000..6d9c06caffc --- /dev/null +++ b/plugins/chat/app/services/chat/update_user_last_read.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating the last read message id of a membership. + # + # @example + # Chat::UpdateUserLastRead.call(channel_id: 2, message_id: 3, guardian: guardian) + # + class UpdateUserLastRead + include ::Service::Base + + # @!method call(channel_id:, message_id:, guardian:) + # @param [Integer] channel_id + # @param [Integer] message_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :channel + model :active_membership + policy :invalid_access + model :message + policy :ensure_message_id_recency + transaction do + step :update_membership_state + step :mark_associated_mentions_as_read + end + step :publish_new_last_read_to_clients + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :channel_id, :integer + + validates :message_id, :channel_id, presence: true + end + + private + + def fetch_channel(contract:, **) + ::Chat::Channel.find_by(id: contract.channel_id) + end + + def fetch_active_membership(guardian:, channel:, **) + ::Chat::ChannelMembershipManager.new(channel).find_for_user(guardian.user, following: true) + end + + def invalid_access(guardian:, active_membership:, **) + guardian.can_join_chat_channel?(active_membership.chat_channel) + end + + def fetch_message(channel:, contract:, **) + ::Chat::Message.with_deleted.find_by(chat_channel_id: channel.id, id: contract.message_id) + end + + def ensure_message_id_recency(message:, active_membership:, **) + !active_membership.last_read_message_id || + message.id >= active_membership.last_read_message_id + end + + def update_membership_state(message:, active_membership:, **) + active_membership.update!(last_read_message_id: message.id, last_viewed_at: Time.zone.now) + end + + def mark_associated_mentions_as_read(active_membership:, message:, **) + ::Chat::Action::MarkMentionsRead.call( + active_membership.user, + channel_ids: [active_membership.chat_channel.id], + message_id: message.id, + ) + end + + def publish_new_last_read_to_clients(guardian:, channel:, message:, **) + ::Chat::Publisher.publish_user_tracking_state!(guardian.user, channel, message) + end + end +end diff --git a/plugins/chat/app/services/chat/update_user_thread_last_read.rb b/plugins/chat/app/services/chat/update_user_thread_last_read.rb new file mode 100644 index 00000000000..8785b41b92c --- /dev/null +++ b/plugins/chat/app/services/chat/update_user_thread_last_read.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for marking messages in a thread + # as read. For now this just marks any mentions in the thread + # as read and marks the entire thread as read. + # As we add finer-grained user tracking state to threads it + # will work in a similar way to Chat::UpdateUserLastRead. + # + # @example + # Chat::UpdateUserThreadLastRead.call(channel_id: 2, thread_id: 3, guardian: guardian) + # + class UpdateUserThreadLastRead + include ::Service::Base + + # @!method call(channel_id:, thread_id:, guardian:) + # @param [Integer] channel_id + # @param [Integer] thread_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :thread + policy :invalid_access + step :mark_associated_mentions_as_read + step :mark_thread_read + step :publish_new_last_read_to_clients + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + + validates :thread_id, :channel_id, presence: true + end + + private + + def fetch_thread(contract:, **) + ::Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def invalid_access(guardian:, thread:, **) + guardian.can_join_chat_channel?(thread.channel) + end + + # NOTE: In future we will pass in a specific last_read_message_id + # to the service, so this will need to change because currently it's + # just using the thread's last_message_id. + def mark_thread_read(thread:, guardian:, **) + thread.mark_read_for_user!(guardian.user) + end + + def mark_associated_mentions_as_read(thread:, guardian:, **) + ::Chat::Action::MarkMentionsRead.call( + guardian.user, + channel_ids: [thread.channel_id], + thread_id: thread.id, + ) + end + + def publish_new_last_read_to_clients(guardian:, thread:, **) + ::Chat::Publisher.publish_user_tracking_state!( + guardian.user, + thread.channel, + thread.last_message, + ) + end + end +end diff --git a/plugins/chat/app/services/chat_message_destroyer.rb b/plugins/chat/app/services/chat_message_destroyer.rb deleted file mode 100644 index f5f159b8169..00000000000 --- a/plugins/chat/app/services/chat_message_destroyer.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageDestroyer - def destroy_in_batches(chat_messages_query, batch_size: 200) - chat_messages_query - .in_batches(of: batch_size) - .each do |relation| - destroyed_ids = relation.destroy_all.pluck(:id) - reset_last_read(destroyed_ids) - delete_flags(destroyed_ids) - end - end - - private - - def reset_last_read(message_ids) - UserChatChannelMembership.where(last_read_message_id: message_ids).update_all( - last_read_message_id: nil, - ) - end - - def delete_flags(message_ids) - ReviewableChatMessage.where(target_id: message_ids).destroy_all - end -end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb deleted file mode 100644 index f628cf5b674..00000000000 --- a/plugins/chat/app/services/chat_publisher.rb +++ /dev/null @@ -1,265 +0,0 @@ -# frozen_string_literal: true - -module ChatPublisher - def self.new_messages_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-messages" - end - - 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( - self.new_messages_message_bus_channel(chat_channel.id), - { - channel_id: chat_channel.id, - 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.user_tracking_state_message_bus_channel(user_id) - "/chat/user-tracking-state/#{user_id}" - end - - def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) - MessageBus.publish( - self.user_tracking_state_message_bus_channel(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.new_mentions_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-mentions" - end - - def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) - MessageBus.publish( - self.new_mentions_message_bus_channel(chat_channel_id), - { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, - user_ids: [user_id], - ) - end - - NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" - - def self.publish_new_channel(chat_channel, users) - users.each do |user| - # FIXME: This could generate a lot of queries depending on the amount of users - membership = chat_channel.membership_for(user) - - # TODO: this event is problematic as some code will update the membership before calling it - # and other code will update it after calling it - # it means frontend must handle logic for both cases - serialized_channel = - ChatChannelSerializer.new( - chat_channel, - scope: Guardian.new(user), # We need a guardian here for direct messages - root: :channel, - membership: membership, - ).as_json - - MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id]) - end - end - - def self.publish_inaccessible_mentions( - user_id, - chat_message, - cannot_chat_users, - without_membership, - too_many_members, - mentions_disabled - ) - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { - type: :mention_warning, - chat_message_id: chat_message.id, - cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, - without_membership: - without_membership.map { |u| { username: u.username, id: u.id } }.as_json, - groups_with_too_many_members: too_many_members.map(&:name).as_json, - group_mentions_disabled: mentions_disabled.map(&:name).as_json, - }, - user_ids: [user_id], - ) - end - - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" - - def self.publish_chat_channel_edit(chat_channel, acting_user) - MessageBus.publish( - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - { - chat_channel_id: chat_channel.id, - name: chat_channel.title(acting_user), - description: chat_channel.description, - }, - permissions(chat_channel), - ) - end - - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" - - def self.publish_channel_status(chat_channel) - MessageBus.publish( - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, status: chat_channel.status }, - permissions(chat_channel), - ) - end - - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" - - def self.publish_chat_channel_metadata(chat_channel) - MessageBus.publish( - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, - permissions(chat_channel), - ) - end - - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" - - def self.publish_archive_status( - chat_channel, - archive_status:, - archived_messages:, - archive_topic_id:, - total_messages: - ) - MessageBus.publish( - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - { - 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/services/service.rb b/plugins/chat/app/services/service.rb new file mode 100644 index 00000000000..c45e25eb0da --- /dev/null +++ b/plugins/chat/app/services/service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Service + # Module to be included to provide steps DSL to any class. This allows to + # create easy to understand services as the whole service cycle is visible + # simply by reading the beginning of its class. + # + # Steps are executed in the order they’re defined. They will use their name + # to execute the corresponding method defined in the service class. + # + # Currently, there are 5 types of steps: + # + # * +contract(name = :default)+: used to validate the input parameters, + # typically provided by a user calling an endpoint. A special embedded + # +Contract+ class has to be defined to holds the validations. If the + # validations fail, the step will fail. Otherwise, the resulting contract + # will be available in +context[:contract]+. When calling +step(name)+ or + # +model(name = :model)+ methods after validating a contract, the contract + # should be used as an argument instead of context attributes. + # * +model(name = :model)+: used to instantiate a model (either by building + # it or fetching it from the DB). If a falsy value is returned, then the + # step will fail. Otherwise the resulting object will be assigned in + # +context[name]+ (+context[:model]+ by default). + # * +policy(name = :default)+: used to perform a check on the state of the + # system. Typically used to run guardians. If a falsy value is returned, + # the step will fail. + # * +step(name)+: used to run small snippets of arbitrary code. The step + # doesn’t care about its return value, so to mark the service as failed, + # {#fail!} has to be called explicitly. + # * +transaction+: used to wrap other steps inside a DB transaction. + # + # The methods defined on the service are automatically provided with + # the whole context passed as keyword arguments. This allows to define in a + # very explicit way what dependencies are used by the method. If for + # whatever reason a key isn’t found in the current context, then Ruby will + # raise an exception when the method is called. + # + # Regarding contract classes, they automatically have {ActiveModel} modules + # included so all the {ActiveModel} API is available. + # + # @example An example from the {TrashChannel} service + # class TrashChannel + # include Base + # + # model :channel, :fetch_channel + # policy :invalid_access + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + # step :enqueue_delete_channel_relations_job + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + # + # def invalid_access(guardian:, channel:, **) + # guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + # end + # + # def prevents_slug_collision(channel:, **) + # … + # end + # + # def soft_delete_channel(guardian:, channel:, **) + # … + # end + # + # def log_channel_deletion(guardian:, channel:, **) + # … + # end + # + # def enqueue_delete_channel_relations_job(channel:, **) + # … + # end + # end + # @example An example from the {UpdateChannelStatus} service which uses a contract + # class UpdateChannelStatus + # include Base + # + # model :channel, :fetch_channel + # contract + # policy :check_channel_permission + # step :change_status + # + # class Contract + # attribute :status + # validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + # end + # + # … + # end +end diff --git a/plugins/chat/app/services/service/base.rb b/plugins/chat/app/services/service/base.rb new file mode 100644 index 00000000000..b21d03e055d --- /dev/null +++ b/plugins/chat/app/services/service/base.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +module Service + module Base + extend ActiveSupport::Concern + + # The only exception that can be raised by a service. + class Failure < StandardError + # @return [Context] + attr_reader :context + + # @!visibility private + def initialize(context = nil) + @context = context + super + end + end + + # Simple structure to hold the context of the service during its whole lifecycle. + class Context < OpenStruct + include ActiveModel::Serialization + + # @return [Boolean] returns +true+ if the context is set as successful (default) + def success? + !failure? + end + + # @return [Boolean] returns +true+ if the context is set as failed + # @see #fail! + # @see #fail + def failure? + @failure || false + end + + # Marks the context as failed. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail!("failure": "something went wrong") + # @return [Context] + def fail!(context = {}) + fail(context) + raise Failure, self + end + + # Marks the context as failed without raising an exception. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail("failure": "something went wrong") + # @return [Context] + def fail(context = {}) + merge(context) + @failure = true + self + end + + # Merges the given context into the current one. + # @!visibility private + def merge(other_context = {}) + other_context.each { |key, value| self[key.to_sym] = value } + self + end + + def inspect_steps + Chat::StepsInspector.new(self) + end + + private + + def self.build(context = {}) + self === context ? context : new(context) + end + end + + # Internal module to define available steps as DSL + # @!visibility private + module StepsHelpers + def model(name = :model, step_name = :"fetch_#{name}", optional: false) + steps << ModelStep.new(name, step_name, optional: optional) + end + + def contract(name = :default, class_name: self::Contract, default_values_from: nil) + steps << ContractStep.new( + name, + class_name: class_name, + default_values_from: default_values_from, + ) + end + + def policy(name = :default, class_name: nil) + steps << PolicyStep.new(name, class_name: class_name) + end + + def step(name) + steps << Step.new(name) + end + + def transaction(&block) + steps << TransactionStep.new(&block) + end + end + + # @!visibility private + class Step + attr_reader :name, :method_name, :class_name + + def initialize(name, method_name = name, class_name: nil) + @name = name + @method_name = method_name + @class_name = class_name + end + + def call(instance, context) + object = class_name&.new(context) + method = object&.method(:call) || instance.method(method_name) + args = {} + args = context.to_h if method.arity.nonzero? + context[result_key] = Context.build(object: object) + instance.instance_exec(**args, &method) + end + + private + + def type + self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1") + end + + def result_key + "result.#{type}.#{name}" + end + end + + # @!visibility private + class ModelStep < Step + attr_reader :optional + + def initialize(name, method_name = name, class_name: nil, optional: nil) + super(name, method_name, class_name: class_name) + @optional = optional.present? + end + + def call(instance, context) + context[name] = super + raise ArgumentError, "Model not found" if !optional && context[name].blank? + if context[name].try(:invalid?) + context[result_key].fail(invalid: true) + context.fail! + end + rescue ArgumentError => exception + context[result_key].fail(exception: exception) + context.fail! + end + end + + # @!visibility private + class PolicyStep < Step + def call(instance, context) + if !super + context[result_key].fail(reason: context[result_key].object&.reason) + context.fail! + end + end + end + + # @!visibility private + class ContractStep < Step + attr_reader :default_values_from + + def initialize(name, method_name = name, class_name: nil, default_values_from: nil) + super(name, method_name, class_name: class_name) + @default_values_from = default_values_from + end + + def call(instance, context) + attributes = class_name.attribute_names.map(&:to_sym) + default_values = {} + default_values = context[default_values_from].slice(*attributes) if default_values_from + contract = class_name.new(default_values.merge(context.to_h.slice(*attributes))) + context[contract_name] = contract + context[result_key] = Context.build + if contract.invalid? + context[result_key].fail(errors: contract.errors) + context.fail! + end + end + + private + + def contract_name + return :contract if name.to_sym == :default + :"#{name}_contract" + end + end + + # @!visibility private + class TransactionStep < Step + include StepsHelpers + + attr_reader :steps + + def initialize(&block) + @steps = [] + instance_exec(&block) + end + + def call(instance, context) + ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } } + end + end + + included do + # The global context which is available from any step. + attr_reader :context + + # @!visibility private + # Internal class used to setup the base contract of the service. + self::Contract = + Class.new do + include ActiveModel::API + include ActiveModel::Attributes + include ActiveModel::AttributeMethods + include ActiveModel::Validations::Callbacks + end + end + + class_methods do + include StepsHelpers + + def call(context = {}) + new(context).tap(&:run).context + end + + def call!(context = {}) + new(context).tap(&:run!).context + end + + def steps + @steps ||= [] + end + end + + # @!scope class + # @!method model(name = :model, step_name = :"fetch_#{name}", optional: false) + # @param name [Symbol] name of the model + # @param step_name [Symbol] name of the method to call for this step + # @param optional [Boolean] if +true+, then the step won’t fail if its return value is falsy. + # Evaluates arbitrary code to build or fetch a model (typically from the + # DB). If the step returns a falsy value, then the step will fail. + # + # It stores the resulting model in +context[:model]+ by default (can be + # customized by providing the +name+ argument). + # + # @example + # model :channel, :fetch_channel + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + + # @!scope class + # @!method policy(name = :default, class_name: nil) + # @param name [Symbol] name for this policy + # @param class_name [Class] a policy object (should inherit from +PolicyBase+) + # Performs checks related to the state of the system. If the + # step doesn’t return a truthy value, then the policy will fail. + # + # When using a policy object, there is no need to define a method on the + # service for the policy step. The policy object `#call` method will be + # called and if the result isn’t truthy, a `#reason` method is expected to + # be implemented to explain the failure. + # + # Policy objects are usually useful for more complex logic. + # + # @example Without a policy object + # policy :no_direct_message_channel + # + # private + # + # def no_direct_message_channel(channel:, **) + # !channel.direct_message_channel? + # end + # + # @example With a policy object + # # in the service object + # policy :no_direct_message_channel, class_name: NoDirectMessageChannelPolicy + # + # # in the policy object File + # class NoDirectMessageChannelPolicy < PolicyBase + # def call + # !context.channel.direct_message_channel? + # end + # + # def reason + # "Direct message channels aren’t supported" + # end + # end + + # @!scope class + # @!method contract(name = :default, class_name: self::Contract, default_values_from: nil) + # @param name [Symbol] name for this contract + # @param class_name [Class] a class defining the contract + # @param default_values_from [Symbol] name of the model to get default values from + # Checks the validity of the input parameters. + # Implements ActiveModel::Validations and ActiveModel::Attributes. + # + # It stores the resulting contract in +context[:contract]+ by default + # (can be customized by providing the +name+ argument). + # + # @example + # contract + # + # class Contract + # attribute :name + # validates :name, presence: true + # end + + # @!scope class + # @!method step(name) + # @param name [Symbol] the name of this step + # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs + # to be made explicitly. + # + # @example + # step :update_channel + # + # private + # + # def update_channel(channel:, params_to_edit:, **) + # channel.update!(params_to_edit) + # end + # @example using {#fail!} in a step + # step :save_channel + # + # private + # + # def save_channel(channel:, **) + # fail!("something went wrong") if !channel.save + # end + + # @!scope class + # @!method transaction(&block) + # @param block [Proc] a block containing steps to be run inside a transaction + # Runs steps inside a DB transaction. + # + # @example + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + + # @!visibility private + def initialize(initial_context = {}) + @initial_context = initial_context.with_indifferent_access + @context = Context.build(initial_context.merge(__steps__: self.class.steps)) + end + + # @!visibility private + def run + run! + rescue Failure => exception + raise if context.object_id != exception.context.object_id + end + + # @!visibility private + def run! + self.class.steps.each { |step| step.call(self, context) } + end + + # @!visibility private + def fail!(message) + step_name = caller_locations(1, 1)[0].label + context["result.step.#{step_name}"].fail(error: message) + context.fail! + end + end +end diff --git a/plugins/chat/app/validators/chat/allow_uploads_validator.rb b/plugins/chat/app/validators/chat/allow_uploads_validator.rb new file mode 100644 index 00000000000..df859b53f62 --- /dev/null +++ b/plugins/chat/app/validators/chat/allow_uploads_validator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Chat + class AllowUploadsValidator + 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 +end diff --git a/plugins/chat/app/validators/chat/default_channel_validator.rb b/plugins/chat/app/validators/chat/default_channel_validator.rb new file mode 100644 index 00000000000..c8f23893851 --- /dev/null +++ b/plugins/chat/app/validators/chat/default_channel_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DefaultChannelValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + !!(value == "" || Chat::Channel.find_by(id: value.to_i)&.public_channel?) + end + + def error_message + I18n.t("site_settings.errors.chat_default_channel") + end + end +end diff --git a/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb new file mode 100644 index 00000000000..f56fadf5dde --- /dev/null +++ b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + 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 +end diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js index 582253f65b5..c762056236e 100644 --- a/plugins/chat/assets/javascripts/discourse/chat-route-map.js +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -1,18 +1,28 @@ export default function () { this.route("chat", { path: "/chat" }, function () { + // TODO(roman): Remove after the 3.1 release + this.route("channel-legacy", { + path: "/channel/:channelId/:channelTitle", + }); + + this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () { + this.route("near-message", { path: "/:messageId" }); + this.route("threads", { path: "/t" }); + this.route("thread", { path: "/t/:threadId" }, function () { + this.route("near-message", { path: "/:messageId" }); + }); + }); + this.route( - "channel", - { path: "/channel/:channelId/:channelTitle" }, + "channel.info", + { path: "/c/:channelTitle/:channelId/info" }, function () { - this.route("info", { path: "/info" }, function () { - this.route("about", { path: "/about" }); - this.route("members", { path: "/members" }); - this.route("settings", { path: "/settings" }); - }); + 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" }); diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs b/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs index 7df6051daa6..70660effd69 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs @@ -1,23 +1,22 @@ -{{#if - (and this.showMobileDirectMessageButton this.canCreateDirectMessageChannel) -}} - - {{d-icon "plus"}} - + /> {{/if}}
    {{#if this.displayPublicChannels}}
    @@ -60,13 +59,14 @@ /> {{/each}} {{/if}} +
    {{/if}} {{#if this.showDirectMessageChannels}} @@ -94,13 +94,12 @@ (not this.showMobileDirectMessageButton) ) }} - - {{d-icon "plus"}} - + /> {{/if}}
    {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js index 9cac0ff735e..b7011cfbae8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.js +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -1,24 +1,51 @@ import { bind } from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import { action, computed } from "@ember/object"; +import Component from "@glimmer/component"; +import { action } from "@ember/object"; import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; -import { and, empty } from "@ember/object/computed"; +import { tracked } from "@glimmer/tracking"; +import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; export default class ChannelsList extends Component { @service chat; @service router; @service chatStateManager; @service chatChannelsManager; - tagName = ""; - inSidebar = false; - toggleSection = null; - @empty("chatChannelsManager.publicMessageChannels") - publicMessageChannelsEmpty; - @and("site.mobileView", "showDirectMessageChannels") - showMobileDirectMessageButton; + @service site; + @service siteSettings; + @service session; + @service currentUser; + @service modal; + + @tracked hasScrollbar = false; + + @action + computeHasScrollbar(element) { + this.hasScrollbar = element.scrollHeight > element.clientHeight; + } + + @action + computeResizedEntries(entries) { + this.computeHasScrollbar(entries[0].target); + } + + @action + openNewMessageModal() { + this.modal.show(ChatModalNewMessage); + } + + get showMobileDirectMessageButton() { + return this.site.mobileView && this.canCreateDirectMessageChannel; + } + + get inSidebar() { + return this.args.inSidebar ?? false; + } + + get publicMessageChannelsEmpty() { + return this.chatChannelsManager.publicMessageChannels?.length === 0; + } - @computed("canCreateDirectMessageChannel") get createDirectMessageChannelLabel() { if (!this.canCreateDirectMessageChannel) { return "chat.direct_messages.cannot_create"; @@ -27,10 +54,6 @@ export default class ChannelsList extends Component { return "chat.direct_messages.new"; } - @computed( - "canCreateDirectMessageChannel", - "chatChannelsManager.directMessageChannels" - ) get showDirectMessageChannels() { return ( this.canCreateDirectMessageChannel || @@ -42,18 +65,17 @@ export default class ChannelsList extends Component { return this.chat.userCanDirectMessage; } - @computed("inSidebar") get publicChannelClasses() { return `channels-list-container public-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" }`; } - @computed( - "publicMessageChannelsEmpty", - "currentUser.{staff,has_joinable_public_channels}" - ) get displayPublicChannels() { + if (!this.siteSettings.enable_public_channels) { + return false; + } + if (this.publicMessageChannelsEmpty) { return ( this.currentUser?.staff || @@ -64,7 +86,6 @@ export default class ChannelsList extends Component { return true; } - @computed("inSidebar") get directMessageChannelClasses() { return `channels-list-container direct-message-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" @@ -73,7 +94,7 @@ export default class ChannelsList extends Component { @action toggleChannelSection(section) { - this.toggleSection(section); + this.args.toggleSection(section); } didRender() { @@ -84,12 +105,20 @@ export default class ChannelsList extends Component { @action storeScrollPosition() { + if (this.chatStateManager.isDrawerActive) { + return; + } + const scrollTop = document.querySelector(".channels-list")?.scrollTop || 0; this.session.channelsListPosition = scrollTop; } @bind _applyScrollPosition() { + if (this.chatStateManager.isDrawerActive) { + return; + } + const position = this.chatStateManager.isFullPageActive ? this.session.channelsListPosition || 0 : 0; diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs index 229f6b600dc..6776a23a109 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs @@ -1,12 +1,3 @@ -{{#if this.chatProgressBarContainer}} - {{#in-element this.chatProgressBarContainer}} - - {{/in-element}} -{{/if}} -
    {{#if this.site.mobileView}} {{i18n "chat.empty_state.title"}}

    {{i18n "chat.empty_state.direct_message"}}

    - - - {{i18n "chat.empty_state.direct_message_cta"}} - +
    {{else if this.channelsCollection.length}} -
    -
    -
    - {{#each this.channelsCollection as |channel|}} - - {{/each}} + +
    +
    +
    + {{#each this.channelsCollection as |channel|}} + + {{/each}} +
    - - {{#unless this.channelsCollection.loading}} - - {{/unless}}
    -
    + + + {{/if}}
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js index 39ea226ef68..bf45efe8be7 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js @@ -4,12 +4,15 @@ import { action, computed } from "@ember/object"; import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; import discourseDebounce from "discourse-common/lib/debounce"; -import showModal from "discourse/lib/show-modal"; +import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; +import ChatModalCreateChannel from "discourse/plugins/chat/discourse/components/chat/modal/create-channel"; const TABS = ["all", "open", "closed", "archived"]; export default class ChatBrowseView extends Component { @service chatApi; + @service modal; + tagName = ""; didReceiveAttrs() { @@ -34,15 +37,16 @@ export default class ChatBrowseView extends Component { } } - get chatProgressBarContainer() { - return document.querySelector("#chat-progress-bar-container"); + @action + showChatNewMessageModal() { + this.modal.show(ChatModalNewMessage); } @action onScroll() { discourseDebounce( this, - this.channelsCollection.loadMore, + this.channelsCollection.load, { filter: this.filter, status: this.status }, INPUT_DELAY ); @@ -50,6 +54,8 @@ export default class ChatBrowseView extends Component { @action debouncedFiltering(event) { + this.set("channelsCollection", this.chatApi.channels()); + discourseDebounce( this, this.channelsCollection.load, @@ -60,7 +66,7 @@ export default class ChatBrowseView extends Component { @action createChannel() { - showModal("create-channel"); + this.modal.show(ChatModalCreateChannel); } @action diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs index 05ebec3b06e..29b3fa73c0a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs @@ -22,19 +22,19 @@ {{#if (chat-guardian "can-edit-chat-channel")}}
    {{/if}}
    - {{replace-emoji this.channel.escapedTitle}} + {{replace-emoji this.channel.title}} +
    +
    + {{this.channel.slug}}
    @@ -90,4 +90,4 @@ leaveIcon="sign-out-alt" }} /> -
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.hbs deleted file mode 100644 index d4da56380ae..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.hbs +++ /dev/null @@ -1,30 +0,0 @@ - -
    -

    {{this.instructionsText}}

    - - -
    -
    - - \ No newline at end of file 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 deleted file mode 100644 index 5519e771f2e..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js +++ /dev/null @@ -1,108 +0,0 @@ -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 { 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"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; - -export default Component.extend(ModalFunctionality, { - chat: service(), - chatApi: service(), - tagName: "", - chatChannel: null, - - selection: NEW_TOPIC_SELECTION, - 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 this.chatApi - .createChannelArchive(this.chatChannel.id, this._data()) - .then(() => { - this.flash(I18n.t("chat.channel_archive.process_started"), "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, - }; - 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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.hbs index e09696ee388..745fb8907b6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.hbs @@ -1,18 +1,25 @@ -{{#if (and this.channel.archive_failed this.currentUser.admin)}} -
    -
    - {{this.channelArchiveFailedMessage}} -
    +{{#if this.shouldRender}} + {{#if @channel.archive.failed}} +
    +
    + {{this.channelArchiveFailedMessage}} +
    -
    - +
    + +
    -
    -{{else if (and this.channel.archive_completed this.currentUser.admin)}} -
    - {{this.channelArchiveCompletedMessage}} -
    + {{else if @channel.archive.completed}} +
    + {{this.channelArchiveCompletedMessage}} +
    + {{/if}} {{/if}} \ No newline at end of file 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 index 0f23a725e3d..8998272a79a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js @@ -1,61 +1,53 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; import I18n from "I18n"; import { popupAjaxError } from "discourse/lib/ajax-error"; import getURL from "discourse-common/lib/get-url"; import { action } from "@ember/object"; -import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; +import { isPresent } from "@ember/utils"; -export default Component.extend({ - channel: null, - tagName: "", - chatApi: service(), +export default class ChatChannelArchiveStatus extends Component { + @service chatApi; + @service currentUser; - @discourseComputed( - "channel.status", - "channel.archived_messages", - "channel.total_messages", - "channel.archive_failed" - ) - channelArchiveFailedMessage() { - const translationKey = !this.channel.archive_topic_id + get shouldRender() { + return this.currentUser.admin && isPresent(this.args.channel.archive); + } + + get channelArchiveFailedMessage() { + const archive = this.args.channel.archive; + const translationKey = !archive.topicId ? "chat.channel_status.archive_failed_no_topic" : "chat.channel_status.archive_failed"; return htmlSafe( I18n.t(translationKey, { - completed: this.channel.archived_messages, - total: this.channel.total_messages, - topic_url: this._getTopicUrl(), + completed: archive.messages, + total: archive.totalMessages, + topic_url: this.topicUrl, }) ); - }, + } - @discourseComputed( - "channel.status", - "channel.archived_messages", - "channel.total_messages", - "channel.archive_completed" - ) - channelArchiveCompletedMessage() { + get channelArchiveCompletedMessage() { return htmlSafe( I18n.t("chat.channel_status.archive_completed", { - topic_url: this._getTopicUrl(), + topic_url: this.topicUrl, }) ); - }, + } @action retryArchive() { return this.chatApi - .createChannelArchive(this.channel.id) + .createChannelArchive(this.args.channel.id) .catch(popupAjaxError); - }, + } - _getTopicUrl() { - if (!this.channel.archive_topic_id) { + get topicUrl() { + if (!this.args.channel.archive.topicId) { return ""; } - return getURL(`/t/-/${this.channel.archive_topic_id}`); - }, -}); + return getURL(`/t/-/${this.args.channel.archive.topicId}`); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.hbs index d36800259ee..9b4d077b090 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.hbs @@ -11,11 +11,11 @@
    - {{replace-emoji @channel.escapedTitle}} + {{replace-emoji @channel.title}} {{#if @channel.chatable.read_restricted}} {{d-icon "lock" class="chat-channel-card__read-restricted"}} @@ -26,7 +26,7 @@ {{#if @channel.currentUserMembership.muted}} @@ -36,7 +36,7 @@ @@ -47,7 +47,7 @@ {{#if @channel.description}}
    - {{replace-emoji @channel.escapedDescription}} + {{replace-emoji @channel.description}}
    {{/if}} @@ -79,7 +79,7 @@ {{#if (gt @channel.membershipsCount 0)}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.hbs deleted file mode 100644 index faf2829c557..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.hbs +++ /dev/null @@ -1,30 +0,0 @@ - -
    -

    - {{this.instructionsText}} -

    -
    - - -
    - - \ No newline at end of file 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 deleted file mode 100644 index 4943ae1e34b..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js +++ /dev/null @@ -1,67 +0,0 @@ -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 { 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"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; - -export default Component.extend(ModalFunctionality, { - chat: service(), - chatApi: 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 this.chatApi - .destroyChannel(this.chatChannel.id, { - name_confirmation: this.channelNameConfirmation, - }) - .then(() => { - this.set("confirmed", true); - this.flash(I18n.t("chat.channel_delete.process_started"), "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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.hbs index 90e409770e9..19fa80d7a42 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.hbs @@ -1,8 +1,8 @@ -{{#unless this.site.mobileView}} +{{#if this.shouldRender}} -{{/unless}} \ No newline at end of file +{{/if}} \ No newline at end of file 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 index 3347b5e2369..b585b366355 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js @@ -1,25 +1,19 @@ -import discourseComputed from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import { equal } from "@ember/object/computed"; +import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; -import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { isPresent } from "@ember/utils"; +export default class ChatChannelLeaveBtn extends Component { + @service chat; + @service site; -export default Component.extend({ - tagName: "", - channel: null, - chat: service(), + get shouldRender() { + return !this.site.mobileView && isPresent(this.args.channel); + } - isDirectMessageRow: equal( - "channel.chatable_type", - CHATABLE_TYPES.directMessageChannel - ), - - @discourseComputed("isDirectMessageRow") - leaveChatTitleKey(isDirectMessageRow) { - if (isDirectMessageRow) { + get leaveChatTitleKey() { + if (this.args.channel.isDirectMessageChannel) { return "chat.direct_messages.leave"; } else { return "chat.leave"; } - }, -}); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.hbs index 9ac1e3779cf..54f52fe97b1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.hbs @@ -1,51 +1,40 @@ -{{#if this.chatProgressBarContainer}} - {{#in-element this.chatProgressBarContainer}} - - {{/in-element}} -{{/if}} - {{#if (gt this.channel.membershipsCount 0)}} -
    -
    - - {{d-icon "search"}} -
    + +
    +
    + + {{d-icon "search"}} +
    -
    - -
    - {{#each this.members as |membership|}} - - - - - {{else}} - {{#unless this.isFetchingMembers}} - {{i18n "chat.channel.no_memberships_found"}} - {{/unless}} - {{/each}} +
    +
    + {{#each this.members as |membership|}} +
    + +
    + {{else}} + {{#if this.members.fetchedOnce}} +
    + {{i18n "chat.channel.no_memberships_found"}} +
    + {{/if}} + {{/each}} +
    -
    + + {{else}}
    {{i18n "chat.channel.no_memberships"}} 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 index dea4cad847d..0912bd77b07 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js @@ -19,7 +19,7 @@ export default class ChatChannelMembersView extends Component { didInsertElement() { this._super(...arguments); - if (!this.channel || this.channel.isDraft) { + if (!this.channel) { return; } @@ -36,13 +36,10 @@ export default class ChatChannelMembersView extends Component { 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("members", this.chatApi.listChannelMemberships(this.channel.id)); discourseDebounce( this, @@ -53,8 +50,8 @@ export default class ChatChannelMembersView extends Component { } @action - loadMore() { - discourseDebounce(this, this.members.loadMore, INPUT_DELAY); + load() { + discourseDebounce(this, this.members.load, INPUT_DELAY); } _focusSearch() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs new file mode 100644 index 00000000000..50899f81535 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js new file mode 100644 index 00000000000..f8c6d4a25d8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js @@ -0,0 +1,51 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { headerOffset } from "discourse/lib/offset-calculator"; +import { createPopper } from "@popperjs/core"; + +export default class ChatChannelMessageEmojiPicker extends Component { + @service site; + @service chatEmojiPickerManager; + + context = "chat-channel-message"; + + @action + didSelectEmoji(emoji) { + this.chatEmojiPickerManager.picker?.didSelectEmoji(emoji); + this.chatEmojiPickerManager.close(); + } + + @action + didInsert(element) { + if (this.site.mobileView) { + element.classList.remove("hidden"); + return; + } + + this._popper = createPopper( + this.chatEmojiPickerManager.picker?.trigger, + element, + { + placement: "top", + modifiers: [ + { + name: "eventListeners", + options: { scroll: false, resize: false }, + }, + { + name: "flip", + options: { padding: { top: headerOffset() } }, + }, + ], + } + ); + + element.classList.remove("hidden"); + } + + @action + willDestroy() { + this._popper?.destroy(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs index 05c16b4e5d2..eaaa128bd70 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs @@ -1,9 +1,11 @@ \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js index 404cd7ebd4e..1ddaa3c5688 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js @@ -1,18 +1,18 @@ import Component from "@glimmer/component"; -export default class ChatChannelMetadata extends Component { - unreadIndicator = false; - get lastMessageFormatedDate() { - return moment(this.args.channel.get("last_message_sent_at")).calendar( - null, - { - sameDay: "LT", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "dddd", - sameElse: "l", - } - ); +export default class ChatChannelMetadata extends Component { + get unreadIndicator() { + return this.args.unreadIndicator ?? false; + } + + get lastMessageFormattedDate() { + return moment(this.args.channel.lastMessage.createdAt).calendar(null, { + sameDay: "LT", + nextDay: "[Tomorrow]", + nextWeek: "dddd", + lastDay: "[Yesterday]", + lastWeek: "dddd", + sameElse: "l", + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs index 9002d3f774c..8832bd3befd 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs @@ -2,17 +2,18 @@ class={{concat-class "chat-channel-preview-card" (unless this.hasDescription "-no-description") + (unless this.showJoinButton "-no-button") }} > - + {{#if this.hasDescription}}

    - {{this.channel.description}} + {{@channel.description}}

    {{/if}} {{#if this.showJoinButton}} {{/if}} 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 index 954313febe9..151828045b2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js @@ -1,19 +1,15 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { isEmpty } from "@ember/utils"; -import { 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; + get showJoinButton() { + return this.args.channel?.isOpen && this.args.channel?.canJoin; + } - @readOnly("channel.isOpen") showJoinButton; - - @computed("channel.description") get hasDescription() { - return !isEmpty(this.channel.description); + return !isEmpty(this.args.channel?.description); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.hbs index a6562b1b5b7..3d1e442ae8d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.hbs @@ -1,6 +1,6 @@ 0; + return this.args.channel.tracking.unreadCount > 0; } get #firstDirectMessageUser() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs deleted file mode 100644 index a344bb8056a..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
    - {{#if this.model.user}} - {{avatar this.model imageSize="tiny"}} - - {{this.model.username}} - - {{else}} - - {{/if}} -
    \ No newline at end of file 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 deleted file mode 100644 index 07d4e9b6c53..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js +++ /dev/null @@ -1,22 +0,0 @@ -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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs deleted file mode 100644 index 0eb18b0b4b2..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs +++ /dev/null @@ -1,32 +0,0 @@ - -
    -
    - - {{d-icon "search"}} - - - -
    - -
    - - {{#each this.channels as |channel|}} - - {{else}} -
    - {{i18n "chat.channel_selector.no_channels"}} -
    - {{/each}} -
    -
    -
    -
    \ No newline at end of file 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 deleted file mode 100644 index 81ff741afe5..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js +++ /dev/null @@ -1,231 +0,0 @@ -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, - chatChannelsManager: service(), - - didInsertElement() { - this._super(...arguments); - - this.appEvents.on("chat-channel-selector-modal:close", this.close); - document.addEventListener("keyup", this.onKeyUp); - document - .getElementById("chat-channel-selector-modal-inner") - ?.addEventListener("mouseover", this.mouseover); - document.getElementById("chat-channel-selector-input")?.focus(); - - this.getInitialChannels(); - }, - - 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) => { - const newChannel = this.chatChannelsManager.store(response.channel); - return this.chatChannelsManager.follow(newChannel).then((c) => { - this.chat.openChannel(c); - this.close(); - }); - }); - } else { - return this.chatChannelsManager.follow(channel).then((c) => { - this.chat.openChannel(c); - 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) { - if (this.isDestroyed || this.isDestroying) { - return; - } - - this.setProperties({ - loading: true, - searchIndex: this.searchIndex + 1, - }); - const thisSearchIndex = this.searchIndex; - ajax("/chat/api/chatables", { 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) => { - return channel.user - ? ChatChannel.create(channel) - : this.chatChannelsManager.store(channel); - }), - loading: false, - }); - this.focusFirstChannel(this.channels); - } - }) - .catch(popupAjaxError); - }, - - @action - getInitialChannels() { - if (this.isDestroyed || this.isDestroying) { - return; - } - - const channels = this.getChannelsWithFilter(this.filter); - this.set("channels", channels); - this.focusFirstChannel(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); - }, - - getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) { - let sortedChannels = this.chatChannelsManager.channels.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(); - - return sortedChannels.filter((channel) => { - if ( - opts.excludeActiveChannel && - this.chat.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); - } - }); - }, -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.hbs index a632114f2dd..e295144af33 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.hbs @@ -1,58 +1,62 @@
    -
    +
    - {{#unless this.channel.currentUserMembership.muted}} -
    + {{#unless @channel.currentUserMembership.muted}} +
    -
    +
    @@ -60,10 +64,7 @@ {{/unless}}
    {{d-icon "info-circle"}} - {{i18n - "chat.settings.retention_info" - days=this.siteSettings.chat_channel_retention_days - }} +
    @@ -71,28 +72,29 @@

    {{i18n "chat.settings.admin_title"}}

    + {{#if this.autoJoinAvailable}} -
    +
    -

    +

    {{i18n "chat.settings.auto_join_users_info" - category=this.channel.chatable.name + category=@channel.chatable.name }}

    @@ -100,36 +102,60 @@ {{/if}} {{#if this.togglingChannelWideMentionsAvailable}} -
    +
    -

    +

    {{i18n "chat.settings.channel_wide_mentions_description" - channel=this.channel.title + channel=@channel.title }}

    {{/if}} + +
    +
    + + +
    +
    {{/if}} -{{#unless this.channel.isDirectMessageChannel}} +{{#unless @channel.isDirectMessageChannel}}
    {{#if (chat-guardian "can-edit-chat-channel")}} - {{#if (chat-guardian "can-archive-channel" this.channel)}} + {{#if (chat-guardian "can-archive-channel" @channel)}}
    {{/if}} - {{#if this.channel.isClosed}} + {{#if @channel.isClosed}}
    0 && - this.channel.isCategoryChannel + this.args.channel.isCategoryChannel ); } - @computed("autoJoinAvailable", "togglingChannelWideMentionsAvailable") get adminSectionAvailable() { return ( this.chatGuardian.canEditChatChannel() && @@ -66,69 +73,55 @@ export default class ChatChannelSettingsView extends Component { ); } - @computed( - "siteSettings.chat_allow_archiving_channels", - "channel.{isArchived,isReadOnly}" - ) get canArchiveChannel() { return ( this.siteSettings.chat_allow_archiving_channels && - !this.channel.isArchived && - !this.channel.isReadOnly + !this.args.channel.isArchived && + !this.args.channel.isReadOnly ); } @action - saveNotificationSettings(key, value) { - if (this.channel[key] === value) { + saveNotificationSettings(frontendKey, backendKey, newValue) { + if (this.args.channel.currentUserMembership[frontendKey] === newValue) { return; } const settings = {}; - settings[key] = value; + settings[backendKey] = newValue; return this.chatApi - .updateCurrentUserChatChannelNotificationsSettings( - this.channel.id, + .updateCurrentUserChannelNotificationsSettings( + this.args.channel.id, settings ) .then((result) => { - [ - "muted", - "desktop_notification_level", - "mobile_notification_level", - ].forEach((property) => { - if ( - result.membership[property] !== - this.channel.currentUserMembership[property] - ) { - this.channel.currentUserMembership[property] = - result.membership[property]; - } - }); + this.args.channel.currentUserMembership[frontendKey] = + result.membership[backendKey]; }); } @action onArchiveChannel() { - const controller = showModal("chat-channel-archive-modal"); - controller.set("chatChannel", this.channel); + return this.modal.show(ChatModalArchiveChannel, { + model: { channel: this.args.channel }, + }); } @action onDeleteChannel() { - const controller = showModal("chat-channel-delete-modal"); - controller.set("chatChannel", this.channel); + return this.modal.show(ChatModalDeleteChannel, { + model: { channel: this.args.channel }, + }); } @action onToggleChannelState() { - const controller = showModal("chat-channel-toggle"); - controller.set("chatChannel", this.channel); + this.modal.show(ChatModalToggleChannelStatus, { model: this.args.channel }); } @action onToggleAutoJoinUsers() { - if (!this.channel.auto_join_users) { + if (!this.args.channel.autoJoinUsers) { this.onEnableAutoJoinUsers(); } else { this.onDisableAutoJoinUsers(); @@ -136,44 +129,75 @@ export default class ChatChannelSettingsView extends Component { } @action - onToggleChannelWideMentions() { + onToggleThreadingEnabled(value) { return this._updateChannelProperty( - this.channel, + this.args.channel, + "threading_enabled", + value + ).then((result) => { + this.args.channel.threadingEnabled = result.channel.threading_enabled; + }); + } + + @action + onToggleChannelWideMentions() { + const newValue = !this.args.channel.allowChannelWideMentions; + if (this.args.channel.allowChannelWideMentions === newValue) { + return; + } + + return this._updateChannelProperty( + this.args.channel, "allow_channel_wide_mentions", - !this.channel.allow_channel_wide_mentions - ); + newValue + ).then((result) => { + this.args.channel.allowChannelWideMentions = + result.channel.allow_channel_wide_mentions; + }); } onDisableAutoJoinUsers() { - return this._updateChannelProperty(this.channel, "auto_join_users", false); + if (this.args.channel.autoJoinUsers === false) { + return; + } + + return this._updateChannelProperty( + this.args.channel, + "auto_join_users", + false + ).then((result) => { + this.args.channel.autoJoinUsers = result.channel.auto_join_users; + }); } onEnableAutoJoinUsers() { + if (this.args.channel.autoJoinUsers === true) { + return; + } + this.dialog.confirm({ message: I18n.t("chat.settings.auto_join_users_warning", { - category: this.channel.chatable.name, + category: this.args.channel.chatable.name, }), didConfirm: () => - this._updateChannelProperty(this.channel, "auto_join_users", true), + this._updateChannelProperty( + this.args.channel, + "auto_join_users", + true + ).then((result) => { + this.args.channel.autoJoinUsers = result.channel.auto_join_users; + }), }); } _updateChannelProperty(channel, property, value) { - if (channel[property] === value) { - return Promise.resolve(); - } - const payload = {}; payload[property] = value; - return this.chatApi - .updateChannel(channel.id, payload) - .then((result) => { - channel.set(property, result.channel[property]); - }) - .catch((event) => { - if (event.jqXHR?.responseJSON?.errors) { - this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); - } - }); + + return this.chatApi.updateChannel(channel.id, payload).catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.hbs index 5bfb42db968..fd17ea850fd 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.hbs @@ -1,8 +1,7 @@ -{{#if this.channelStatusMessage}} +{{#if this.shouldRender}}
    {{d-icon this.channelStatusIcon}} {{this.channelStatusMessage}} - - +
    -{{/if}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js index f6048b21b9d..83f2b957961 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js @@ -1,57 +1,63 @@ -import discourseComputed from "discourse-common/utils/decorators"; import I18n from "I18n"; -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { CHANNEL_STATUSES, channelStatusIcon, - channelStatusName, } from "discourse/plugins/chat/discourse/models/chat-channel"; -export default Component.extend({ - tagName: "", - channel: null, - format: null, +export default class ChatChannelStatus extends Component { + LONG_FORMAT = "long"; + SHORT_FORMAT = "short"; + VALID_FORMATS = [this.SHORT_FORMAT, this.LONG_FORMAT]; - init() { - this._super(...arguments); - if (!["short", "long"].includes(this.format)) { - this.set("format", "long"); - } - }, + get format() { + return this.VALID_FORMATS.includes(this.args.format) + ? this.args.format + : this.LONG_FORMAT; + } - @discourseComputed("channel.status") - channelStatusMessage(channelStatus) { - if (channelStatus === CHANNEL_STATUSES.open) { - return null; - } + get shouldRender() { + return ( + this.channelStatusIcon && + this.args.channel.status !== CHANNEL_STATUSES.open + ); + } - if (this.format === "long") { - return this._longStatusMessage(channelStatus); + get channelStatusMessage() { + if (this.format === this.LONG_FORMAT) { + return this.#longStatusMessage(this.args.channel.status); } else { - return this._shortStatusMessage(channelStatus); + return this.#shortStatusMessage(this.args.channel.status); } - }, + } - @discourseComputed("channel.status") - channelStatusIcon(channelStatus) { - return channelStatusIcon(channelStatus); - }, + get channelStatusIcon() { + return channelStatusIcon(this.args.channel.status); + } - _shortStatusMessage(channelStatus) { - return channelStatusName(channelStatus); - }, - - _longStatusMessage(channelStatus) { - switch (channelStatus) { + #shortStatusMessage(status) { + switch (status) { + case CHANNEL_STATUSES.archived: + return I18n.t("chat.channel_status.archived"); case CHANNEL_STATUSES.closed: - return I18n.t("chat.channel_status.closed_header"); - break; + return I18n.t("chat.channel_status.closed"); + case CHANNEL_STATUSES.open: + return I18n.t("chat.channel_status.open"); case CHANNEL_STATUSES.readOnly: - return I18n.t("chat.channel_status.read_only_header"); - break; + return I18n.t("chat.channel_status.read_only"); + } + } + + #longStatusMessage(status) { + switch (status) { case CHANNEL_STATUSES.archived: return I18n.t("chat.channel_status.archived_header"); - break; + case CHANNEL_STATUSES.closed: + return I18n.t("chat.channel_status.closed_header"); + case CHANNEL_STATUSES.open: + return I18n.t("chat.channel_status.open_header"); + case CHANNEL_STATUSES.readOnly: + return I18n.t("chat.channel_status.read_only_header"); } - }, -}); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs index e5178c47cef..0c097c46af8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs @@ -1,70 +1,67 @@ -{{#if this.channel.isDraft}} -
    - {{this.channel.title}} - {{#if (has-block)}} - {{yield}} - {{/if}} -
    -{{else}} - {{#if this.channel.isDirectMessageChannel}} -
    - +{{#if @channel.isDirectMessageChannel}} +
    + {{#if @channel.chatable.users.length}}
    {{#if this.multiDm}} - {{this.channel.chatable.users.length}} + {{@channel.chatable.users.length}} {{else}} - + {{/if}}
    + {{/if}} - - {{/if}} + + {{#if (has-block)}} + {{yield}} + {{/if}} +
    +{{else if @channel.isCategoryChannel}} +
    + + {{d-icon "d-chat"}} + {{#if @channel.chatable.read_restricted}} + {{d-icon "lock" class="chat-channel-title__restricted-category-icon"}} + {{/if}} + + + {{replace-emoji @channel.title}} + + + {{#if (has-block)}} + {{yield}} + {{/if}} +
    {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js index 6bb9dba438a..3fd41b2b9ac 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js @@ -1,33 +1,24 @@ -import Component from "@ember/component"; +import Component from "@glimmer/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; + get users() { + return this.args.channel.chatable.users; + } - @reads("channel.chatable.users.[]") users; - @gt("users.length", 1) multiDm; + get multiDm() { + return this.users.length > 1; + } - @computed("users") get usernames() { return this.users.mapBy("username").join(", "); } - @computed("channel.chatable.color") get channelColorStyle() { - return htmlSafe(`color: #${this.channel.chatable.color}`); + return htmlSafe(`color: #${this.args.channel.chatable.color}`); } - @computed( - "channel.chatable.users.length", - "channel.chatable.users.@each.status" - ) get showUserStatus() { - return !!( - this.channel.chatable.users.length === 1 && - this.channel.chatable.users[0].status - ); + return !!(this.users.length === 1 && this.users[0].status); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.hbs deleted file mode 100644 index 237017a1864..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.hbs +++ /dev/null @@ -1,14 +0,0 @@ - -
    -

    {{this.instructions}}

    -
    -
    - - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.hbs index e459ff02215..b9a7506b474 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.hbs @@ -1,16 +1,12 @@ -{{#if (gt @channel.currentUserMembership.unread_count 0)}} +{{#if this.showUnreadIndicator}}
    -
    {{@channel.currentUserMembership.unread_count}}
    +
    {{#if + this.showUnreadCount + }}{{this.unreadCount}}{{else}} {{/if}}
    {{/if}} \ No newline at end of file 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 00000000000..ef8f0d9c31b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js @@ -0,0 +1,32 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelUnreadIndicator extends Component { + @service chat; + @service site; + + get showUnreadIndicator() { + return ( + this.args.channel.tracking.unreadCount > 0 || + // We want to do this so we don't show a blue dot if the user is inside + // the channel and a new unread thread comes in. + (this.chat.activeChannel?.id !== this.args.channel.id && + this.args.channel.unreadThreadsCountSinceLastViewed > 0) + ); + } + + get unreadCount() { + return this.args.channel.tracking.unreadCount; + } + + get isUrgent() { + return ( + this.args.channel.isDirectMessageChannel || + this.args.channel.tracking.mentionCount > 0 + ); + } + + get showUnreadCount() { + return this.args.channel.isDirectMessageChannel || this.isUrgent; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs new file mode 100644 index 00000000000..26a9dba1225 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs @@ -0,0 +1,88 @@ +
    + + + + + + +
    +
    + {{#each this.messagesManager.messages key="id" as |message|}} + + {{else}} + {{#unless this.messagesLoader.fetchedOnce}} + + {{/unless}} + {{/each}} +
    + + {{! at bottom even if shown at top due to column-reverse }} + {{#if this.messagesLoader.loadedPast}} +
    + {{i18n "chat.all_loaded"}} +
    + {{/if}} +
    + + + + {{#if this.pane.selectingMessages}} + + {{else}} + {{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}} + + {{else}} + + {{/if}} + {{/if}} + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js new file mode 100644 index 00000000000..8aeb71f6f87 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js @@ -0,0 +1,764 @@ +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import Component from "@glimmer/component"; +import { bind } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +// TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager +// is moved over from this file completely. +import { handleStagedMessage } from "discourse/plugins/chat/discourse/services/chat-pane-base-subscriptions-manager"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cancel, next, schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { resetIdle } from "discourse/lib/desktop-notifications"; +import { + onPresenceChange, + removeOnPresenceChange, +} from "discourse/lib/user-presence"; +import { bodyScrollFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; +import { + scrollListToBottom, + scrollListToMessage, +} from "discourse/plugins/chat/discourse/lib/scroll-helpers"; +import { + checkMessageBottomVisibility, + checkMessageTopVisibility, +} from "discourse/plugins/chat/discourse/lib/check-message-visibility"; +import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader"; +import { cached, tracked } from "@glimmer/tracking"; +import discourseDebounce from "discourse-common/lib/debounce"; +import DiscourseURL from "discourse/lib/url"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { + FUTURE, + PAST, + READ_INTERVAL_MS, +} from "discourse/plugins/chat/discourse/lib/chat-constants"; +import { stackingContextFix } from "../lib/chat-ios-hacks"; + +export default class ChatChannel extends Component { + @service appEvents; + @service capabilities; + @service chat; + @service chatApi; + @service chatChannelsManager; + @service chatChannelPaneSubscriptionsManager; + @service chatComposerPresenceManager; + @service chatDraftsManager; + @service chatEmojiPickerManager; + @service chatStateManager; + @service("chat-channel-composer") composer; + @service("chat-channel-pane") pane; + @service currentUser; + @service messageBus; + @service router; + @service site; + + @tracked sending = false; + @tracked showChatQuoteSuccess = false; + @tracked includeHeader = true; + @tracked needsArrow = false; + @tracked atBottom = false; + @tracked uploadDropZone; + @tracked isScrolling = false; + + scrollable = null; + _loadedChannelId = null; + _mentionWarningsSeen = {}; + _unreachableGroupMentions = []; + _overMembersLimitGroupMentions = []; + + @cached + get messagesLoader() { + return new ChatMessagesLoader(getOwner(this), this.args.channel); + } + + get messagesManager() { + return this.args.channel.messagesManager; + } + + get currentUserMembership() { + return this.args.channel.currentUserMembership; + } + + @action + setUploadDropZone(element) { + this.uploadDropZone = element; + } + + @action + setScrollable(element) { + this.scrollable = element; + } + + @action + setupListeners() { + onPresenceChange({ callback: this.onPresenceChangeCallback }); + } + + @action + teardownListeners() { + this.#cancelHandlers(); + removeOnPresenceChange(this.onPresenceChangeCallback); + this.unsubscribeToUpdates(this._loadedChannelId); + } + + @action + didResizePane() { + this.debounceFillPaneAttempt(); + this.computeDatesSeparators(); + } + + @action + didUpdateChannel() { + this.#cancelHandlers(); + + if (!this.args.channel) { + return; + } + + this.messagesManager.clear(); + + if ( + this.args.channel.isDirectMessageChannel && + !this.args.channel.isFollowing + ) { + this.chatChannelsManager.follow(this.args.channel); + } + + if (this._loadedChannelId !== this.args.channel.id) { + this.unsubscribeToUpdates(this._loadedChannelId); + this.pane.selectingMessages = false; + this._loadedChannelId = this.args.channel.id; + } + + const existingDraft = this.chatDraftsManager.get({ + channelId: this.args.channel.id, + }); + if (existingDraft) { + this.composer.message = existingDraft; + } else { + this.resetComposerMessage(); + } + + this.composer.focus(); + this.loadMessages(); + + // We update this value server-side when we load the Channel + // here, so this reflects reality for sidebar unread logic. + this.args.channel.updateLastViewedAt(); + } + + @action + loadMessages() { + if (!this.args.channel?.id) { + return; + } + + this.subscribeToUpdates(this.args.channel); + + if (this.args.targetMessageId) { + this.debounceHighlightOrFetchMessage(this.args.targetMessageId); + } else { + this.fetchMessages({ fetch_from_last_read: true }); + } + } + + @bind + onPresenceChangeCallback(present) { + if (present) { + this.debouncedUpdateLastReadMessage(); + } + } + + async fetchMessages(findArgs = {}) { + if (this.messagesLoader.loading) { + return; + } + + this.messagesManager.clear(); + + const result = await this.messagesLoader.load(findArgs); + this.messagesManager.messages = this.processMessages( + this.args.channel, + result + ); + + if (findArgs.target_message_id) { + this.scrollToMessageId(findArgs.target_message_id, { highlight: true }); + } else if (findArgs.fetch_from_last_read) { + const lastReadMessageId = this.currentUserMembership?.lastReadMessageId; + this.scrollToMessageId(lastReadMessageId); + } else if (findArgs.target_date) { + this.scrollToMessageId(result.meta.target_message_id, { + highlight: true, + position: "center", + }); + } else { + this._ignoreNextScroll = true; + this.scrollToBottom(); + } + + this.debounceFillPaneAttempt(); + this.debouncedUpdateLastReadMessage(); + } + + async fetchMoreMessages({ direction }, opts = {}) { + if (this.messagesLoader.loading) { + return; + } + + const result = await this.messagesLoader.loadMore({ direction }); + if (!result) { + return; + } + + const messages = this.processMessages(this.args.channel, result); + if (!messages.length) { + return; + } + + const targetMessageId = this.messagesManager.messages.lastObject.id; + stackingContextFix(this.scrollable, () => { + this.messagesManager.addMessages(messages); + }); + + if (direction === FUTURE && !opts.noScroll) { + this.scrollToMessageId(targetMessageId, { + position: "end", + forceAuto: true, + }); + } + + this.debounceFillPaneAttempt(); + } + + @action + scrollToBottom() { + this._ignoreNextScroll = true; + scrollListToBottom(this.scrollable); + } + + scrollToMessageId(messageId, options = {}) { + this._ignoreNextScroll = true; + const message = this.messagesManager.findMessage(messageId); + scrollListToMessage(this.scrollable, message, options); + } + + debounceFillPaneAttempt() { + this._debouncedFillPaneAttemptHandler = discourseDebounce( + this, + this.fillPaneAttempt, + 500 + ); + } + + @bind + fetchMessagesByDate(date) { + if (this.messagesLoader.loading) { + return; + } + + const message = this.messagesManager.findFirstMessageOfDay(new Date(date)); + if (message.firstOfResults && this.messagesLoader.canLoadMorePast) { + this.fetchMessages({ target_date: date, direction: FUTURE }); + } else { + this.highlightOrFetchMessage(message.id, { position: "center" }); + } + } + + async fillPaneAttempt() { + if (!this.messagesLoader.fetchedOnce) { + return; + } + + // safeguard + if (this.messagesManager.messages.length > 200) { + return; + } + + if (!this.messagesLoader.canLoadMorePast) { + return; + } + + schedule("afterRender", () => { + const firstMessageId = this.messagesManager.messages.firstObject?.id; + const messageContainer = this.scrollable.querySelector( + `.chat-message-container[data-id="${firstMessageId}"]` + ); + if ( + messageContainer && + checkMessageTopVisibility(this.scrollable, messageContainer) + ) { + this.fetchMoreMessages({ direction: PAST }); + } + }); + } + + @bind + processMessages(channel, result) { + const messages = []; + let foundFirstNew = false; + const hasNewest = this.messagesManager.messages.some((m) => m.newest); + + result.messages.forEach((messageData, index) => { + messageData.firstOfResults = index === 0; + + if (this.currentUser.ignored_users) { + // 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 + messageData.hidden = this.currentUser.ignored_users.includes( + messageData.user.username + ); + } + + if (this.requestedTargetMessageId === messageData.id) { + messageData.expanded = !messageData.hidden; + } else { + messageData.expanded = !(messageData.hidden || messageData.deleted_at); + } + + // newest has to be in after fetch callback as we don't want to make it + // dynamic or it will make the pane jump around, it will disappear on reload + if ( + !hasNewest && + !foundFirstNew && + messageData.id > this.currentUserMembership?.lastReadMessageId + ) { + foundFirstNew = true; + messageData.newest = true; + } + + const message = ChatMessage.create(channel, messageData); + message.manager = channel.messagesManager; + + if (message.thread) { + this.#preloadThreadTrackingState( + message.thread, + result.tracking.thread_tracking + ); + } + + messages.push(message); + }); + + return messages; + } + + debounceHighlightOrFetchMessage(messageId, options = {}) { + this._debouncedHighlightOrFetchMessageHandler = discourseDebounce( + this, + this.highlightOrFetchMessage, + messageId, + options, + 100 + ); + } + + highlightOrFetchMessage(messageId, options = {}) { + const message = this.messagesManager.findMessage(messageId); + if (message) { + this.scrollToMessageId( + message.id, + Object.assign( + { + highlight: true, + position: "start", + autoExpand: true, + behavior: this.capabilities.isIOS ? "smooth" : null, + }, + options + ) + ); + } else { + this.fetchMessages({ target_message_id: messageId }); + } + } + + debouncedUpdateLastReadMessage() { + this._debouncedUpdateLastReadMessageHandler = discourseDebounce( + this, + this.updateLastReadMessage, + READ_INTERVAL_MS + ); + } + + updateLastReadMessage() { + if (!this.args.channel.isFollowing) { + return; + } + + schedule("afterRender", () => { + let lastFullyVisibleMessageNode = null; + + this.scrollable + .querySelectorAll(".chat-message-container") + .forEach((item) => { + if (checkMessageBottomVisibility(this.scrollable, item)) { + lastFullyVisibleMessageNode = item; + } + }); + + if (!lastFullyVisibleMessageNode) { + return; + } + + let lastUnreadVisibleMessage = this.messagesManager.findMessage( + lastFullyVisibleMessageNode.dataset.id + ); + + if (!lastUnreadVisibleMessage) { + return; + } + + const lastReadId = + this.args.channel.currentUserMembership?.lastReadMessageId; + // we don't return early if === as we want to ensure different tabs will do the check + if (lastReadId > lastUnreadVisibleMessage.id) { + return; + } + + return this.chatApi.markChannelAsRead( + this.args.channel.id, + lastUnreadVisibleMessage.id + ); + }); + } + + @action + scrollToLatestMessage() { + if (this.messagesLoader.canLoadMoreFuture) { + this.fetchMessages(); + } else if (this.messagesManager.messages.length > 0) { + this.scrollToBottom(this.scrollable); + } + } + + @action + onScroll(state) { + bodyScrollFix(); + + next(() => { + if (this.#flushIgnoreNextScroll()) { + return; + } + + this.needsArrow = + (this.messagesLoader.fetchedOnce && + this.messagesLoader.canLoadMoreFuture) || + (state.distanceToBottom.pixels > 250 && !state.atBottom); + this.isScrolling = true; + this.debouncedUpdateLastReadMessage(); + + if ( + state.atTop || + (!this.capabilities.isIOS && + state.up && + state.distanceToTop.percentage < 40) + ) { + this.fetchMoreMessages({ direction: PAST }); + } else if (state.atBottom) { + this.fetchMoreMessages({ direction: FUTURE }); + } + }); + } + + @action + onScrollEnd(state) { + resetIdle(); + this.needsArrow = + (this.messagesLoader.fetchedOnce && + this.messagesLoader.canLoadMoreFuture) || + (state.distanceToBottom.pixels > 250 && !state.atBottom); + this.isScrolling = false; + this.atBottom = state.atBottom; + + if (state.atBottom) { + this.fetchMoreMessages({ direction: FUTURE }); + } + } + + @bind + onMessage(data) { + switch (data.type) { + case "sent": + this.handleSentMessage(data); + break; + } + } + + handleSentMessage(data) { + if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { + const stagedMessage = handleStagedMessage( + this.args.channel, + this.messagesManager, + data + ); + if (stagedMessage) { + return; + } + } + + const message = ChatMessage.create(this.args.channel, data.chat_message); + message.manager = this.args.channel.messagesManager; + stackingContextFix(this.scrollable, () => { + this.messagesManager.addMessages([message]); + }); + this.debouncedUpdateLastReadMessage(); + this.args.channel.lastMessage = message; + } + + @action + async onSendMessage(message) { + await message.cook(); + if (message.editing) { + await this.#sendEditMessage(message); + } else { + await this.#sendNewMessage(message); + } + } + + @action + resetComposerMessage() { + this.composer.reset(this.args.channel); + } + + async #sendEditMessage(message) { + this.pane.sending = true; + + const data = { + new_message: message.message, + upload_ids: message.uploads.map((upload) => upload.id), + }; + + this.resetComposerMessage(); + + try { + stackingContextFix(this.scrollable, async () => { + await this.chatApi.editMessage(this.args.channel.id, message.id, data); + }); + } catch (e) { + popupAjaxError(e); + } finally { + this.chatDraftsManager.remove({ channelId: this.args.channel.id }); + this.pane.sending = false; + } + } + + async #sendNewMessage(message) { + this.pane.sending = true; + + resetIdle(); + + stackingContextFix(this.scrollable, async () => { + await this.args.channel.stageMessage(message); + }); + + message.manager = this.args.channel.messagesManager; + this.resetComposerMessage(); + + if (!this.capabilities.isIOS && !this.messagesLoader.canLoadMoreFuture) { + this.scrollToLatestMessage(); + } + + try { + await this.chatApi.sendMessage(this.args.channel.id, { + message: message.message, + in_reply_to_id: message.inReplyTo?.id, + staged_id: message.id, + upload_ids: message.uploads.map((upload) => upload.id), + }); + + if (!this.capabilities.isIOS) { + this.scrollToLatestMessage(); + } + } catch (error) { + this._onSendError(message.id, error); + } finally { + this.chatDraftsManager.remove({ channelId: this.args.channel.id }); + this.pane.sending = false; + } + } + + _onSendError(id, error) { + const stagedMessage = + this.args.channel.messagesManager.findStagedMessage(id); + if (stagedMessage) { + if (error.jqXHR?.responseJSON?.errors?.length) { + // only network errors are retryable + stagedMessage.message = ""; + stagedMessage.cooked = ""; + stagedMessage.error = error.jqXHR.responseJSON.errors[0]; + } else { + this.chat.markNetworkAsUnreliable(); + stagedMessage.error = "network_error"; + } + } + + this.resetComposerMessage(); + } + + @action + resendStagedMessage(stagedMessage) { + this.pane.sending = true; + + stagedMessage.error = null; + + const data = { + cooked: stagedMessage.cooked, + message: stagedMessage.message, + upload_ids: stagedMessage.uploads.map((upload) => upload.id), + staged_id: stagedMessage.id, + }; + + this.chatApi + .sendMessage(this.args.channel.id, data) + .catch((error) => { + this._onSendError(data.staged_id, error); + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .finally(() => { + this.pane.sending = false; + }); + } + + @action + onCloseFullScreen() { + this.chatStateManager.prefersDrawer(); + + DiscourseURL.routeTo(this.chatStateManager.lastKnownAppURL).then(() => { + DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL); + }); + } + + unsubscribeToUpdates(channelId) { + if (!channelId) { + return; + } + + this.chatChannelPaneSubscriptionsManager.unsubscribe(); + this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); + } + + subscribeToUpdates(channel) { + if (!channel) { + return; + } + + this.unsubscribeToUpdates(channel.id); + this.messageBus.subscribe( + `/chat/${channel.id}`, + this.onMessage, + channel.channelMessageBusLastId + ); + this.chatChannelPaneSubscriptionsManager.subscribe(channel); + } + + @action + addAutoFocusEventListener() { + document.addEventListener("keydown", this._autoFocus); + } + + @action + removeAutoFocusEventListener() { + document.removeEventListener("keydown", this._autoFocus); + } + + @bind + _autoFocus(event) { + if (this.chatStateManager.isDrawerActive) { + return; + } + + const { key, metaKey, ctrlKey, code, target } = event; + + if ( + !key || + // Handles things like Enter, Tab, Shift + key.length > 1 || + // Don't need to focus if the user is beginning a shortcut. + metaKey || + ctrlKey || + // Space's key comes through as ' ' so it's not covered by key + code === "Space" || + // ? is used for the keyboard shortcut modal + key === "?" + ) { + return; + } + + if (!target || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) { + return; + } + + event.preventDefault(); + this.composer.focus({ addText: event.key }); + return; + } + + @bind + computeDatesSeparators() { + schedule("afterRender", () => { + const dates = [ + ...this.scrollable.querySelectorAll(".chat-message-separator-date"), + ].reverse(); + const height = this.scrollable.querySelector( + ".chat-messages-container" + ).clientHeight; + + dates + .map((date, index) => { + const item = { bottom: 0, date }; + const line = date.nextElementSibling; + + if (index > 0) { + const prevDate = dates[index - 1]; + const prevLine = prevDate.nextElementSibling; + item.bottom = height - prevLine.offsetTop; + } + + if (dates.length === 1) { + item.height = height; + } else { + if (index === 0) { + item.height = height - line.offsetTop; + } else { + const prevDate = dates[index - 1]; + const prevLine = prevDate.nextElementSibling; + item.height = + height - line.offsetTop - (height - prevLine.offsetTop); + } + } + + return item; + }) + // group all writes at the end + .forEach((item) => { + item.date.style.bottom = item.bottom + "px"; + item.date.style.height = item.height + "px"; + }); + }); + } + + #cancelHandlers() { + cancel(this._debouncedHighlightOrFetchMessageHandler); + cancel(this._debouncedUpdateLastReadMessageHandler); + cancel(this._debouncedFillPaneAttemptHandler); + } + + #preloadThreadTrackingState(thread, threadTracking) { + if (!threadTracking[thread.id]) { + return; + } + + thread.tracking.unreadCount = threadTracking[thread.id].unread_count; + thread.tracking.mentionCount = threadTracking[thread.id].mention_count; + } + + #flushIgnoreNextScroll() { + const prev = this._ignoreNextScroll; + this._ignoreNextScroll = false; + return prev; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs index 2112b9e8b2e..9a9aabd7c71 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs @@ -1,26 +1,36 @@ -{{#if this.buttons.length}} - - -
      - {{#each this.buttons as |button|}} -
    • +{{#if @buttons.length}} + + + {{#if this.isExpanded}} +
        + {{#each @buttons as |button|}} +
      • {{/each}}
      - + {{/if}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js index 36dad78ae34..df826cb2ece 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js @@ -1,7 +1,63 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import tippy from "tippy.js"; +import { action } from "@ember/object"; +import { hideOnEscapePlugin } from "discourse/lib/d-popover"; +import { tracked } from "@glimmer/tracking"; export default class ChatComposerDropdown extends Component { - tagName = ""; - buttons = null; - isDisabled = false; + @tracked isExpanded = false; + + trigger = null; + + @action + setupTrigger(element) { + this.trigger = element; + } + + @action + toggleExpand() { + if (this.args.hasActivePanel) { + this.args.onCloseActivePanel?.(); + } else { + this.isExpanded = !this.isExpanded; + } + } + + @action + onButtonClick(button) { + this._tippyInstance.hide(); + button.action(); + } + + @action + setupPanel(element) { + this._tippyInstance = tippy(this.trigger, { + theme: "chat-composer-dropdown", + trigger: "click", + zIndex: 1400, + arrow: iconHTML("tippy-rounded-arrow"), + interactive: true, + allowHTML: false, + appendTo: "parent", + hideOnClick: true, + plugins: [hideOnEscapePlugin], + content: element, + onShow: () => { + this.isExpanded = true; + return true; + }, + onHide: () => { + this.isExpanded = false; + return true; + }, + }); + + this._tippyInstance.show(); + } + + @action + teardownPanel() { + this._tippyInstance?.destroy(); + } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.hbs deleted file mode 100644 index 380d5eef66e..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#each this.buttons as |button|}} - -{{/each}} \ No newline at end of file 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 deleted file mode 100644 index 88361c29398..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js +++ /dev/null @@ -1,5 +0,0 @@ -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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs index fcc65c8c996..5b5a10eedc7 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs @@ -1,16 +1,20 @@ -
      +
      - {{d-icon this.icon}} - - {{this.message.user.username}} - {{replace-emoji - this.message.excerpt - }} + {{d-icon (if @message.editing "pencil-alt" "reply")}} + + {{@message.user.username}} + + {{replace-emoji (html-safe @message.excerpt)}} +
      - 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 index 44494409ab5..dc169dc4936 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js @@ -1,5 +1,3 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; -export default Component.extend({ - tagName: "", -}); +export default class ChatComposerMessageDetails extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs index bf1f84e5c6e..b5bbfb7130b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs @@ -1,44 +1,57 @@ - - - {{#if (eq this.type this.IMAGE_TYPE)}} - {{#if this.isDone}} - - {{else}} - {{d-icon "far-image"}} - {{/if}} - {{else}} - {{d-icon "file-alt"}} - {{/if}} - - - -
      - {{this.fileName}} - -
      - -
      - {{#if this.isDone}} - {{this.upload.extension}} - {{else}} - {{#if this.upload.processing}} - {{i18n "processing"}} +{{#if @upload}} +
      +
      + {{#if this.isImage}} + {{#if @isDone}} + {{else}} - {{i18n "uploading"}} + {{d-icon "far-image"}} {{/if}} - - + {{else}} + {{d-icon "file-alt"}} {{/if}}
      - - \ No newline at end of file + + + {{#unless this.isImage}} +
      + {{this.fileName}} +
      + {{/unless}} + +
      + {{#if @isDone}} + {{#unless this.isImage}} + {{@upload.extension}} + {{/unless}} + {{else}} + {{#if @upload.processing}} + {{i18n "processing"}} + {{else}} + {{i18n "uploading"}} + {{/if}} + + + {{/if}} +
      +
      + + +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js index a2a89a2119c..ca2280cf67a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js @@ -1,25 +1,16 @@ -import Component from "@ember/component"; -import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@glimmer/component"; import { isImage } from "discourse/lib/uploads"; -export default Component.extend({ - IMAGE_TYPE: "image", +export default class ChatComposerUpload extends Component { + get isImage() { + return isImage( + this.args.upload.original_filename || this.args.upload.fileName + ); + } - 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; - }, -}); + get fileName() { + return this.args.isDone + ? this.args.upload.original_filename + : this.args.upload.fileName; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs index 8bb20bf6d23..2f07ecc996f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs @@ -21,19 +21,4 @@ @allowMultiple={{true}} @fileInputId={{this.fileUploadElementId}} @fileInputClass="hidden-upload-field" -/> - -
      -
      -
      - {{d-icon "file-audio"}} - {{d-icon "file-video"}} - {{d-icon "file-image"}} -
      - -

      - {{d-icon "upload"}} - Drop a file to upload it. -

      -
      -
      \ No newline at end of file +/> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js index 570619322dc..b762ef2aa76 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js @@ -5,6 +5,7 @@ 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"; +import { cloneJSON } from "discourse-common/lib/object"; export default Component.extend(UppyUploadMixin, { classNames: ["chat-composer-uploads"], @@ -12,27 +13,39 @@ export default Component.extend(UppyUploadMixin, { chatStateManager: service(), id: "chat-composer-uploader", type: "chat-composer", + existingUploads: null, uploads: null, useMultipartUploadsIfAvailable: true, + uploadDropZone: null, init() { this._super(...arguments); this.setProperties({ - uploads: [], fileInputSelector: `#${this.fileUploadElementId}`, }); - this.appEvents.on("chat-composer:load-uploads", this, "_loadUploads"); + }, + + didReceiveAttrs() { + this._super(...arguments); + if (this.inProgressUploads?.length > 0) { + this._uppyInstance?.cancelAll(); + } + + this.set( + "uploads", + this.existingUploads ? cloneJSON(this.existingUploads) : [] + ); }, 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 @@ -41,7 +54,7 @@ export default Component.extend(UppyUploadMixin, { uploadDone(upload) { this.uploads.pushObject(upload); - this.onUploadChanged(this.uploads); + this._triggerUploadsChanged(); }, @discourseComputed("uploads.length", "inProgressUploads.length") @@ -54,38 +67,21 @@ export default Component.extend(UppyUploadMixin, { this.appEvents.trigger(`upload-mixin:${this.id}:cancel-upload`, { fileId: upload.id, }); - this.uploads.removeObject(upload); - this.onUploadChanged(this.uploads); + this.removeUpload(upload); }, @action removeUpload(upload) { this.uploads.removeObject(upload); - this.onUploadChanged(this.uploads); + this._triggerUploadsChanged(); }, _uploadDropTargetOptions() { - let targetEl; - if (this.chatStateManager.isFullPageActive) { - targetEl = document.querySelector(".full-page-chat"); - } else { - targetEl = document.querySelector(".chat-drawer.is-expanded"); - } - - if (!targetEl) { - return this._super(); - } - return { - target: targetEl, + target: this.uploadDropZone || document.body, }; }, - _loadUploads(uploads) { - this._uppyInstance?.cancelAll(); - this.set("uploads", uploads); - }, - _uppyReady() { if (this.siteSettings.composer_media_optimization_image_enabled) { this._useUploadPlugin(UppyMediaOptimization, { @@ -127,4 +123,16 @@ export default Component.extend(UppyUploadMixin, { this._addFiles([...event.clipboardData.files], { pasted: true }); } }, + + onProgressUploadsChanged() { + this._triggerUploadsChanged(this.uploads, { + inProgressUploadsCount: this.inProgressUploads?.length, + }); + }, + + _triggerUploadsChanged() { + this.onUploadChanged?.(this.uploads, { + inProgressUploadsCount: this.inProgressUploads?.length, + }); + }, }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs index 52f67e2d124..9d12ae0bc5d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs @@ -1,90 +1,129 @@ -{{#if this.replyToMsg}} - -{{/if}} +{{! template-lint-disable no-pointer-down-event-binding }} +{{! template-lint-disable no-invalid-interactive }} -{{#if this.editingMessage}} - -{{/if}} - -
      - -
      - {{#if - (and - this.chatEmojiPickerManager.opened - (eq this.chatEmojiPickerManager.context "chat-composer") - ) - }} - + {{#if this.shouldRenderMessageDetails}} + - {{else}} - {{#unless this.disableComposer}} - - {{/unless}} {{/if}} - +
      +
      +
      + - {{#if this.isNetworkUnreliable}} - - {{d-icon "exclamation-circle"}} - - {{/if}} +
      + +
      - + {{#if this.inlineButtons.length}} + {{#each this.inlineButtons as |button|}} + + {{/each}} - {{#unless this.disableComposer}} - - {{/unless}} -
      + + {{/if}} -{{#if this.canAttachUploads}} - -{{/if}} - -{{#unless this.chatChannel.isDraft}} -
      - + +
      +
      -{{/unless}} \ No newline at end of file + + {{#if this.canAttachUploads}} + + {{/if}} + + {{#if this.shouldRenderReplyingIndicator}} +
      + +
      + {{/if}} + + +
      \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index 932ca8f84f3..2a22d869978 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -1,181 +1,309 @@ -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 Component from "@glimmer/component"; import { action } from "@ember/object"; -import { cancel, next, schedule, throttle } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { cancel, next } from "@ember/runloop"; import { cloneJSON } from "discourse-common/lib/object"; +import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import showModal from "discourse/lib/show-modal"; +import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor"; +import { getOwner } from "discourse-common/lib/get-owner"; +import userSearch from "discourse/lib/user-search"; 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 I18n from "I18n"; 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 discourseDebounce from "discourse-common/lib/debounce"; +import { isPresent } from "@ember/utils"; +import { Promise } from "rsvp"; +import User from "discourse/models/user"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; import { - chatComposerButtons, - chatComposerButtonsDependentKeys, -} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; -import { mentionRegex } from "pretty-text/mentions"; + destroyTippyInstances, + initUserStatusHtml, + renderUserStatusHtml, +} from "discourse/lib/user-status-on-autocomplete"; +import ChatModalChannelSummary from "discourse/plugins/chat/discourse/components/chat/modal/channel-summary"; -const THROTTLE_MS = 150; -const MENTION_DEBOUNCE_MS = 1000; +export default class ChatComposer extends Component { + @service capabilities; + @service site; + @service siteSettings; + @service chat; + @service chatComposerPresenceManager; + @service chatComposerWarningsTracker; + @service appEvents; + @service chatEmojiReactionStore; + @service chatEmojiPickerManager; + @service currentUser; + @service chatApi; + @service chatDraftsManager; + @service modal; -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, - mentionsTimer: null, - value: "", - inProgressUploads: null, - composerEventPrefix: "chat", - composerFocusSelector: ".chat-composer-input", - canAttachUploads: reads("siteSettings.chat_allow_uploads"), - isNetworkUnreliable: reads("chat.isNetworkUnreliable"), - typingMention: false, + @tracked isFocused = false; + @tracked inProgressUploadsCount = 0; + @tracked presenceChannelName; - @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.isFullPageActive") - 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" + get shouldRenderMessageDetails() { + return ( + this.currentMessage?.editing || + (this.context === "channel" && this.currentMessage?.inReplyTo) ); + } - this.setProperties({ - inProgressUploads: [], - _uploads: [], - }); - }, + get inlineButtons() { + return chatComposerButtons(this, "inline", this.context); + } - didInsertElement() { - this._super(...arguments); + get dropdownButtons() { + return chatComposerButtons(this, "dropdown", this.context); + } - 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(); + get fileUploadElementId() { + return this.context + "-file-uploader"; + } - this.appEvents.on("chat:modify-selection", this, "_modifySelection"); + get canAttachUploads() { + return ( + this.siteSettings.chat_allow_uploads && + isPresent(this.args.uploadDropZone) + ); + } + + @action + persistDraft() {} + + @action + setupAutocomplete(textarea) { + const $textarea = $(textarea); + this.#applyUserAutocomplete($textarea); + this.#applyEmojiAutocomplete($textarea); + this.#applyCategoryHashtagAutocomplete($textarea); + } + + @action + setupTextareaInteractor(textarea) { + this.composer.textarea = new TextareaInteractor(getOwner(this), textarea); + + if (this.site.desktopView && this.args.autofocus) { + this.composer.focus({ ensureAtEnd: true, refreshHeight: true }); + } + } + + @action + didUpdateMessage() { + this.cancelPersistDraft(); + this.composer.textarea.value = this.currentMessage.message; + this.persistDraft(); + } + + @action + didUpdateInReplyTo() { + this.cancelPersistDraft(); + this.persistDraft(); + } + + @action + cancelPersistDraft() { + cancel(this._persistHandler); + } + + @action + handleInlineButonAction(buttonAction, event) { + event.stopPropagation(); + + buttonAction(); + } + + get currentMessage() { + return this.composer.message; + } + + get hasContent() { + const minLength = this.siteSettings.chat_minimum_message_length || 1; + return ( + this.currentMessage?.message?.length >= minLength || + (this.canAttachUploads && this.currentMessage?.uploads?.length > 0) + ); + } + + get sendEnabled() { + return ( + (this.hasContent || this.currentMessage?.editing) && + !this.pane.sending && + !this.inProgressUploadsCount > 0 + ); + } + + @action + setup() { + this.appEvents.on("chat:modify-selection", this, "modifySelection"); this.appEvents.on( "chat:open-insert-link-modal", this, - "_openInsertLinkModal" + "openInsertLinkModal" ); - document.addEventListener("visibilitychange", this._blurInput); - document.addEventListener("resume", this._blurInput); - document.addEventListener("freeze", this._blurInput); + } - this.set("ready", true); - }, + @action + teardown() { + this.appEvents.off("chat:modify-selection", this, "modifySelection"); + this.appEvents.off( + "chat:open-insert-link-modal", + this, + "openInsertLinkModal" + ); + this.pane.sending = false; + } - _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), + @action + insertDiscourseLocalDate() { + showModal("discourse-local-dates-create-modal").setProperties({ + insertDate: (markup) => { + this.composer.textarea.addText( + this.composer.textarea.getSelected(), + markup + ); + this.composer.focus(); }, }); - }, + } - willDestroyElement() { - this._super(...arguments); + @action + uploadClicked() { + document.querySelector(`#${this.fileUploadElementId}`).click(); + } - this.appEvents.off( - "chat-composer:reply-to-set", - this, - "_replyToMsgChanged" - ); - this.appEvents.off( - "upload-mixin:chat-composer-uploader:in-progress-uploads", - this, - "_inProgressUploadsChanged" - ); + @action + computeIsFocused(isFocused) { + next(() => { + this.isFocused = isFocused; + }); + } - cancel(this.timer); - cancel(this.mentionsTimer); + @action + onInput(event) { + this.currentMessage.draftSaved = false; + this.currentMessage.message = event.target.value; + this.composer.textarea.refreshHeight(); + this.reportReplyingPresence(); + this.persistDraft(); + this.captureMentions(); + } - 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); - }, + @action + onUploadChanged(uploads, { inProgressUploadsCount }) { + this.currentMessage.draftSaved = false; - // 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) { + this.inProgressUploadsCount = inProgressUploadsCount || 0; + + if ( + typeof uploads !== "undefined" && + inProgressUploadsCount !== "undefined" && + inProgressUploadsCount === 0 && + this.currentMessage + ) { + this.currentMessage.uploads = cloneJSON(uploads); + } + + this.composer.textarea?.focus(); + this.reportReplyingPresence(); + this.persistDraft(); + } + + @action + trapMouseDown(event) { + event?.preventDefault(); + } + + @action + async onSend(event) { + if (!this.sendEnabled) { return; } - // keyCode for 'Enter' - if (event.keyCode === 13) { + event?.preventDefault(); + + if ( + this.currentMessage.editing && + this.currentMessage.message.length === 0 + ) { + new ChatMessageInteractor( + getOwner(this), + this.currentMessage, + this.context + ).delete(); + this.reset(this.args.channel, this.args.thread); + return; + } + + await this.args.onSendMessage(this.currentMessage); + this.composer.textarea.refreshHeight(); + } + + reportReplyingPresence() { + if (!this.args.channel || !this.currentMessage) { + return; + } + + this.chatComposerPresenceManager.notifyState( + this.presenceChannelName, + !this.currentMessage.editing && this.hasContent + ); + } + + @action + modifySelection(event, options = { type: null, context: null }) { + if (options.context !== this.context) { + return; + } + + const sel = this.composer.textarea.getSelected("", { lineVal: true }); + if (options.type === "bold") { + this.composer.textarea.applySurround(sel, "**", "**", "bold_text"); + } else if (options.type === "italic") { + this.composer.textarea.applySurround(sel, "_", "_", "italic_text"); + } else if (options.type === "code") { + this.composer.textarea.applySurround(sel, "`", "`", "code_text"); + } + } + + @action + onTextareaFocusIn(textarea) { + if (!this.capabilities.isIOS) { + return; + } + + // hack to prevent the whole viewport to move on focus input + // we need access to native node + textarea = this.composer.textarea.textarea; + textarea.style.transform = "translateY(-99999px)"; + textarea.focus(); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + textarea.style.transform = ""; + }); + }); + } + + @action + onKeyDown(event) { + if ( + this.site.mobileView || + event.altKey || + event.metaKey || + this.#isAutocompleteDisplayed() + ) { + return; + } + + if (event.key === "Escape" && !event.shiftKey) { + return this.handleEscape(event); + } + + if (event.key === "Enter") { if (event.shiftKey) { // Shift+Enter: insert newline return; @@ -184,256 +312,147 @@ export default Component.extend(TextareaTextManipulation, { // 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)) { + const { pre } = this.composer.textarea.getSelected({ lineVal: true }); + if (this.composer.textarea.isInside(pre, /(^|\n)```/g)) { return; } } - this.sendClicked(); + this.onSend(); + event.preventDefault(); return false; } if ( event.key === "ArrowUp" && - this._messageIsEmpty() && - !this.editingMessage + !this.hasContent && + !this.currentMessage.editing ) { - 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; + if (event.shiftKey && this.lastMessage?.replyable) { + this.composer.replyTo(this.lastMessage); } else { - this._textarea.blur(); + const editableMessage = this.lastUserMessage(this.currentUser); + if (editableMessage?.editable) { + this.composer.edit(editableMessage); + } } } - }, + } - 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._debouncedCaptureMentions(); - 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) - ) - ) { + @action + openInsertLinkModal(event, options = { context: null }) { + if (options.context !== this.context) { 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(); - - this.typingMention = value.slice(-1) === "@"; - - if (this.typingMention && value.slice(-1) === " ") { - this.typingMention = false; - this._debouncedCaptureMentions(); - } - - // 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 - _debouncedCaptureMentions() { - this.mentionsTimer = discourseDebounce( - this, - this._captureMentions, - MENTION_DEBOUNCE_MS - ); - }, - - @bind - _captureMentions() { - if (this.siteSettings.enable_mentions) { - const mentions = this._extractMentions(); - this.onMentionUpdates(mentions); - } - }, - - _extractMentions() { - let message = this.value; - const regex = mentionRegex(this.siteSettings.unicode_usernames); - const mentions = []; - let mentionsLeft = true; - - while (mentionsLeft) { - const matches = message.match(regex); - - if (matches) { - const mention = matches[1] || matches[2]; - mentions.push(mention); - message = message.replaceAll(`${mention}`, ""); - } else { - mentionsLeft = false; - } - } - - return mentions; - }, - - @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); + const selected = this.composer.textarea.getSelected("", { lineVal: true }); + const linkText = selected?.value; + showModal("insert-hyperlink").setProperties({ + linkText, + toolbarEvent: { + addText: (text) => this.composer.textarea.addText(selected, text), }, }); - }, + } - // text-area-manipulation mixin override - addText() { - this._super(...arguments); + @action + onSelectEmoji(emoji) { + const code = `:${emoji}:`; + this.chatEmojiReactionStore.track(code); + this.composer.textarea.addText(this.composer.textarea.getSelected(), code); - 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) => { - return userSearch({ term, includeGroups: true }).then((result) => { - if (result?.users?.length > 0) { - const presentUserNames = - this.chat.presenceChannel.users?.mapBy("username"); - result.users.forEach((user) => { - if (presentUserNames.includes(user.username)) { - user.cssClasses = "mention-user-is-online"; - } - }); - } - return result; - }); - }, - afterComplete: (text) => { - this.set("value", text); - this._focusTextArea(); - this._debouncedCaptureMentions(); - }, - }); + if (this.site.desktopView) { + this.composer.focus(); + } else { + this.chatEmojiPickerManager.close(); } - }, + } - _applyCategoryHashtagAutocomplete($textarea) { + @action + captureMentions() { + if (this.hasContent) { + this.chatComposerWarningsTracker.trackMentions( + this.currentMessage.message + ); + } + } + + @action + showChannelSummaryModal() { + this.modal.show(ChatModalChannelSummary, { + model: { channelId: this.args.channel.id }, + }); + } + + #addMentionedUser(userData) { + const user = User.create(userData); + this.currentMessage.mentionedUsers.set(user.id, user); + } + + #applyUserAutocomplete($textarea) { + if (!this.siteSettings.enable_mentions) { + return; + } + + $textarea.autocomplete({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: true, + transformComplete: (obj) => { + if (obj.isUser) { + this.#addMentionedUser(obj); + } + + return obj.username || obj.name; + }, + dataSource: (term) => { + destroyTippyInstances(); + return userSearch({ term, includeGroups: true }).then((result) => { + if (result?.users?.length > 0) { + const presentUserNames = + this.chat.presenceChannel.users?.mapBy("username"); + result.users.forEach((user) => { + if (presentUserNames.includes(user.username)) { + user.cssClasses = "is-online"; + } + }); + initUserStatusHtml(result.users); + } + return result; + }); + }, + onRender: (options) => { + renderUserStatusHtml(options); + }, + afterComplete: (text, event) => { + event.preventDefault(); + this.composer.textarea.value = text; + this.composer.focus(); + this.captureMentions(); + }, + onClose: destroyTippyInstances, + }); + } + + #applyCategoryHashtagAutocomplete($textarea) { setupHashtagAutocomplete( this.site.hashtag_configurations["chat-composer"], $textarea, this.siteSettings, { treatAsTextarea: true, - afterComplete: (value) => { - this.set("value", value); - return this._focusTextArea(); + afterComplete: (text, event) => { + event.preventDefault(); + this.composer.textarea.value = text; + this.composer.focus(); }, } ); - }, + } - _applyEmojiAutocomplete($textarea) { + #applyEmojiAutocomplete($textarea) { if (!this.siteSettings.enable_emoji) { return; } @@ -441,12 +460,12 @@ export default Component.extend(TextareaTextManipulation, { $textarea.autocomplete({ template: findRawTemplate("emoji-selector-autocomplete"), key: ":", - afterComplete: (text) => { - this.set("value", text); - this._focusTextArea(); + afterComplete: (text, event) => { + event.preventDefault(); + this.composer.textarea.value = text; + this.composer.focus(); }, treatAsTextarea: true, - onKeyUp: (text, cp) => { const matches = /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( @@ -457,18 +476,19 @@ export default Component.extend(TextareaTextManipulation, { 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); + this.chatEmojiPickerManager.open({ + context: this.context, + initialFilter: v.term, + }); return ""; } }, - dataSource: (term) => { return new Promise((resolve) => { const full = `:${term}`; @@ -510,8 +530,7 @@ export default Component.extend(TextareaTextManipulation, { // note this will only work for emojis starting with : // eg: :-) - const emojiTranslation = - this.get("site.custom_emoji_translation") || {}; + const emojiTranslation = this.site.custom_emoji_translation || {}; const allTranslations = Object.assign( {}, translations, @@ -521,12 +540,13 @@ export default Component.extend(TextareaTextManipulation, { return resolve([allTranslations[full]]); } + const emojiDenied = this.site.denied_emojis || []; const match = term.match(/^:?(.*?):t([2-6])?$/); if (match) { const name = match[1]; const scale = match[2]; - if (isSkinTonableEmoji(name)) { + if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) { if (scale) { return resolve([`${name}:t${scale}`]); } else { @@ -538,6 +558,7 @@ export default Component.extend(TextareaTextManipulation, { const options = emojiSearch(term, { maxResults: 5, diversity: this.chatEmojiReactionStore.diversity, + exclude: emojiDenied, }); return resolve(options); @@ -556,244 +577,9 @@ export default Component.extend(TextareaTextManipulation, { }); }, }); - }, + } - @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.onMentionUpdates([]); - 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"; - }); - }, -}); + #isAutocompleteDisplayed() { + return document.querySelector(".autocomplete"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs deleted file mode 100644 index 3e8aa906ec1..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs +++ /dev/null @@ -1,28 +0,0 @@ -
      - {{#if this.site.mobileView}} -
      - -

      - {{d-icon "comment"}} - {{i18n "chat.draft_channel_screen.header"}} -

      -
      - {{/if}} - - - - {{#if this.previewedChannel}} - - {{/if}} -
      \ No newline at end of file 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 deleted file mode 100644 index f8f00d3e920..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js +++ /dev/null @@ -1,53 +0,0 @@ -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.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-drawer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs index 7d5b8411b59..e5529087c97 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs @@ -1,137 +1,24 @@ {{#if this.chatStateManager.isDrawerActive}}
      -
      - {{#if - (and this.draftChannelView this.chatStateManager.isDrawerExpanded) - }} -
      -
      - - {{d-icon "chevron-left"}} - -
      -
      - - -
      - {{i18n "chat.direct_message_creator.title"}} -
      -
      - {{else if this.chatView}} - {{#if this.chatStateManager.isDrawerExpanded}} - - {{d-icon "chevron-left"}} - - {{/if}} - - {{#if this.chat.activeChannel}} - {{#if this.chatStateManager.isDrawerExpanded}} - -
      - -
      -
      - {{else}} -
      -
      - - {{#if this.unreadCount}} - {{this.unreadCount}} - {{/if}} - -
      -
      - {{/if}} - {{/if}} - {{else}} - -
      - {{i18n "chat.heading"}} -
      -
      - {{/if}} - -
      -
      - {{#if this.chatStateManager.isDrawerExpanded}} - - {{/if}} - - - - {{#if this.showClose}} - - {{/if}} -
      -
      -
      - - {{#if this.chatStateManager.isDrawerExpanded}} -
      - {{#if (and this.chatView this.chat.activeChannel)}} - - {{else if this.draftChannelView}} - - {{else}} - - {{/if}} -
      - {{/if}} +
      +
      {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js index df5fb52819e..7732db38cff 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js @@ -1,33 +1,29 @@ import Component from "@ember/component"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import { bind, 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, schedule, throttle } from "@ember/runloop"; +import { cancel, throttle } from "@ember/runloop"; import { inject as service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { escapeExpression } from "discourse/lib/utilities"; +import DiscourseURL from "discourse/lib/url"; export default Component.extend({ tagName: "", - listView: equal("view", LIST_VIEW), - chatView: equal("view", CHAT_VIEW), - draftChannelView: equal("view", DRAFT_CHANNEL_VIEW), chat: service(), router: service(), + chatDrawerSize: service(), chatChannelsManager: service(), chatStateManager: service(), + chatDrawerRouter: service(), loading: false, - showClose: true, // TODO - false when on same topic sizeTimer: null, rafTimer: null, - view: null, hasUnreadMessages: false, + drawerStyle: null, didInsertElement() { this._super(...arguments); + if (!this.chat.userCanChat) { return; } @@ -35,39 +31,33 @@ export default Component.extend({ 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("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"); + window.addEventListener("resize", this._checkSize); this.appEvents.on( "composer:resize-started", this, "_startDynamicCheckSize" ); this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize"); + + this.computeDrawerStyle(); }, willDestroyElement() { this._super(...arguments); + if (!this.chat.userCanChat) { return; } + window.removeEventListener("resize", this._checkSize); + 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("composer:closed", this, "_checkSize"); this.appEvents.off("composer:opened", this, "_checkSize"); this.appEvents.off("composer:resized", this, "_checkSize"); @@ -97,30 +87,22 @@ export default Component.extend({ this.appEvents.trigger("chat:rerender-header"); }, - @discourseComputed("chatStateManager.isDrawerExpanded") - topLineClass(expanded) { - const baseClass = "chat-drawer-header__top-line"; - return expanded ? `${baseClass}--expanded` : `${baseClass}--collapsed`; + computeDrawerStyle() { + const { width, height } = this.chatDrawerSize.size; + let style = `width: ${escapeExpression((width || "0").toString())}px;`; + style += `height: ${escapeExpression((height || "0").toString())}px;`; + this.set("drawerStyle", htmlSafe(style)); }, - @discourseComputed("chatStateManager.isDrawerExpanded", "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); + get drawerActions() { + return { + openInFullPage: this.openInFullPage, + close: this.close, + toggleExpand: this.toggleExpand, + }; }, + @bind _dynamicCheckSize() { if (!this.chatStateManager.isDrawerActive) { return; @@ -141,7 +123,9 @@ export default Component.extend({ return; } - document.querySelector(".chat-drawer").classList.add("clear-transitions"); + document + .querySelector(".chat-drawer-outlet-container") + .classList.add("clear-transitions"); }, _clearDynamicCheckSize() { @@ -150,37 +134,33 @@ export default Component.extend({ } document - .querySelector(".chat-drawer") + .querySelector(".chat-drawer-outlet-container") .classList.remove("clear-transitions"); this._checkSize(); }, + @bind _checkSize() { - if (!this.chatStateManager.isDrawerActive) { - return; - } - this.sizeTimer = throttle(this, this._performCheckSize, 150); }, _performCheckSize() { - if (!this.isDestroying || this.isDestroyed) { + if (this.isDestroying || this.isDestroyed) { return; } - if (!this.chatStateManager.isDrawerActive) { - return; - } - - const drawer = document.querySelector(".chat-drawer"); - if (!drawer) { + const drawerContainer = document.querySelector( + ".chat-drawer-outlet-container" + ); + if (!drawerContainer) { return; } const composer = document.getElementById("reply-control"); const composerIsClosed = composer.classList.contains("closed"); const minRightMargin = 15; - drawer.style.setProperty( + + drawerContainer.style.setProperty( "--composer-right", (composerIsClosed ? minRightMargin @@ -188,107 +168,15 @@ export default Component.extend({ ); }, - @discourseComputed("chatStateManager.isDrawerExpanded") - expandIcon(expanded) { - if (expanded) { - return "angle-double-down"; - } else { - return "angle-double-up"; - } - }, - - @discourseComputed("chat.activeChannel.currentUserMembership.unread_count") - unreadCount(count) { - return count || 0; - }, - @action - openURL(URL = null) { - this.chat.setActiveChannel(null); - this.chatStateManager.didOpenDrawer(URL); - - const route = this._buildRouteFromURL( - URL || this.chatStateManager.lastKnownChatURL - ); - - switch (route.name) { - case "chat": - this.set("view", LIST_VIEW); - this.appEvents.trigger("chat:float-toggled", false); - return; - case "chat.draft-channel": - this.set("view", DRAFT_CHANNEL_VIEW); - this.appEvents.trigger("chat:float-toggled", false); - return; - case "chat.channel": - return this.chatChannelsManager - .find(route.params.channelId) - .then((channel) => { - this.chat.setActiveChannel(channel); - this.set("view", CHAT_VIEW); - this.appEvents.trigger("chat:float-toggled", false); - - if (route.queryParams.messageId) { - schedule("afterRender", () => { - this.appEvents.trigger( - "chat-live-pane:highlight-message", - route.queryParams.messageId - ); - }); - } - }); - } + openURL(url = null) { + this.chat.activeChannel = null; + this.chatStateManager.didOpenDrawer(url); + this.chatDrawerRouter.stateFor(this._routeFromURL(url)); }, - @action - openInFullPage() { - this.chatStateManager.storeAppURL(); - this.chatStateManager.prefersFullPage(); - this.chat.setActiveChannel(null); - - return this.router.transitionTo(this.chatStateManager.lastKnownChatURL); - }, - - @action - toggleExpand() { - this.chatStateManager.didToggleDrawer(); - this.appEvents.trigger( - "chat:toggle-expand", - this.chatStateManager.isDrawerExpanded - ); - }, - - @action - close() { - this.chatStateManager.didCloseDrawer(); - this.chat.setActiveChannel(null); - this.appEvents.trigger("chat:float-toggled", true); - }, - - @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 || "/"); + _routeFromURL(url) { + let route = this.router.recognize(url || "/"); // ember might recognize the index subroute if (route.localName === "index") { @@ -298,20 +186,34 @@ export default Component.extend({ 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"; - } + @action + openInFullPage() { + this.chatStateManager.storeAppURL(); + this.chatStateManager.prefersFullPage(); + this.chat.activeChannel = null; + + return DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL); + }, + + @action + toggleExpand() { + this.computeDrawerStyle(); + this.chatStateManager.didToggleDrawer(); + this.appEvents.trigger( + "chat:toggle-expand", + this.chatStateManager.isDrawerExpanded + ); + }, + + @action + close() { + this.computeDrawerStyle(); + this.chatStateManager.didCloseDrawer(); + this.chat.activeChannel = null; + }, + + @action + didResize(element, { width, height }) { + this.chatDrawerSize.size = { width, height }; }, }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs new file mode 100644 index 00000000000..f78588d7632 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs @@ -0,0 +1,25 @@ + + + + + + + + +{{#if this.chatStateManager.isDrawerExpanded}} +
      + {{#if this.chat.activeChannel}} + + {{/if}} +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.js new file mode 100644 index 00000000000..22a8017f9d2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.js @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatDrawerChannel extends Component { + @service appEvents; + @service chat; + @service chatStateManager; + @service chatChannelsManager; + + @action + fetchChannel() { + if (!this.args.params?.channelId) { + return; + } + + return this.chatChannelsManager + .find(this.args.params.channelId) + .then((channel) => { + this.chat.activeChannel = channel; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.hbs new file mode 100644 index 00000000000..416b6e5616c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.hbs @@ -0,0 +1,14 @@ +{{! template-lint-disable no-invalid-interactive }} +
      + {{yield}} +
      \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.js new file mode 100644 index 00000000000..f38df7fe6e0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeader extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.hbs new file mode 100644 index 00000000000..2d2c0243581 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.hbs @@ -0,0 +1,8 @@ + + {{d-icon "chevron-left"}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.js new file mode 100644 index 00000000000..d8fe6a60067 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/back-link.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeaderBackLink extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.hbs new file mode 100644 index 00000000000..876e4f87358 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.hbs @@ -0,0 +1,33 @@ +{{#if @channel}} + {{#if this.chatStateManager.isDrawerExpanded}} + +
      + +
      +
      + {{else}} +
      +
      + + {{#if @channel.tracking.unreadCount}} + + {{@channel.tracking.unreadCount}} + + {{/if}} + +
      +
      + {{/if}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.js new file mode 100644 index 00000000000..f7c3c5152d6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/channel-title.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerChannelHeaderTitle extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/close-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/close-button.hbs new file mode 100644 index 00000000000..2c8d97aa0ca --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/close-button.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.hbs new file mode 100644 index 00000000000..9c607d7982b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.hbs @@ -0,0 +1,8 @@ +{{#if this.chatStateManager.isDrawerExpanded}} + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.js new file mode 100644 index 00000000000..99ad4c2927d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/full-page-button.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeaderFullPageButton extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.hbs new file mode 100644 index 00000000000..93917adec23 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.hbs @@ -0,0 +1,10 @@ +{{#if this.chatStateManager.isDrawerExpanded}} +
      +
      + +
      +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.js new file mode 100644 index 00000000000..13f45c7507f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/left-actions.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeaderLeftActions extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.hbs new file mode 100644 index 00000000000..dd13664d459 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.hbs @@ -0,0 +1,17 @@ +
      +
      + {{#if this.showThreadsListButton}} + + {{/if}} + + + + + + +
      +
      \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js new file mode 100644 index 00000000000..48e034e6d94 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/right-actions.js @@ -0,0 +1,10 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeaderRightActions extends Component { + @service chat; + + get showThreadsListButton() { + return this.chat.activeChannel?.threadingEnabled; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/title.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/title.hbs new file mode 100644 index 00000000000..544448310b3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/title.hbs @@ -0,0 +1,5 @@ + +
      + {{i18n @title}} +
      +
      \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.hbs new file mode 100644 index 00000000000..28d93d22fd0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.hbs @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.js new file mode 100644 index 00000000000..39a19c11777 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/header/toggle-expand-button.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerHeaderToggleExpandButton extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.hbs new file mode 100644 index 00000000000..080e5ab4ea5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.hbs @@ -0,0 +1,15 @@ + +
      +
      + {{i18n "chat.heading"}} +
      +
      + + +
      + +{{#if this.chatStateManager.isDrawerExpanded}} +
      + +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.js new file mode 100644 index 00000000000..c6cab84947a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/index.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatDrawerIndex extends Component { + @service chatStateManager; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs new file mode 100644 index 00000000000..9b6c3930539 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs @@ -0,0 +1,33 @@ + + {{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}} +
      +
      + +
      +
      + {{/if}} + + + + +
      + +{{#if this.chatStateManager.isDrawerExpanded}} +
      + {{#if this.chat.activeChannel.activeThread}} + + {{/if}} +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js new file mode 100644 index 00000000000..8dd9f43f785 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js @@ -0,0 +1,46 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatDrawerThread extends Component { + @service appEvents; + @service chat; + @service chatStateManager; + @service chatChannelsManager; + @service chatHistory; + + get backLink() { + const link = { + models: this.chat.activeChannel.routeModels, + }; + + if (this.chatHistory.previousRoute?.name === "chat.channel.threads") { + link.title = "chat.return_to_threads_list"; + link.route = "chat.channel.threads"; + } else { + link.title = "chat.return_to_list"; + link.route = "chat.channel"; + } + + return link; + } + + @action + fetchChannelAndThread() { + if (!this.args.params?.channelId || !this.args.params?.threadId) { + return; + } + + return this.chatChannelsManager + .find(this.args.params.channelId) + .then((channel) => { + this.chat.activeChannel = channel; + + channel.threadsManager + .find(channel.id, this.args.params.threadId) + .then((thread) => { + this.chat.activeChannel.activeThread = thread; + }); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs new file mode 100644 index 00000000000..a2f592047f3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.hbs @@ -0,0 +1,28 @@ + + {{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}} +
      +
      + +
      +
      + {{/if}} + + + + +
      + +{{#if this.chatStateManager.isDrawerExpanded}} +
      + {{#if this.chat.activeChannel}} + + {{/if}} +
      +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js new file mode 100644 index 00000000000..ad13a3922a1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/threads.js @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatDrawerThreads extends Component { + @service appEvents; + @service chat; + @service chatStateManager; + @service chatChannelsManager; + + @action + fetchChannel() { + if (!this.args.params?.channelId) { + return; + } + + return this.chatChannelsManager + .find(this.args.params.channelId) + .then((channel) => { + this.chat.activeChannel = channel; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs index 4b6273b5aa3..f8589e43640 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs @@ -1,170 +1,135 @@ {{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-nested-interactive }} -{{! template-lint-disable no-down-event-binding }} -
      -
      - - - - {{#if this.chatEmojiPickerManager.sections.length}} - {{#if (not (gte this.filteredEmojis.length 0))}} -
      + class="chat-emoji-picker__fitzpatrick-scale" + role="toolbar" + {{on "keyup" this.didNavigateFitzpatrickScale}} + > + {{#if this.isExpandedFitzpatrickScale}} + {{#each this.fitzpatrickModifiers as |fitzpatrick|}} - {{#each-in this.groups as |section emojis|}} - + {{d-icon "check"}} + + {{/if}} + {{/each}} + {{/if}} + +
      - {{/if}} + {{on "keyup" this.didToggleFitzpatrickScale}} + {{on "click" this.didToggleFitzpatrickScale}} + > +
      + +
      -
      -
      - {{#if (gte this.filteredEmojis.length 0)}} - {{#each this.filteredEmojis as |emoji|}} - {{emoji.name}} - {{else}} -

      - {{i18n "chat.emoji_picker.no_results"}} -

      - {{/each}} - {{/if}} - - {{#each-in this.groups as |section emojis|}} + {{#if this.chatEmojiPickerManager.sections.length}} + {{#if (eq this.filteredEmojis null)}} +
      -

      - {{i18n - (concat "chat.emoji_picker." section) - translatedFallback=section + class="chat-emoji-picker__sections-nav__indicator" + style={{this.navIndicatorStyle}} + >

      + + {{#each-in this.groups as |section emojis|}} + -
      - {{! we always want the first emoji for tabbing}} - {{#let emojis.firstObject as |emoji|}} + tabindex="-1" + style={{this.navBtnStyle}} + @action={{fn this.didRequestSection section}} + data-section={{section}} + > + {{#if (eq section "favorites")}} + {{replace-emoji ":star:"}} + {{else}} + + {{/if}} + + {{/each-in}} +
      + {{/if}} + +
      +
      + {{#if (not-eq this.filteredEmojis null)}} +
      + {{#each this.filteredEmojis as |emoji|}} - {{/let}} - - {{#if - (includes this.chatEmojiPickerManager.visibleSections section) - }} - {{#each emojis as |emoji index|}} - {{! first emoji has already been rendered, we don't want to re render or would lose focus}} - {{#if (gt index 0)}} - {{emoji.name}} - {{/if}} - {{/each}} - {{/if}} + {{else}} +

      + {{i18n "chat.emoji_picker.no_results"}} +

      + {{/each}}
      -
      - {{/each-in}} -
      -
      - {{else}} -
      - {{/if}} -
      + {{/if}} -{{#if - (and - this.chatEmojiPickerManager.opened - this.site.mobileView - (eq this.chatEmojiPickerManager.context "chat-message") - ) -}} -
      + {{#each-in this.groups as |section emojis|}} +
      +

      + {{i18n + (concat "chat.emoji_picker." section) + translatedFallback=section + }} +

      +
      + {{! we always want the first emoji for tabbing}} + {{#let emojis.firstObject as |emoji|}} + {{emoji.name}} + {{/let}} + + {{#if + (includes this.chatEmojiPickerManager.visibleSections section) + }} + {{#each emojis as |emoji index|}} + {{! first emoji has already been rendered, we don't want to re render or would lose focus}} + {{#if (gt index 0)}} + {{emoji.name}} + {{/if}} + {{/each}} + {{/if}} +
      +
      + {{/each-in}} +
      +
      + {{else}} +
      + {{/if}} +
      + + {{#if + (and + this.site.mobileView + (eq this.chatEmojiPickerManager.picker.context "chat-channel-message") + ) + }} +
      + {{/if}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js index 8cf3ef88eed..482472b21da 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -1,4 +1,4 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; import { action } from "@ember/object"; import { inject as service } from "@ember/service"; @@ -40,22 +40,26 @@ export default class ChatEmojiPicker extends Component { @service chatEmojiPickerManager; @service emojiPickerScrollObserver; @service chatEmojiReactionStore; + @service capabilities; + @service site; + @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), - }; - }), + favorites: this.chatEmojiReactionStore.favorites + .filter((f) => !this.site.denied_emojis?.includes(f)) + .map((name) => { + return { + name, + group: "favorites", + url: emojiUrlFor(name), + }; + }), }; return { @@ -65,6 +69,10 @@ export default class ChatEmojiPicker extends Component { } get flatEmojis() { + if (!this.chatEmojiPickerManager.emojis) { + return []; + } + // eslint-disable-next-line no-unused-vars let { favorites, ...rest } = this.chatEmojiPickerManager.emojis; return Object.values(rest).flat(); @@ -159,7 +167,7 @@ export default class ChatEmojiPicker extends Component { } } - this.toggleProperty("isExpandedFitzpatrickScale"); + this.isExpandedFitzpatrickScale = !this.isExpandedFitzpatrickScale; } @action @@ -195,23 +203,20 @@ export default class ChatEmojiPicker extends Component { } @action - didInputFilter(event) { - if (!event.target.value.length) { + didInputFilter(value) { + if (!value?.length) { this.filteredEmojis = null; return; } - discourseDebounce( - this, - this.debouncedDidInputFilter, - event.target.value, - INPUT_DELAY - ); + discourseDebounce(this, this.debouncedDidInputFilter, value, INPUT_DELAY); } @action focusFilter(target) { - target.focus(); + schedule("afterRender", () => { + target?.focus(); + }); } debouncedDidInputFilter(filter = "") { @@ -236,6 +241,15 @@ export default class ChatEmojiPicker extends Component { }); } + @action + onSectionsKeyDown(event) { + if (event.key === "Enter") { + this.didSelectEmoji(event); + } else { + this.didNavigateSection(event); + } + } + @action didNavigateSection(event) { const sectionsEmojis = (section) => [...section.querySelectorAll(".emoji")]; @@ -252,7 +266,7 @@ export default class ChatEmojiPicker extends Component { }; const allEmojis = () => [ ...document.querySelectorAll( - ".chat-emoji-picker__scrollable-content .emoji" + ".chat-emoji-picker__section:not(.hidden) .emoji" ), ]; @@ -339,19 +353,10 @@ export default class ChatEmojiPicker extends Component { emoji = `${emoji}:t${diversity}`; } - this.chatEmojiPickerManager.didSelectEmoji(emoji); - this.appEvents.trigger("chat:focus-composer"); + this.args.didSelectEmoji?.(emoji); } } - @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( @@ -374,13 +379,18 @@ export default class ChatEmojiPicker extends Component { } schedule("afterRender", () => { - document - .querySelector(`.chat-emoji-picker__section[data-section="${section}"]`) - .scrollIntoView({ - behavior: "auto", - block: "start", - inline: "nearest", - }); + const firstEmoji = document.querySelector( + `.chat-emoji-picker__section[data-section="${section}"] .emoji:nth-child(1)` + ); + + const targetEmoji = + [ + ...document.querySelectorAll( + `.chat-emoji-picker__section[data-section="${section}"] .emoji` + ), + ].find((emoji) => emoji.offsetTop > firstEmoji.offsetTop) || firstEmoji; + + targetEmoji.focus(); later(() => { // iOS hack to avoid blank div when requesting section during momentum diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs new file mode 100644 index 00000000000..304b9bc5549 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs @@ -0,0 +1,48 @@ +{{#if (and this.chatStateManager.isFullPageActive this.displayed)}} +
      +
      + {{#if this.site.mobileView}} +
      + + {{d-icon "chevron-left"}} + +
      + {{/if}} + + + + + + {{#if (or @channel.threadingEnabled this.site.desktopView)}} +
      + {{#if this.site.desktopView}} + + {{/if}} + + {{#if this.showThreadsListButton}} + + {{/if}} +
      + {{/if}} +
      +
      + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js new file mode 100644 index 00000000000..055ed2d0368 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js @@ -0,0 +1,20 @@ +import { inject as service } from "@ember/service"; +import Component from "@glimmer/component"; + +export default class ChatFullPageHeader extends Component { + @service site; + @service chatStateManager; + @service router; + + get displayed() { + return this.args.displayed ?? true; + } + + get showThreadsListButton() { + return ( + this.args.channel.threadingEnabled && + this.router.currentRoute.name !== "chat.channel.threads" && + this.router.currentRoute.name !== "chat.channel.thread" + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.hbs deleted file mode 100644 index 06da5981c31..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{#if (gt this.chatChannelsManager.unreadUrgentCount 0)}} -
      -
      -
      {{this.chatChannelsManager.unreadUrgentCount}}
      -
      -
      -{{else if (gt this.chatChannelsManager.unreadCount 0)}} -
      -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.js deleted file mode 100644 index df39429e489..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon-unread-indicator.js +++ /dev/null @@ -1,6 +0,0 @@ -import { inject as service } from "@ember/service"; -import Component from "@glimmer/component"; - -export default class ChatHeaderIconUnreadIndicator extends Component { - @service chatChannelsManager; -} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-header-icon.hbs deleted file mode 100644 index c363c95fe96..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon.hbs +++ /dev/null @@ -1,21 +0,0 @@ -{{#if (and this.chatStateManager.isFullPageActive this.site.desktopView)}} - - {{d-icon "comment"}} - - {{#unless this.currentUserInDnD}} - - {{/unless}} - -{{else}} - - {{d-icon "comment"}} - - {{#unless this.currentUserInDnD}} - - {{/unless}} - -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs deleted file mode 100644 index bf9154bc6b6..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ /dev/null @@ -1,155 +0,0 @@ -{{#if (and this.chatStateManager.isFullPageActive this.includeHeader)}} -
      -
      - {{#if this.site.mobileView}} -
      - -
      - {{/if}} - - - - - - {{#if this.showCloseFullScreenBtn}} -
      - -
      - {{/if}} -
      -
      - - -{{/if}} - - - - - -
      -
      -
      - -
      -
      -
      - {{#if (or this.loading this.loadingMorePast)}} - - {{/if}} - - {{#each this.messages as |message|}} - - {{/each}} - - {{#if this.loadingMoreFuture}} - - {{/if}} -
      - - {{#if this.allPastMessagesLoaded}} -
      - {{i18n "chat.all_loaded"}} -
      - {{/if}} -
      - -{{#if this.showScrollToBottomBtn}} - -{{/if}} - -{{#if this.selectingMessages}} - -{{else}} - {{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}} - - {{else}} - - {{/if}} -{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js deleted file mode 100644 index 42fdf413451..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ /dev/null @@ -1,1570 +0,0 @@ -import isElementInViewport from "discourse/lib/is-element-in-viewport"; -import { cloneJSON } from "discourse-common/lib/object"; -import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; -import Component from "@ember/component"; -import discourseComputed, { - afterRender, - bind, - debounce, - 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"; - -const MENTION_RESULT = { - invalid: -1, - unreachable: 0, - over_members_limit: 1, -}; - -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, - - // Track mention hints to display warnings - unreachableGroupMentions: null, // Array - overMembersLimitGroupMentions: null, // Array - tooManyMentions: false, - mentionsCount: null, - // Complimentary structure to avoid repeating mention checks. - _mentionWarningsSeen: null, // Hash - - chat: service(), - chatChannelsManager: service(), - router: service(), - chatEmojiPickerManager: service(), - chatComposerPresenceManager: service(), - chatStateManager: service(), - chatApi: service(), - - getCachedChannelDetails: null, - clearCachedChannelDetails: null, - _scrollerEl: null, - - init() { - this._super(...arguments); - - this.set("messages", []); - this.set("_mentionWarningsSeen", {}); - this.set("unreachableGroupMentions", []); - this.set("overMembersLimitGroupMentions", []); - }, - - 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("wheel", 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("wheel", 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?.user_option.timezone; - - 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(); - } - }, - - @debounce(100) - fetchMessages(channel, options = {}) { - if (this._selfDeleted) { - return; - } - - 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 (options.fetchFromLastMessage) { - this.set("stickyScroll", true); - this._stickScrollToBottom(); - } - - this._focusComposer(); - }) - .catch(this._handleErrors) - .finally(() => { - if (this._selfDeleted || this.chatChannel.id !== channel.id) { - return; - } - - 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.messageLookup = {}; - const meta = messages.resultSetMeta; - this.setProperties({ - messages: this._prepareMessages(messages), - details: { - chat_channel_id: this.chatChannel.id, - chatable_type: this.chatChannel.chatable_type, - can_delete_self: meta.can_delete_self, - can_delete_others: meta.can_delete_others, - can_flag: meta.can_flag, - user_silenced: meta.user_silenced, - can_moderate: meta.can_moderate, - channel_message_bus_last_id: meta.channel_message_bus_last_id, - }, - 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.chatChannel.currentUserMembership.last_read_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; - } - - const indexOfLastReadMessage = - this.messages.findIndex((m) => m.id === lastReadId) || 0; - let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1]; - - if (newestUnreadMessage && !this.targetMessageId) { - 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("chatStateManager.isDrawerActive") - onFloatHiddenChange() { - if (this.chatStateManager.isDrawerActive) { - 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 = this.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 = Promise.resolve(channel); - - if (channel.isDirectMessageChannel || channel.isDraft) { - promise = this.chat.upsertDmChannelForUsernames( - channel.chatable.users.mapBy("username") - ); - } - - return promise.then((c) => - ajax(`/chat/${c.id}.json`, { - type: "POST", - data: { - message, - upload_ids: (uploads || []).mapBy("id"), - }, - }).then(() => { - this.onSwitchChannel(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, - }; - - this.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); - this._focusComposer(); - } - }, - - @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 - updateMentions(mentions) { - const mentionsCount = mentions?.length; - this.set("mentionsCount", mentionsCount); - - if (mentionsCount > 0) { - if (mentionsCount > this.siteSettings.max_mentions_per_chat_message) { - this.set("tooManyMentions", true); - } else { - this.set("tooManyMentions", false); - const newMentions = mentions.filter( - (mention) => !(mention in this._mentionWarningsSeen) - ); - - if (newMentions?.length > 0) { - this._recordNewWarnings(newMentions, mentions); - } else { - this._rebuildWarnings(mentions); - } - } - } else { - this.set("tooManyMentions", false); - this.set("unreachableGroupMentions", []); - this.set("overMembersLimitGroupMentions", []); - } - }, - - _recordNewWarnings(newMentions, mentions) { - ajax("/chat/api/mentions/groups.json", { - data: { mentions: newMentions }, - }) - .then((newWarnings) => { - newWarnings.unreachable.forEach((warning) => { - this._mentionWarningsSeen[warning] = MENTION_RESULT["unreachable"]; - }); - - newWarnings.over_members_limit.forEach((warning) => { - this._mentionWarningsSeen[warning] = - MENTION_RESULT["over_members_limit"]; - }); - - newWarnings.invalid.forEach((warning) => { - this._mentionWarningsSeen[warning] = MENTION_RESULT["invalid"]; - }); - - this._rebuildWarnings(mentions); - }) - .catch(this._rebuildWarnings(mentions)); - }, - - _rebuildWarnings(mentions) { - const newWarnings = mentions.reduce( - (memo, mention) => { - if ( - mention in this._mentionWarningsSeen && - !(this._mentionWarningsSeen[mention] === MENTION_RESULT["invalid"]) - ) { - if ( - this._mentionWarningsSeen[mention] === MENTION_RESULT["unreachable"] - ) { - memo[0].push(mention); - } else { - memo[1].push(mention); - } - } - - return memo; - }, - [[], []] - ); - - this.set("unreachableGroupMentions", newWarnings[0]); - this.set("overMembersLimitGroupMentions", newWarnings[1]); - }, - - @action - reStickScrollIfNeeded() { - if (this.stickyScroll) { - this._stickScrollToBottom(); - } - }, - - @action - onHoverMessage(message, options = {}, event) { - 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._selfDeleted) { - return; - } - - 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() { - this.appEvents.trigger("chat:focus-composer"); - }, - - _unsubscribeToUpdates(channelId) { - this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); - }, - - _subscribeToUpdates(channelId) { - this._unsubscribeToUpdates(channelId); - this.messageBus.subscribe( - `/chat/${channelId}`, - this.onMessage, - this.details.channel_message_bus_last_id - ); - }, - - @bind - onMessage(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, - }); - }, - - _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-mention-warnings.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs index 104a4bb92dc..c02e048596d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs @@ -8,13 +8,13 @@ {{this.warningHeaderText}}
        - {{#if @tooManyMentions}} + {{#if this.hasTooManyMentions}}
      • {{this.tooManyMentionsBody}}
      • {{else}} - {{#if @unreachableGroupMentions}} + {{#if this.hasUnreachableGroupMentions}}
      • {{this.unreachableBody}}
      • {{/if}} - {{#if @overMembersLimitGroupMentions}} + {{#if this.hasOverMembersLimitGroupMentions}}
      • {{this.overMembersLimitBody}}
      • {{/if}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js index e97bfefbda3..24592fec121 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js @@ -2,21 +2,35 @@ import Component from "@glimmer/component"; import I18n from "I18n"; import { htmlSafe } from "@ember/template"; import { inject as service } from "@ember/service"; +import getURL from "discourse-common/lib/get-url"; export default class ChatMentionWarnings extends Component { @service siteSettings; @service currentUser; + @service chatComposerWarningsTracker; - get unreachableGroupMentionsCount() { - return this.args?.unreachableGroupMentions.length; + get unreachableGroupMentions() { + return this.chatComposerWarningsTracker.unreachableGroupMentions; } - get overMembersLimitMentionsCount() { - return this.args?.overMembersLimitGroupMentions.length; + get overMembersLimitGroupMentions() { + return this.chatComposerWarningsTracker.overMembersLimitGroupMentions; } get hasTooManyMentions() { - return this.args?.tooManyMentions; + return this.chatComposerWarningsTracker.tooManyMentions; + } + + get mentionsCount() { + return this.chatComposerWarningsTracker.mentionsCount; + } + + get unreachableGroupMentionsCount() { + return this.unreachableGroupMentions.length; + } + + get overMembersLimitMentionsCount() { + return this.overMembersLimitGroupMentions.length; } get hasUnreachableGroupMentions() { @@ -54,10 +68,7 @@ export default class ChatMentionWarnings extends Component { } get warningHeaderText() { - if ( - this.args?.mentionsCount <= this.warningsCount || - this.hasTooManyMentions - ) { + if (this.mentionsCount <= this.warningsCount || this.hasTooManyMentions) { return I18n.t("chat.mention_warning.groups.header.all"); } else { return I18n.t("chat.mention_warning.groups.header.some"); @@ -69,31 +80,22 @@ export default class ChatMentionWarnings extends Component { return; } - let notificationLimit = I18n.t( - "chat.mention_warning.groups.notification_limit" - ); - - if (this.currentUser.staff) { - notificationLimit = htmlSafe( - ` - ${notificationLimit} - ` + if (this.currentUser.admin) { + return htmlSafe( + I18n.t("chat.mention_warning.too_many_mentions_admin", { + count: this.siteSettings.max_mentions_per_chat_message, + siteSettingUrl: getURL( + "/admin/site_settings/category/plugins?filter=max_mentions_per_chat_message" + ), + }) + ); + } else { + return htmlSafe( + I18n.t("chat.mention_warning.too_many_mentions", { + count: this.siteSettings.max_mentions_per_chat_message, + }) ); } - - const settingLimit = I18n.t("chat.mention_warning.mentions_limit", { - count: this.siteSettings.max_mentions_per_chat_message, - }); - - return htmlSafe( - I18n.t("chat.mention_warning.too_many_mentions", { - notification_limit: notificationLimit, - limit: settingLimit, - }) - ); } get unreachableBody() { @@ -101,17 +103,21 @@ export default class ChatMentionWarnings extends Component { return; } - if (this.unreachableGroupMentionsCount <= 2) { - return I18n.t("chat.mention_warning.groups.unreachable", { - group: this.args.unreachableGroupMentions[0], - group_2: this.args.unreachableGroupMentions[1], - count: this.unreachableGroupMentionsCount, - }); - } else { - return I18n.t("chat.mention_warning.groups.unreachable_multiple", { - group: this.args.unreachableGroupMentions[0], - count: this.unreachableGroupMentionsCount - 1, //N others - }); + switch (this.unreachableGroupMentionsCount) { + case 1: + return I18n.t("chat.mention_warning.groups.unreachable_1", { + group: this.unreachableGroupMentions[0], + }); + case 2: + return I18n.t("chat.mention_warning.groups.unreachable_2", { + group1: this.unreachableGroupMentions[0], + group2: this.unreachableGroupMentions[1], + }); + default: + return I18n.t("chat.mention_warning.groups.unreachable_multiple", { + group: this.unreachableGroupMentions[0], + count: this.unreachableGroupMentionsCount - 1, + }); } } @@ -120,44 +126,18 @@ export default class ChatMentionWarnings extends Component { return; } - let notificationLimit = I18n.t( - "chat.mention_warning.groups.notification_limit" + return htmlSafe( + I18n.messageFormat("chat.mention_warning.groups.too_many_members_MF", { + groupCount: this.overMembersLimitMentionsCount, + isAdmin: this.currentUser.admin, + siteSettingUrl: getURL( + "/admin/site_settings/category/plugins?filter=max_users_notified_per_group_mention" + ), + notificationLimit: + this.siteSettings.max_users_notified_per_group_mention, + group1: this.overMembersLimitGroupMentions[0], + group2: this.overMembersLimitGroupMentions[1], + }) ); - - if (this.currentUser.staff) { - notificationLimit = htmlSafe( - ` - ${notificationLimit} - ` - ); - } - - const settingLimit = I18n.t("chat.mention_warning.groups.users_limit", { - count: this.siteSettings.max_users_notified_per_group_mention, - }); - - if (this.hasOverMembersLimitGroupMentions <= 2) { - return htmlSafe( - I18n.t("chat.mention_warning.groups.too_many_members", { - group: this.args.overMembersLimitGroupMentions[0], - group_2: this.args.overMembersLimitGroupMentions[1], - count: this.overMembersLimitMentionsCount, - notification_limit: notificationLimit, - limit: settingLimit, - }) - ); - } else { - return htmlSafe( - I18n.t("chat.mention_warning.groups.too_many_members_multiple", { - group: this.args.overMembersLimitGroupMentions[0], - count: this.overMembersLimitMentionsCount - 1, //N others - notification_limit: notificationLimit, - limit: settingLimit, - }) - ); - } } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs index 651e0419b5e..054f8df7498 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs @@ -1,49 +1,80 @@ -
        -
        - {{#if this.chatStateManager.isFullPageActive}} - {{#each this.emojiReactions as |reaction|}} - +
        + {{#if this.shouldRenderFavoriteReactions}} + {{#each + this.messageInteractor.emojiReactions key="emoji" + as |reaction| + }} + + {{/each}} + {{/if}} + + {{#if this.messageInteractor.canInteractWithMessage}} + - {{/each}} - {{/if}} + {{/if}} - {{#if this.messageCapabilities.canReact}} - - {{/if}} + {{#if this.messageInteractor.canBookmark}} + + + + {{/if}} - {{#if this.messageCapabilities.canBookmark}} - - - - {{/if}} + {{#if this.messageInteractor.canReply}} + + {{/if}} - {{#if this.messageCapabilities.canReply}} - - {{/if}} - - {{#if this.secondaryButtons.length}} - - {{/if}} + {{#if + (and + this.messageInteractor.message + this.messageInteractor.secondaryActions.length + ) + }} + + {{/if}} +
        -
        \ No newline at end of file +{{/if}} \ No newline at end of file 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 index d88a31d8148..997ceb1321d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js @@ -1,48 +1,116 @@ -import Component from "@ember/component"; -import { action } from "@ember/object"; -import { createPopper } from "@popperjs/core"; -import { schedule } from "@ember/runloop"; +import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import { getOwner } from "@ember/application"; +import { schedule } from "@ember/runloop"; +import { createPopper } from "@popperjs/core"; +import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; const MSG_ACTIONS_VERTICAL_PADDING = -10; +const FULL = "full"; +const REDUCED = "reduced"; +const REDUCED_WIDTH_THRESHOLD = 500; -export default Component.extend({ - tagName: "", +export default class ChatMessageActionsDesktop extends Component { + @service chat; + @service chatEmojiPickerManager; + @service site; - chatStateManager: service(), + @tracked size = FULL; - messageActions: null, + popper = null; - didReceiveAttrs() { - this._super(...arguments); + get message() { + return this.chat.activeMessage.model; + } + get context() { + return this.chat.activeMessage.context; + } + + get messageInteractor() { + return new ChatMessageInteractor( + getOwner(this), + this.message, + this.context + ); + } + + get shouldRenderFavoriteReactions() { + return this.size === FULL; + } + + @action + onWheel() { + // prevents menu to stop scroll on the list of messages + this.chat.activeMessage = null; + } + + @action + onMouseleave(event) { + // if the mouse is leaving the actions menu for the actual menu, don't close it + // this will avoid the menu rerendering + if ( + (event.toElement || event.relatedTarget)?.closest( + ".chat-message-container" + ) + ) { + return; + } + + this.chat.activeMessage = null; + } + + @action + setup(element) { 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: "top-end", - modifiers: [ - { name: "hide", enabled: true }, - { name: "eventListeners", options: { scroll: false } }, - { - name: "offset", - options: { offset: [-2, MSG_ACTIONS_VERTICAL_PADDING] }, - }, - ], - } + const messageContainer = chatMessageContainer( + this.message.id, + this.context ); + + if (!messageContainer) { + return; + } + + const viewport = messageContainer.closest(".popper-viewport"); + this.size = + viewport.clientWidth < REDUCED_WIDTH_THRESHOLD ? REDUCED : FULL; + + if (!messageContainer) { + return; + } + + this.popper = createPopper(messageContainer, element, { + placement: "top-end", + strategy: "fixed", + modifiers: [ + { + name: "flip", + enabled: true, + options: { + boundary: viewport, + fallbackPlacements: ["bottom-end"], + }, + }, + { name: "hide", enabled: true }, + { name: "eventListeners", options: { scroll: false } }, + { + name: "offset", + options: { offset: [-2, MSG_ACTIONS_VERTICAL_PADDING] }, + }, + ], + }); }); - }, + } @action - handleSecondaryButtons(id) { - this.messageActions?.[id]?.(); - }, -}); + teardown() { + this.popper?.destroy(); + this.popper = null; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs index 02adea78cdc..08c59926d84 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs @@ -1,87 +1,94 @@ -
        +{{#if (and this.site.mobileView this.chat.activeMessage)}}
        -
        - -
        -
        -
        - - - {{this.message.message}} - -
        +
        -
          - {{#each this.secondaryButtons as |button|}} -
        • - +
          +
          + + -
        • - {{/each}} -
        - - {{#if - (or this.messageCapabilities.canReact this.messageCapabilities.canReply) - }} -
        - {{#if this.messageCapabilities.canReact}} - {{#each this.emojiReactions as |reaction|}} - - {{/each}} - - - {{/if}} - - {{#if this.messageCapabilities.canBookmark}} - - - - {{/if}} - - {{#if this.messageCapabilities.canReply}} - - {{/if}} + {{this.message.message}} + +
        - {{/if}} + +
          + {{#each this.messageInteractor.secondaryActions as |button|}} +
        • + +
        • + {{/each}} +
        + + {{#if + (or this.messageInteractor.canReact this.messageInteractor.canReply) + }} +
        + {{#if this.messageInteractor.canReact}} + {{#each this.messageInteractor.emojiReactions as |reaction|}} + + {{/each}} + + + {{/if}} + + {{#if this.messageInteractor.canBookmark}} + + + + {{/if}} + + {{#if this.messageInteractor.canReply}} + + {{/if}} +
        + {{/if}} +
        -
    \ No newline at end of file +{{/if}} \ No newline at end of file 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 index 1457f56e55e..43c2373c121 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js @@ -1,43 +1,71 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { tracked } from "@glimmer/tracking"; import discourseLater from "discourse-common/lib/later"; import { action } from "@ember/object"; import { isTesting } from "discourse-common/config/environment"; +import { inject as service } from "@ember/service"; -export default Component.extend({ - tagName: "", - hasExpandedReply: false, - messageActions: null, +export default class ChatMessageActionsMobile extends Component { + @service chat; + @service site; + @service capabilities; - didInsertElement() { - this._super(...arguments); + @tracked hasExpandedReply = false; + @tracked showFadeIn = false; - discourseLater(this._addFadeIn); + get message() { + return this.chat.activeMessage.model; + } + + get context() { + return this.chat.activeMessage.context; + } + + get messageInteractor() { + return new ChatMessageInteractor( + getOwner(this), + this.message, + this.context + ); + } + + @action + fadeAndVibrate() { + discourseLater(this.#addFadeIn.bind(this)); if (this.capabilities.canVibrate && !isTesting()) { navigator.vibrate(5); } - }, + } @action expandReply(event) { event.stopPropagation(); - this.set("hasExpandedReply", true); - }, + this.hasExpandedReply = true; + } @action collapseMenu(event) { - event.stopPropagation(); - this.onCloseMenu(); - }, + event.preventDefault(); + this.#onCloseMenu(); + } @action - actAndCloseMenu(fn) { - fn?.(); - this.onCloseMenu(); - }, + actAndCloseMenu(fnId) { + this.messageInteractor[fnId](); + this.#onCloseMenu(); + } - onCloseMenu() { - this._removeFadeIn(); + @action + openEmojiPicker(_, event) { + this.messageInteractor.openEmojiPicker(_, event); + 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 @@ -48,19 +76,15 @@ export default Component.extend({ // by ensuring we are not hovering any message anymore // we also ensure the menu is fully removed - this.onHoverMessage?.(null); + this.chat.activeMessage = null; }, 200); - }, + } - _addFadeIn() { - document - .querySelector(".chat-message-actions-backdrop") - ?.classList.add("fade-in"); - }, + #addFadeIn() { + this.showFadeIn = true; + } - _removeFadeIn() { - document - .querySelector(".chat-message-actions-backdrop") - ?.classList?.remove("fade-in"); - }, -}); + #removeFadeIn() { + this.showFadeIn = false; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs deleted file mode 100644 index 8b12b5d09d6..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs +++ /dev/null @@ -1,7 +0,0 @@ -
    - {{#if @message.chat_webhook_event.emoji}} - - {{else}} - - {{/if}} -
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js deleted file mode 100644 index 5b7b32a549a..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; - -export default class ChatMessageAvatar extends Component { - tagName = ""; -} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs index 97635d8b5e6..0ac4035be2f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs @@ -1,10 +1,10 @@
    {{#if this.hasUploads}} - {{html-safe this.cooked}} + {{html-safe @cooked}} - +
    - {{#each this.uploads as |upload|}} + {{#each @uploads as |upload|}} {{/each}}
    @@ -12,8 +12,14 @@ {{else}} {{#each this.cookedBodies as |cooked|}} {{#if cooked.needsCollapser}} - - {{cooked.body}} + + {{#if cooked.videoAttributes}} +
    + +
    + {{else}} + {{cooked.body}} + {{/if}}
    {{else}} {{cooked.body}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js index ab91763bd8d..3a6100e6887 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -1,28 +1,23 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; +import { inject as service } from "@ember/service"; 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; + @service siteSettings; - @computed("uploads") get hasUploads() { - return hasUploads(this.uploads); + return hasUploads(this.args.uploads); } - @computed("uploads") get uploadsHeader() { let name = ""; - if (this.uploads.length === 1) { - name = this.uploads[0].original_filename; + if (this.args.uploads.length === 1) { + name = this.args.uploads[0].original_filename; } else { - name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); + name = I18n.t("chat.uploaded_files", { count: this.args.uploads.length }); } return htmlSafe( `${escapeExpression( @@ -31,12 +26,13 @@ export default class ChatMessageCollapser extends Component { ); } - @computed("cooked") get cookedBodies() { - const elements = Array.prototype.slice.call(domFromString(this.cooked)); + const elements = Array.prototype.slice.call( + domFromString(this.args.cooked) + ); - if (hasYoutube(elements)) { - return this.youtubeCooked(elements); + if (hasLazyVideo(elements)) { + return this.lazyVideoCooked(elements); } if (hasImageOnebox(elements)) { @@ -54,20 +50,26 @@ export default class ChatMessageCollapser extends Component { return []; } - youtubeCooked(elements) { + lazyVideoCooked(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); + if (this.siteSettings.lazy_videos_enabled && lazyVideoPredicate(e)) { + const getVideoAttributes = requirejs( + "discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes" + ).default; - acc.push({ header, body, needsCollapser: true }); + const videoAttributes = getVideoAttributes(e); + + if (this.siteSettings[`lazy_${videoAttributes.providerName}_enabled`]) { + const link = escapeExpression(videoAttributes.url); + const title = videoAttributes.title; + const header = htmlSafe( + `${title}` + ); + + acc.push({ header, body: e, videoAttributes, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } } else { acc.push({ body: e, needsCollapser: false }); } @@ -132,16 +134,12 @@ export default class ChatMessageCollapser extends Component { } } -function youtubePredicate(e) { - return ( - e.classList.length && - e.classList.contains("onebox") && - e.classList.contains("lazyYT-container") - ); +function lazyVideoPredicate(e) { + return e.classList.contains("lazy-video-container"); } -function hasYoutube(elements) { - return elements.some((e) => youtubePredicate(e)); +function hasLazyVideo(elements) { + return elements.some((e) => lazyVideoPredicate(e)); } function animatedImagePredicate(e) { @@ -205,7 +203,7 @@ export function isCollapsible(cooked, uploads) { const elements = Array.prototype.slice.call(domFromString(cooked)); return ( - hasYoutube(elements) || + hasLazyVideo(elements) || hasImageOnebox(elements) || hasUploads(uploads) || hasImage(elements) || diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs new file mode 100644 index 00000000000..716525f3fd1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs @@ -0,0 +1,19 @@ +{{#if @message.inReplyTo}} + + {{d-icon "share" title="chat.in_reply_to"}} + + {{#if @message.inReplyTo.chatWebhookEvent.emoji}} + + {{else}} + + {{/if}} + + + {{replace-emoji (html-safe @message.inReplyTo.excerpt)}} + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js new file mode 100644 index 00000000000..75f2a714c41 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js @@ -0,0 +1,35 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageInReplyToIndicator extends Component { + @service router; + + get route() { + if (this.hasThread) { + return "chat.channel.thread"; + } else { + return "chat.channel.near-message"; + } + } + + get model() { + if (this.hasThread) { + return [ + ...this.args.message.channel.routeModels, + this.args.message.thread.id, + ]; + } else { + return [ + ...this.args.message.channel.routeModels, + this.args.message.inReplyTo.id, + ]; + } + } + + get hasThread() { + return ( + this.args.message?.channel?.threadingEnabled && + this.args.message?.thread?.id + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs deleted file mode 100644 index a6e06bb6e71..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs +++ /dev/null @@ -1,48 +0,0 @@ -
    - {{#if @message.chat_webhook_event}} - {{#if @message.chat_webhook_event.username}} - - {{@message.chat_webhook_event.username}} - - {{/if}} - - - {{i18n "chat.bot"}} - - {{else}} - - {{this.name}} - {{#if this.showStatus}} -
    - -
    - {{/if}} -
    - {{/if}} - - - {{format-chat-date @message @details}} - - - {{#if @message.bookmark}} - - - - {{/if}} - - {{#if this.isFlagged}} - - {{#if @message.reviewable_id}} - - {{d-icon "flag" title="chat.flagged"}} - - {{else}} - {{d-icon "flag" title="chat.you_flagged"}} - {{/if}} - - {{/if}} -
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js deleted file mode 100644 index 9bb31d6f4ce..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js +++ /dev/null @@ -1,70 +0,0 @@ -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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs deleted file mode 100644 index 35c8b3c7d39..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs +++ /dev/null @@ -1,28 +0,0 @@ - -
    -

    {{this.instructionsText}}

    -
    - - -
    - - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs index 576c1a935b2..44aa89c5dda 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs @@ -1,17 +1,18 @@ -{{#if (and this.reaction this.emojiUrl)}} +{{#if (and @reaction this.emojiUrl)}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js index a15cdc06b73..ef5b1bac146 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -1,119 +1,243 @@ -import { guidFor } from "@ember/object/internals"; -import Component from "@ember/component"; -import { action, computed } from "@ember/object"; +import Component from "@glimmer/component"; +import { action } 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"; +import { cancel } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import setupPopover from "discourse/lib/d-popover"; +import discourseLater from "discourse-common/lib/later"; +import { tracked } from "@glimmer/tracking"; export default class ChatMessageReaction extends Component { - reaction = null; - showUsersList = false; - tagName = ""; - react = null; - class = null; + @service capabilities; + @service currentUser; - didReceiveAttrs() { - this._super(...arguments); + @tracked isActive = false; - 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); + get showCount() { + return this.args.showCount ?? true; } @action - handleClick() { - this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add"); - return false; + setup(element) { + this.setupListeners(element); + this.setupTooltip(element); } - _setupPopover() { - const target = document.getElementById(this.componentId); + @action + teardown() { + cancel(this.longPressHandler); + this.teardownTooltip(); + } - if (!target) { + @action + setupListeners(element) { + this.element = element; + + if (this.capabilities.touch) { + this.element.addEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.element.addEventListener("touchmove", this.cancelTouch, { + passive: true, + }); + this.element.addEventListener("touchend", this.onTouchEnd); + this.element.addEventListener("touchCancel", this.cancelTouch); + } + + this.element.addEventListener("click", this.handleClick, { + passive: true, + }); + } + + @action + teardownListeners() { + if (this.capabilities.touch) { + this.element.removeEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.element.removeEventListener("touchmove", this.cancelTouch, { + passive: true, + }); + this.element.removeEventListener("touchend", this.onTouchEnd); + this.element.removeEventListener("touchCancel", this.cancelTouch); + } + + this.element.removeEventListener("click", this.handleClick, { + passive: true, + }); + } + + @action + onTouchStart(event) { + event.stopPropagation(); + this.isActive = true; + + this.longPressHandler = discourseLater(() => { + this.touching = false; + }, 400); + + this.touching = true; + } + + @action + cancelTouch() { + cancel(this.longPressHandler); + this._tippyInstance?.hide(); + this.touching = false; + this.isActive = false; + } + + @action + onTouchEnd(event) { + event.preventDefault(); + + if (this.touching) { + this.handleClick(event); + } + + cancel(this.longPressHandler); + this._tippyInstance?.hide(); + this.isActive = false; + } + + @action + setupTooltip(element) { + this._tippyInstance = setupPopover(element, { + trigger: "mouseenter", + interactive: false, + allowHTML: true, + offset: [0, 10], + onShow(instance) { + if (instance.props.content === "") { + return false; + } + }, + }); + } + + @action + teardownTooltip() { + this._tippyInstance?.destroy(); + } + + @action + refreshTooltip() { + this._tippyInstance?.setContent(this.popoverContent || ""); + } + + get emojiString() { + return `:${this.args.reaction.emoji}:`; + } + + get emojiUrl() { + return emojiUrlFor(this.args.reaction.emoji); + } + + @action + handleClick(event) { + event.stopPropagation(); + + this.args.onReaction?.( + this.args.reaction.emoji, + this.args.reaction.reacted ? "remove" : "add" + ); + + this._tippyInstance?.clearDelayTimeouts(); + } + + get popoverContent() { + if (!this.args.reaction.count || !this.args.reaction.users?.length) { 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; + return emojiUnescape( + this.args.reaction.reacted + ? this.#reactionTextWithSelf + : this.#reactionText + ); } - @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, - }); - } + get #reactionTextWithSelf() { + const reactionCount = this.args.reaction.count; + + if (reactionCount === 0) { + return; } + + if (reactionCount === 1) { + return I18n.t("chat.reactions.only_you", { + emoji: this.args.reaction.emoji, + }); + } + + const maxUsernames = 5; + const usernames = this.args.reaction.users + .filter((user) => user.id !== this.currentUser?.id) + .slice(0, maxUsernames) + .mapBy("username"); + + if (reactionCount === 2) { + return I18n.t("chat.reactions.you_and_single_user", { + emoji: this.args.reaction.emoji, + username: usernames.pop(), + }); + } + + const unnamedUserCount = reactionCount - usernames.length; + if (unnamedUserCount > 0) { + return I18n.t("chat.reactions.you_multiple_users_and_more", { + emoji: this.args.reaction.emoji, + commaSeparatedUsernames: this.#joinUsernames(usernames), + count: unnamedUserCount, + }); + } + + return I18n.t("chat.reactions.you_and_multiple_users", { + emoji: this.args.reaction.emoji, + username: usernames.pop(), + commaSeparatedUsernames: this.#joinUsernames(usernames), + }); + } + + get #reactionText() { + const reactionCount = this.args.reaction.count; + + if (reactionCount === 0) { + return; + } + + const maxUsernames = 5; + const usernames = this.args.reaction.users + .filter((user) => user.id !== this.currentUser?.id) + .slice(0, maxUsernames) + .mapBy("username"); + + if (reactionCount === 1) { + return I18n.t("chat.reactions.single_user", { + emoji: this.args.reaction.emoji, + username: usernames.pop(), + }); + } + + const unnamedUserCount = reactionCount - usernames.length; + + if (unnamedUserCount > 0) { + return I18n.t("chat.reactions.multiple_users_and_more", { + emoji: this.args.reaction.emoji, + commaSeparatedUsernames: this.#joinUsernames(usernames), + count: unnamedUserCount, + }); + } + + return I18n.t("chat.reactions.multiple_users", { + emoji: this.args.reaction.emoji, + username: usernames.pop(), + commaSeparatedUsernames: this.#joinUsernames(usernames), + }); + } + + #joinUsernames(usernames) { + return usernames.join(I18n.t("word_connector.comma")); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs new file mode 100644 index 00000000000..9b5361dcd2c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs @@ -0,0 +1,30 @@ +{{#if @message.formattedFirstMessageDate}} +
    +
    + + {{@message.formattedFirstMessageDate}} + + {{#if @message.newest}} + + - + {{i18n "chat.last_visit"}} + + {{/if}} + +
    +
    + +
    +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.js new file mode 100644 index 00000000000..411dfcdaecb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.js @@ -0,0 +1,11 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; + +export default class ChatMessageSeparatorDate extends Component { + @action + onDateClick() { + return this.args.fetchMessagesByDate?.( + this.args.message.firstMessageOfTheDayAt + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs new file mode 100644 index 00000000000..7aaba0ccb65 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs @@ -0,0 +1,13 @@ +{{#if (and @message.newest (not @message.formattedFirstMessageDate))}} +
    +
    + + {{i18n "chat.last_visit"}} + +
    + +
    +
    +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs deleted file mode 100644 index 7cb5a5e8898..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#if this.message.newestMessage}} -
    -
    - - {{i18n "chat.new_messages"}} - -
    -{{else if this.message.firstMessageOfTheDayAt}} -
    -
    - - {{this.message.firstMessageOfTheDayAt}} - -
    -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js deleted file mode 100644 index 44494409ab5..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; - -export default Component.extend({ - tagName: "", -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs index a11a76454b6..e6f8059e3fb 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs @@ -1,11 +1,15 @@
    {{#if this.isCollapsible}} - + {{else}} - {{html-safe this.cooked}} + {{html-safe @cooked}} {{/if}} - {{#if this.edited}} + {{#if this.isEdited}} ({{i18n "chat.edited"}}) {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js index d02c47ba1cc..042d774ba61 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js @@ -1,15 +1,12 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; export default class ChatMessageText extends Component { - tagName = ""; - cooked = null; - uploads = null; - edited = false; + get isEdited() { + return this.args.edited ?? false; + } - @computed("cooked", "uploads.[]") get isCollapsible() { - return isCollapsible(this.cooked, this.uploads); + return isCollapsible(this.args.cooked, this.args.uploads); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs new file mode 100644 index 00000000000..2e16cc3d229 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.hbs @@ -0,0 +1,34 @@ +
    + +
    + +
    + +
    + + {{@message.thread.preview.lastReplyUser.username}} + + + {{format-date @message.thread.preview.lastReplyCreatedAt leaveAgo="true"}} + +
    +
    + {{i18n "chat.thread.replies" count=@message.thread.preview.replyCount}} +
    + +
    + {{replace-emoji (html-safe @message.thread.preview.lastReplyExcerpt)}} +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js new file mode 100644 index 00000000000..d77121cebe6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-thread-indicator.js @@ -0,0 +1,86 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatMessageThreadIndicator extends Component { + @service capabilities; + @service chat; + @service chatStateManager; + @service router; + @service site; + + @tracked isActive = false; + + @action + setup(element) { + this.element = element; + + if (this.capabilities.touch) { + this.element.addEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.element.addEventListener("touchmove", this.cancelTouch, { + passive: true, + }); + this.element.addEventListener("touchend", this.onTouchEnd); + this.element.addEventListener("touchCancel", this.cancelTouch); + } + + this.element.addEventListener("click", this.openThread, { + passive: true, + }); + } + + @action + teardown() { + if (this.capabilities.touch) { + this.element.removeEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.element.removeEventListener("touchmove", this.cancelTouch, { + passive: true, + }); + this.element.removeEventListener("touchend", this.onTouchEnd); + this.element.removeEventListener("touchCancel", this.cancelTouch); + } + + this.element.removeEventListener("click", this.openThread, { + passive: true, + }); + } + + @bind + onTouchStart(event) { + this.isActive = true; + event.stopPropagation(); + + this.touching = true; + } + + @bind + onTouchEnd() { + this.isActive = false; + + if (this.touching) { + this.openThread(); + } + } + + @bind + cancelTouch() { + this.isActive = false; + this.touching = false; + } + + @bind + openThread() { + this.chat.activeMessage = null; + + this.router.transitionTo( + "chat.channel.thread", + ...this.args.message.thread.routeModels + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index c6739296837..73421462cf2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -1,230 +1,131 @@ {{! template-lint-disable no-invalid-interactive }} - - -{{#if - (and - this.showActions this.site.mobileView this.chatMessageActionsMobileAnchor - ) -}} - {{#in-element this.chatMessageActionsMobileAnchor}} - - {{/in-element}} -{{/if}} + + {{/if}} -{{#if - (and - this.showActions this.site.desktopView this.chatMessageActionsDesktopAnchor - ) -}} - {{#in-element this.chatMessageActionsDesktopAnchor}} - - {{/in-element}} -{{/if}} - -
    - {{#if this.show}} - {{#if this.selectingMessages}} - - {{/if}} - - {{#if this.deletedAndCollapsed}} -
    - + {{#if this.show}} + {{#if this.pane.selectingMessages}} + -
    - {{else if this.hiddenAndCollapsed}} -
    - -
    - {{else}} -
    - {{#if this.message.in_reply_to}} -
    - {{d-icon "share" title="chat.in_reply_to"}} + {{/if}} - {{#if this.message.in_reply_to.chat_webhook_event.emoji}} - - {{else}} - - {{/if}} - - - {{replace-emoji this.message.in_reply_to.excerpt}} - -
    - {{/if}} - - {{#if this.hideUserInfo}} - - {{else}} - - {{/if}} - -
    - {{#unless this.hideUserInfo}} - + {{#if this.deletedAndCollapsed}} +
    + +
    + {{else if this.hiddenAndCollapsed}} +
    + +
    + {{else}} +
    + {{#unless this.hideReplyToInfo}} + {{/unless}} - - {{#if this.hasReactions}} -
    - {{#if this.reactionLabel}} -
    - {{replace-emoji this.reactionLabel}} -
    - {{/if}} + {{#if this.hideUserInfo}} + + {{else}} + + {{/if}} - {{#each-in this.message.reactions as |emoji reactionAttrs|}} - - {{/each-in}} +
    + - {{#if this.canInteractWithChat}} - {{#unless this.site.mobileView}} + + {{#if @message.reactions.length}} +
    + {{#each @message.reactions as |reaction|}} + + {{/each}} + + {{#if this.shouldRenderOpenEmojiPickerButton}} - {{/unless}} - {{/if}} -
    - {{/if}} -
    - - {{#if this.message.error}} -
    - {{#if (eq this.message.error "network_error")}} - - {{d-icon "exclamation-circle"}} - - {{i18n "chat.retry_staged_message.title"}} - - - {{i18n "chat.retry_staged_message.action"}} - - - {{else}} - {{this.message.error}} + {{/if}} +
    {{/if}} -
    - {{/if}} + - {{#if this.mentionWarning}} -
    - {{#if this.mentionWarning.invitationSent}} - {{d-icon "check"}} - - {{i18n - "chat.mention_warning.invitations_sent" - count=this.mentionWarning.without_membership.length - }} - - {{else}} - + + +
    - {{#if this.mentionWarning.cannot_see}} -

    {{this.mentionedCannotSeeText}}

    - {{/if}} - - {{#if this.mentionWarning.without_membership}} -

    - {{this.mentionedWithoutMembershipText}} - - {{i18n "chat.mention_warning.invite"}} - -

    - {{/if}} - {{#if this.mentionWarning.group_mentions_disabled}} -

    {{this.groupsWithDisabledMentions}}

    - {{/if}} - - {{#if this.mentionWarning.groups_with_too_many_members}} -

    {{this.groupsWithTooManyMembers}}

    - {{/if}} - {{/if}} -
    + {{#if this.showThreadIndicator}} + {{/if}}
    -
    + {{/if}} {{/if}} - {{/if}} -
    \ No newline at end of file +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index 13e46c27f82..57f796172c9 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -1,27 +1,20 @@ -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 { action } from "@ember/object"; +import Component from "@glimmer/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 { cancel, schedule } from "@ember/runloop"; 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"; +import { getOwner } from "discourse-common/lib/get-owner"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; +import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention"; +import { tracked } from "@glimmer/tracking"; let _chatMessageDecorators = []; +let _tippyInstances = []; export function addChatMessageDecorator(decorator) { _chatMessageDecorators.push(decorator); @@ -32,791 +25,413 @@ export function resetChatMessageDecorators() { } export const MENTION_KEYWORDS = ["here", "all"]; +export const MESSAGE_CONTEXT_THREAD = "thread"; -export default Component.extend({ - ADD_REACTION: "add", - REMOVE_REACTION: "remove", - SHOW_LEFT: "showLeft", - SHOW_RIGHT: "showRight", - canInteractWithChat: false, - isHovered: false, - onHoverMessage: null, - chatEmojiReactionStore: service("chat-emoji-reaction-store"), - chatEmojiPickerManager: service("chat-emoji-picker-manager"), - chatChannelsManager: service("chat-channels-manager"), - adminTools: optionalService(), - _hasSubscribedToAppEvents: false, - tagName: "", - chat: service(), - dialog: service(), - chatMessageActionsMobileAnchor: null, - chatMessageActionsDesktopAnchor: null, - chatMessageEmojiPickerAnchor: null, - cachedFavoritesReactions: null, +export default class ChatMessage extends Component { + @service site; + @service dialog; + @service currentUser; + @service appEvents; + @service capabilities; + @service chat; + @service chatApi; + @service chatEmojiReactionStore; + @service chatEmojiPickerManager; + @service chatChannelPane; + @service chatThreadPane; + @service chatChannelsManager; + @service router; - init() { - this._super(...arguments); + @tracked isActive = false; - 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)); - } - }, + @optionalService adminTools; - didInsertElement() { - this._super(...arguments); + constructor() { + super(...arguments); + this.initMentionedUsers(); + } - this.set( - "chatMessageActionsMobileAnchor", - document.querySelector(".chat-message-actions-mobile-anchor") - ); - this.set( - "chatMessageActionsDesktopAnchor", - document.querySelector(".chat-message-actions-desktop-anchor") + get pane() { + return this.args.context === MESSAGE_CONTEXT_THREAD + ? this.chatThreadPane + : this.chatChannelPane; + } + + get messageInteractor() { + return new ChatMessageInteractor( + getOwner(this), + this.args.message, + this.args.context ); + } - this.set("cachedFavoritesReactions", this.chatEmojiReactionStore.favorites); - }, + get deletedAndCollapsed() { + return this.args.message?.deletedAt && this.collapsed; + } - willDestroyElement() { - this._super(...arguments); - if (this.message.stagedId) { - this.appEvents.off( - `chat-message-staged-${this.message.stagedId}:id-populated`, - this, - "_subscribeToAppEvents" - ); - } + get hiddenAndCollapsed() { + return this.args.message?.hidden && this.collapsed; + } - this.appEvents.off("chat:refresh-message", this, "_refreshedMessage"); + get collapsed() { + return !this.args.message?.expanded; + } - this.appEvents.off( - `chat-message-${this.message.id}:reaction`, - this, - "_handleReactionMessage" - ); + get deletedMessageLabel() { + let count = 1; - 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.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, - deleteMessage: this.deleteMessage, - restore: this.restore, - rebakeMessage: this.rebakeMessage, - toggleBookmark: this.toggleBookmark, - startReactionForMessageActions: this.startReactionForMessageActions, + const recursiveCount = (message) => { + const previousMessage = message.previousMessage; + if (previousMessage?.deletedAt) { + count++; + recursiveCount(previousMessage); + } }; - }, - get messageCapabilities() { - return { - canReact: this.canReact, - canReply: this.canReply, - canBookmark: this.showBookmarkButton, - }; - }, + recursiveCount(this.args.message); - @discourseComputed("message", "details.can_moderate") - show(message, canModerate) { + return I18n.t("chat.deleted", { count }); + } + + get shouldRender() { return ( - !message.deleted_at || - this.currentUser.id === this.message.user.id || - this.currentUser.staff || - canModerate + this.args.message.expanded || + !this.args.message.deletedAt || + (this.args.message.deletedAt && !this.args.message.nextMessage?.deletedAt) ); - }, + } + + get shouldRenderOpenEmojiPickerButton() { + return this.chat.userCanInteractWithChat && this.site.desktopView; + } @action - handleTouchStart() { - // if zoomed don't track long press - if (isZoomed()) { - return; + expand() { + const recursiveExpand = (message) => { + const previousMessage = message.previousMessage; + if (previousMessage?.deletedAt) { + previousMessage.expanded = true; + recursiveExpand(previousMessage); + } + }; + + this.args.message.expanded = true; + this.refreshStatusOnMentions(); + recursiveExpand(this.args.message); + } + + @action + toggleChecked(event) { + if (event.shiftKey) { + this.messageInteractor.bulkSelect(event.target.checked); } - 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.messageInteractor.select(event.target.checked); + } + + @action + willDestroyMessage() { + cancel(this._invitationSentTimer); + cancel(this._disableMessageActionsHandler); + cancel(this._makeMessageActiveHandler); + cancel(this._debounceDecorateCookedMessageHandler); + this.#teardownMentionedUsers(); + } + + #destroyTippyInstances() { + _tippyInstances.forEach((instance) => { + instance.destroy(); + }); + _tippyInstances = []; + } + + @action + refreshStatusOnMentions() { + schedule("afterRender", () => { + this.args.message.mentionedUsers.forEach((user) => { + const href = `/u/${user.username.toLowerCase()}`; + const mentions = this.messageContainer.querySelectorAll( + `a.mention[href="${href}"]` + ); + + mentions.forEach((mention) => { + updateUserStatusOnMention(mention, user.status, _tippyInstances); + }); + }); + }); + } + + @action + didInsertMessage(element) { + this.messageContainer = element; + this.debounceDecorateCookedMessage(); + this.refreshStatusOnMentions(); + } + + @action + didUpdateMessageId() { + this.debounceDecorateCookedMessage(); + } + + @action + didUpdateMessageVersion() { + this.debounceDecorateCookedMessage(); + this.refreshStatusOnMentions(); + this.initMentionedUsers(); + } + + debounceDecorateCookedMessage() { + this._debounceDecorateCookedMessageHandler = discourseDebounce( + this, + this.decorateCookedMessage, + this.args.message, + 100 + ); + } + + @action + decorateCookedMessage(message) { + schedule("afterRender", () => { + _chatMessageDecorators.forEach((decorator) => { + decorator.call(this, this.messageContainer, message.channel); + }); + }); + } + + @action + initMentionedUsers() { + this.args.message.mentionedUsers.forEach((user) => { + if (user.isTrackingStatus()) { + return; } - this._isPressingHandler = discourseLater(this._handleLongPress, 500); + user.trackStatus(); + user.on("status-changed", this, "refreshStatusOnMentions"); + }); + } + + get show() { + return ( + !this.args.message?.deletedAt || + this.currentUser.id === this.args.message?.user?.id || + this.currentUser.staff || + this.args.message?.channel?.canModerate + ); + } + + @action + onMouseEnter() { + if (this.site.mobileView) { + return; } - }, - @action - handleTouchMove() { - if (!this.isHovered) { - cancel(this._isPressingHandler); + if (this.chat.activeMessage?.model?.id === this.args.message.id) { + return; } - }, + + this._onMouseEnterMessageDebouncedHandler = discourseDebounce( + this, + this._debouncedOnHoverMessage, + 250 + ); + } @action - handleTouchEnd() { - cancel(this._isPressingHandler); - }, + onMouseMove() { + if (this.site.mobileView) { + return; + } + + if (this.chat.activeMessage?.model?.id === this.args.message.id) { + return; + } + + this._setActiveMessage(); + } @action - _handleLongPress() { + onMouseLeave(event) { + cancel(this._onMouseEnterMessageDebouncedHandler); + + if (this.site.mobileView) { + return; + } + + if ( + (event.toElement || event.relatedTarget)?.closest( + ".chat-message-actions-container" + ) + ) { + return; + } + + this.chat.activeMessage = null; + } + + @bind + _debouncedOnHoverMessage() { + this._setActiveMessage(); + } + + _setActiveMessage() { + if (this.args.disableMouseEvents) { + return; + } + + cancel(this._onMouseEnterMessageDebouncedHandler); + + if (!this.chat.userCanInteractWithChat) { + return; + } + + if (!this.args.message.expanded) { + return; + } + + this.chat.activeMessage = { + model: this.args.message, + context: this.args.context, + }; + } + + @action + onLongPressStart(element, event) { + if (!this.args.message.expanded) { + return; + } + + if (event.target.tagName === "IMG") { + return; + } + + // prevents message to show as active when starting scroll + // at this moment scroll has no momentum and the row can + // capture the touch event instead of a scroll + this._makeMessageActiveHandler = discourseLater(() => { + this.isActive = true; + }, 125); + } + + @action + onLongPressCancel() { + cancel(this._makeMessageActiveHandler); + this.isActive = false; + + // this a tricky bit of code which is needed to prevent the long press + // from triggering a click on the message actions panel when releasing finger press + // we can't prevent default as we need to keep the event passive for performance reasons + // this class will prevent any click from being triggered until removed + // this number has been chosen from testing but might need to be increased + this._disableMessageActionsHandler = discourseLater(() => { + document.documentElement.classList.remove( + "disable-message-actions-touch" + ); + }, 200); + } + + @action + onLongPressEnd(element, event) { + if (event.target.tagName === "IMG") { + return; + } + + cancel(this._makeMessageActiveHandler); + this.isActive = false; + if (isZoomed()) { // if zoomed don't handle long press return; } + document.documentElement.classList.add("disable-message-actions-touch"); document.activeElement.blur(); - document.querySelector(".chat-composer-input")?.blur(); + document.querySelector(".chat-composer__input")?.blur(); - this.onHoverMessage(this.message); - }, + this._setActiveMessage(); + } - @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) { + get hasActiveState() { return ( - !deletedAt && - this.currentUser.id === message.user?.id && - this.chatChannel.canModifyMessages(this.currentUser) + this.isActive || + this.chat.activeMessage?.model?.id === this.args.message.id ); - }, + } - @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 - ); - }, + get hasReply() { + return this.args.message.inReplyTo && !this.hideReplyToInfo; + } - @discourseComputed("message") - canManageDeletion(message) { - return this.currentUser?.id === message.user?.id - ? this.details.can_delete_self - : this.details.can_delete_others; - }, + get hideUserInfo() { + const message = this.args.message; - @discourseComputed("message.deleted_at", "chatChannel.status") - canReply(deletedAt) { - return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); - }, + const previousMessage = message.previousMessage; - @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") - mentionWarning() { - return this.message.mentionWarning; - }, - - @discourseComputed("mentionWarning.cannot_see") - mentionedCannotSeeText(users) { - return I18n.t("chat.mention_warning.cannot_see", { - username: users[0].username, - count: users.length, - others: this._othersTranslation(users.length - 1), - }); - }, - - @discourseComputed("mentionWarning.without_membership") - mentionedWithoutMembershipText(users) { - return I18n.t("chat.mention_warning.without_membership", { - username: users[0].username, - count: users.length, - others: this._othersTranslation(users.length - 1), - }); - }, - - @discourseComputed("mentionWarning.group_mentions_disabled") - groupsWithDisabledMentions(groups) { - return I18n.t("chat.mention_warning.group_mentions_disabled", { - group_name: groups[0], - count: groups.length, - others: this._othersTranslation(groups.length - 1), - }); - }, - - @discourseComputed("mentionWarning.groups_with_too_many_members") - groupsWithTooManyMembers(groups) { - return I18n.t("chat.mention_warning.too_many_members", { - group_name: groups[0], - count: groups.length, - others: this._othersTranslation(groups.length - 1), - }); - }, - - _othersTranslation(othersCount) { - return I18n.t("chat.mention_warning.warning_multiple", { - count: othersCount, - }); - }, - - @action - inviteMentioned() { - const user_ids = this.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; + if (!previousMessage) { + return false; } - this.react(emoji, this.REMOVE_REACTION); - this.notifyPropertyChange("emojiReactions"); - }, - - @action - selectReaction(emoji) { - if (!this.canInteractWithChat) { - return; + // this is a micro optimization to avoid layout changes when we load more messages + if (message.firstOfResults) { + return false; } - 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); + if (message.chatWebhookEvent) { + return false; } - this._updateReactionsList(busData.emoji, busData.action, busData.user); - this.afterReactionAdded(); - }, - - @action - react(emoji, reactAction) { - if (!this.canInteractWithChat || this._loadingReactions.includes(emoji)) { - return; + if (previousMessage.deletedAt) { + return false; } - if (this.capabilities.canVibrate && !isTesting()) { - navigator.vibrate(5); + if ( + Math.abs( + new Date(message.createdAt) - new Date(previousMessage.createdAt) + ) > 300000 + ) { + return false; } - 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) { - return this.chatChannelsManager - .getChannel(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 + if (message.inReplyTo) { + if (message.inReplyTo?.id === previousMessage.id) { + return message.user?.id === previousMessage.user?.id; + } else { 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); - }); - }, + return message.user?.id === previousMessage.user?.id; + } - // 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 - 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.createFor(this.currentUser, "ChatMessage", this.message.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); - }, - } + get hideReplyToInfo() { + return ( + this.args.context === MESSAGE_CONTEXT_THREAD || + this.args.message?.inReplyTo?.id === + this.args.message?.previousMessage?.id || + this.threadingEnabled ); - }, + } - @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}` + get threadingEnabled() { + return ( + this.args.message?.channel?.threadingEnabled && + !!this.args.message?.thread ); - url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; - clipboardCopy(url); + } - discourseLater(() => { - this.messageContainer - ?.querySelector(".link-to-message-btn") - ?.classList?.remove("copied"); - }, 250); - }, + get showThreadIndicator() { + return ( + this.args.context !== MESSAGE_CONTEXT_THREAD && + this.threadingEnabled && + this.args.message?.thread && + this.args.message?.thread.preview.replyCount > 0 + ); + } - @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; + #teardownMentionedUsers() { + this.args.message.mentionedUsers.forEach((user) => { + user.stopTrackingStatus(); + user.off("status-changed", this, "refreshStatusOnMentions"); }); - - return favorites.slice(0, 3).map((emoji) => { - if (userReactions.includes(emoji)) { - return { emoji, reacted: true }; - } else { - return { emoji, reacted: false }; - } - }); - }, -}); + this.#destroyTippyInstances(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-notices.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-notices.hbs new file mode 100644 index 00000000000..0587c135ccc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-notices.hbs @@ -0,0 +1,17 @@ +
    + + + {{#each this.noticesForChannel as |notice|}} +
    +

    + {{notice.textContent}} +

    + + +
    + {{/each}} +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-notices.js b/plugins/chat/assets/javascripts/discourse/components/chat-notices.js new file mode 100644 index 00000000000..80b535a3cca --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-notices.js @@ -0,0 +1,18 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatNotices extends Component { + @service("chat-channel-pane-subscriptions-manager") subscriptionsManager; + + get noticesForChannel() { + return this.subscriptionsManager.notices.filter( + (notice) => notice.channelId === this.args.channel.id + ); + } + + @action + clearNotice(notice) { + this.subscriptionsManager.clearNotice(notice); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.hbs index 5805cca0cdf..e145bb0630e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.hbs @@ -1,10 +1,20 @@ -
    - {{#if this.shouldDisplay}} - {{this.text}} - - . - . - . - - {{/if}} -
    \ No newline at end of file +{{#if @presenceChannelName}} +
    + {{#if this.shouldRender}} + {{this.text}} + + . + . + . + + {{/if}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js index 0e0f797e938..9aad844a5d4 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js @@ -1,85 +1,77 @@ -import { isBlank, isPresent } from "@ember/utils"; -import Component from "@ember/component"; +import { isPresent } from "@ember/utils"; +import Component from "@glimmer/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"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; -export default Component.extend({ - tagName: "", - presence: service(), - presenceChannel: null, - chatChannel: null, +export default class ChatReplyingIndicator extends Component { + @service currentUser; + @service presence; - @discourseComputed("presenceChannel.users.[]") - usernames(users) { - return users - ?.filter((u) => u.id !== this.currentUser.id) - ?.mapBy("username"); - }, + @tracked presenceChannel = null; - @discourseComputed("usernames.[]") - text(usernames) { - if (isBlank(usernames)) { - return; + @action + async updateSubscription() { + await this.unsubscribe(); + await this.subscribe(); + } + + @action + async subscribe() { + this.presenceChannel = this.presence.getChannel( + this.args.presenceChannelName + ); + await this.presenceChannel.subscribe(); + } + + @action + async unsubscribe() { + if (this.presenceChannel?.subscribed) { + await this.presenceChannel.unsubscribe(); } + } - if (usernames.length === 1) { + get users() { + return ( + this.presenceChannel + ?.get("users") + ?.filter((u) => u.id !== this.currentUser.id) || [] + ); + } + + get usernames() { + return this.users.mapBy("username"); + } + + get text() { + if (this.usernames.length === 1) { return I18n.t("chat.replying_indicator.single_user", { - username: usernames[0], + username: this.usernames[0], }); } - if (usernames.length < 4) { - const lastUsername = usernames.pop(); - const commaSeparatedUsernames = usernames.join(", "); + if (this.usernames.length < 4) { + const lastUsername = this.usernames[this.usernames.length - 1]; + const commaSeparatedUsernames = this.usernames + .slice(0, this.usernames.length - 1) + .join(I18n.t("word_connector.comma")); return I18n.t("chat.replying_indicator.multiple_users", { commaSeparatedUsernames, lastUsername, }); } - const commaSeparatedUsernames = usernames.slice(0, 2).join(", "); + const commaSeparatedUsernames = this.usernames + .slice(0, 2) + .join(I18n.t("word_connector.comma")); return I18n.t("chat.replying_indicator.many_users", { commaSeparatedUsernames, - count: usernames.length - 2, + count: this.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(); - }, -}); + get shouldRender() { + return isPresent(this.usernames); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.hbs new file mode 100644 index 00000000000..309d7f6c5fe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.hbs @@ -0,0 +1,3 @@ + + {{this.text}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.js b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.js new file mode 100644 index 00000000000..5ad70302b9f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder-text.js @@ -0,0 +1,33 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; + +export default class ChatRetentionReminderText extends Component { + @service siteSettings; + + get text() { + if (this.args.channel.isDirectMessageChannel) { + if (this.#countForChannelType > 0) { + return I18n.t("chat.retention_reminders.dm", { + count: this.siteSettings.chat_dm_retention_days, + }); + } else { + return I18n.t("chat.retention_reminders.dm_none"); + } + } else { + if (this.#countForChannelType > 0) { + return I18n.t("chat.retention_reminders.public", { + count: this.siteSettings.chat_channel_retention_days, + }); + } else { + return I18n.t("chat.retention_reminders.public_none"); + } + } + } + + get #countForChannelType() { + return this.args.channel.isDirectMessageChannel + ? this.siteSettings.chat_dm_retention_days + : this.siteSettings.chat_channel_retention_days; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs index 7eff93b9558..6033b130443 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs @@ -1,9 +1,9 @@ {{#if this.show}}
    - {{this.text}} +
    diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js index ec667667603..c55694d9296 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js @@ -1,59 +1,33 @@ -import Component from "@ember/component"; -import discourseComputed from "discourse-common/utils/decorators"; -import I18n from "I18n"; +import Component from "@glimmer/component"; import { action } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; -export default Component.extend({ - tagName: "", - loading: false, +export default class ChatRetentionReminder extends Component { + @service currentUser; - @discourseComputed( - "chatChannel.chatable_type", - "currentUser.{needs_dm_retention_reminder,needs_channel_retention_reminder}" - ) - show() { + get show() { return ( - !this.chatChannel.isDraft && - ((this.chatChannel.isDirectMessageChannel && - this.currentUser.needs_dm_retention_reminder) || - (!this.chatChannel.isDirectMessageChannel && - this.currentUser.needs_channel_retention_reminder)) + (this.args.channel?.isDirectMessageChannel && + this.currentUser?.get("needs_dm_retention_reminder")) || + (this.args.channel?.isCategoryChannel && + this.currentUser?.get("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 }, + data: { chatable_type: this.args.channel.chatableType }, }) .then(() => { - const field = this.chatChannel.isDirectMessageChannel + const field = this.args.channel.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 deleted file mode 100644 index a9c8887318d..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js +++ /dev/null @@ -1,124 +0,0 @@ -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-side-panel-resizer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs new file mode 100644 index 00000000000..a717e52b509 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs new file mode 100644 index 00000000000..1582dfa8c49 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs @@ -0,0 +1,21 @@ +{{#if this.chatStateManager.isSidePanelExpanded}} +
    + {{yield}} + + {{#if this.site.desktopView}} + + {{/if}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js new file mode 100644 index 00000000000..aa1e0f712c5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js @@ -0,0 +1,41 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import { tracked } from "@glimmer/tracking"; + +const MIN_CHAT_CHANNEL_WIDTH = 250; + +export default class ChatSidePanel extends Component { + @service chatStateManager; + @service chatSidePanelSize; + @service site; + + @tracked widthStyle; + + @action + setupSize() { + this.widthStyle = htmlSafe(`width:${this.chatSidePanelSize.width}px`); + } + + @action + didResize(element, size) { + if (this.isDestroying || this.isDestroyed) { + return; + } + + const parentWidth = element.parentElement.getBoundingClientRect().width; + const mainPanelWidth = parentWidth - size.width; + + if (mainPanelWidth >= MIN_CHAT_CHANNEL_WIDTH) { + this.chatSidePanelSize.width = size.width; + element.style.width = size.width + "px"; + this.widthStyle = htmlSafe(`width:${size.width}px`); + } + } + + #maxWidth(element) { + const parentWidth = element.parentElement.getBoundingClientRect().width; + return parentWidth - MIN_CHAT_CHANNEL_WIDTH; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs index f46109445ed..719aa260777 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs @@ -1,119 +1,29 @@
    -
    -
    -
    -
    + {{#each this.placeholders as |placeholder|}} +
    +
    +
    +
    +
    + {{#if placeholder.image}} +
    + {{/if}} -
    -
    -
    -
    -
    -
    -
    -
    +
    + {{#each placeholder.rows as |row|}} +
    + {{/each}} +
    -
    -
    -
    -
    -
    + {{#if placeholder.reactions}} +
    + {{#each placeholder.reactions}} +
    + {{/each}} +
    + {{/if}} +
    -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    + {{/each}}
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js new file mode 100644 index 00000000000..6af83cf2e41 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import { htmlSafe } from "@ember/template"; + +export default class ChatSkeleton extends Component { + get placeholders() { + return Array.from({ length: 15 }, () => { + return { + image: this.#randomIntFromInterval(1, 10) === 5, + rows: Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => { + return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`); + }), + reactions: Array.from({ length: this.#randomIntFromInterval(0, 3) }), + }; + }); + } + + #randomIntFromInterval(min, max) { + return Math.floor(Math.random() * (max - min + 1) + min); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs new file mode 100644 index 00000000000..0d5fb60712d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -0,0 +1,62 @@ +
    + {{#if @includeHeader}} + + {{/if}} + +
    +
    + {{#each this.messagesManager.messages key="id" as |message|}} + + {{/each}} + + {{#unless this.messagesLoader.fetchedOnce}} + {{#if this.messagesLoader.loading}} + + {{/if}} + {{/unless}} +
    +
    + + + + {{#if this.chatThreadPane.selectingMessages}} + + {{else}} + + {{/if}} + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js new file mode 100644 index 00000000000..98261307bcb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -0,0 +1,459 @@ +import Component from "@glimmer/component"; +import { NotificationLevels } from "discourse/lib/notification-levels"; +import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership"; +import { cached, tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse-common/utils/decorators"; +import { inject as service } from "@ember/service"; +import { cancel, next } from "@ember/runloop"; +import { resetIdle } from "discourse/lib/desktop-notifications"; +import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { + FUTURE, + PAST, + READ_INTERVAL_MS, +} from "discourse/plugins/chat/discourse/lib/chat-constants"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { + bodyScrollFix, + stackingContextFix, +} from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; +import { + scrollListToBottom, + scrollListToMessage, + scrollListToTop, +} from "discourse/plugins/chat/discourse/lib/scroll-helpers"; + +export default class ChatThread extends Component { + @service appEvents; + @service capabilities; + @service chat; + @service chatApi; + @service chatComposerPresenceManager; + @service chatHistory; + @service chatThreadComposer; + @service chatThreadPane; + @service chatThreadPaneSubscriptionsManager; + @service currentUser; + @service router; + @service siteSettings; + + @tracked isAtBottom = true; + @tracked isScrolling = false; + @tracked needsArrow = false; + @tracked uploadDropZone; + + scrollable = null; + + @action + resetIdle() { + resetIdle(); + } + + @cached + get messagesLoader() { + return new ChatMessagesLoader(getOwner(this), this.args.thread); + } + + get messagesManager() { + return this.args.thread.messagesManager; + } + + @action + handleKeydown(event) { + if (event.key === "Escape") { + return this.router.transitionTo( + "chat.channel", + ...this.args.thread.channel.routeModels + ); + } + } + + @action + didUpdateThread() { + this.messagesManager.clear(); + this.chatThreadComposer.focus(); + this.loadMessages(); + this.resetComposerMessage(); + } + + @action + setUploadDropZone(element) { + this.uploadDropZone = element; + } + + @action + subscribeToUpdates() { + this.chatThreadPaneSubscriptionsManager.subscribe(this.args.thread); + } + + @action + teardown() { + this.chatThreadPaneSubscriptionsManager.unsubscribe(); + cancel(this._debouncedFillPaneAttemptHandler); + cancel(this._debounceUpdateLastReadMessageHandler); + } + + @action + onScroll(state) { + next(() => { + if (this.#flushIgnoreNextScroll()) { + return; + } + + bodyScrollFix(); + + this.needsArrow = + (this.messagesLoader.fetchedOnce && + this.messagesLoader.canLoadMoreFuture) || + (state.distanceToBottom.pixels > 250 && !state.atBottom); + this.isScrolling = true; + this.debounceUpdateLastReadMessage(); + + if ( + state.atTop || + (!this.capabilities.isIOS && + state.up && + state.distanceToTop.percentage < 40) + ) { + this.fetchMoreMessages({ direction: PAST }); + } else if (state.atBottom) { + this.fetchMoreMessages({ direction: FUTURE }); + } + }); + } + + @action + onScrollEnd(state) { + this.needsArrow = + (this.messagesLoader.fetchedOnce && + this.messagesLoader.canLoadMoreFuture) || + (state.distanceToBottom.pixels > 250 && !state.atBottom); + this.isScrolling = false; + this.resetIdle(); + this.atBottom = state.atBottom; + + if (state.atBottom) { + this.fetchMoreMessages({ direction: FUTURE }); + } + } + + debounceUpdateLastReadMessage() { + this._debounceUpdateLastReadMessageHandler = discourseDebounce( + this, + this.updateLastReadMessage, + READ_INTERVAL_MS + ); + } + + @bind + updateLastReadMessage() { + // HACK: We don't have proper scroll visibility over + // what message we are looking at, don't have the lastReadMessageId + // for the thread, and this updateLastReadMessage function is only + // called when scrolling all the way to the bottom. + this.markThreadAsRead(); + } + + @action + setScrollable(element) { + this.scrollable = element; + } + + @action + loadMessages() { + this.fetchMessages(); + this.subscribeToUpdates(); + } + + @action + didResizePane() { + this._ignoreNextScroll = true; + this.debounceFillPaneAttempt(); + this.debounceUpdateLastReadMessage(); + } + + async fetchMessages(findArgs = {}) { + if (this.args.thread.staged) { + const message = this.args.thread.originalMessage; + message.thread = this.args.thread; + message.manager = this.messagesManager; + this.messagesManager.addMessages([message]); + return; + } + + if (this.messagesLoader.loading) { + return; + } + + this.messagesManager.clear(); + + findArgs.targetMessageId ??= + this.args.targetMessageId || + this.args.thread.currentUserMembership?.lastReadMessageId; + + if (!findArgs.targetMessageId) { + findArgs.direction = FUTURE; + } + + const result = await this.messagesLoader.load(findArgs); + if (!result) { + return; + } + + const [messages, meta] = this.processMessages(this.args.thread, result); + stackingContextFix(this.scrollable, () => { + this.messagesManager.addMessages(messages); + }); + this.args.thread.details = meta; + + if (this.args.targetMessageId) { + this.scrollToMessageId(this.args.targetMessageId, { highlight: true }); + } else if (this.args.thread.currentUserMembership?.lastReadMessageId) { + this.scrollToMessageId( + this.args.thread.currentUserMembership?.lastReadMessageId + ); + } else { + this.scrollToTop(); + } + + this.debounceFillPaneAttempt(); + } + + @action + async fetchMoreMessages({ direction }) { + if (this.messagesLoader.loading) { + return; + } + + const result = await this.messagesLoader.loadMore({ direction }); + if (!result) { + return; + } + + const [messages, meta] = this.processMessages(this.args.thread, result); + if (!messages?.length) { + return; + } + + stackingContextFix(this.scrollable, () => { + this.messagesManager.addMessages(messages); + }); + this.args.thread.details = meta; + + if (direction === FUTURE) { + this.scrollToMessageId(messages.firstObject.id, { + position: "end", + behavior: "auto", + }); + } else if (direction === PAST) { + this.scrollToMessageId(messages.lastObject.id); + } + + this.debounceFillPaneAttempt(); + } + + @action + scrollToLatestMessage() { + if (this.messagesLoader.canLoadMoreFuture) { + this.fetchMessages(); + } else if (this.messagesManager.messages.length > 0) { + this.scrollToBottom(); + } + } + + debounceFillPaneAttempt() { + if (!this.messagesLoader.fetchedOnce) { + return; + } + + this._debouncedFillPaneAttemptHandler = discourseDebounce( + this, + this.fillPaneAttempt, + 500 + ); + } + + async fillPaneAttempt() { + // safeguard + if (this.messagesManager.messages.length > 200) { + return; + } + + if (!this.messagesLoader.canLoadMorePast) { + return; + } + + const firstMessage = this.messagesManager.messages.firstObject; + if (!firstMessage?.visible) { + return; + } + + await this.fetchMoreMessages({ direction: PAST }); + } + + scrollToMessageId( + messageId, + opts = { highlight: false, position: "start", autoExpand: false } + ) { + this._ignoreNextScroll = true; + const message = this.messagesManager.findMessage(messageId); + scrollListToMessage(this.scrollable, message, opts); + } + + @bind + processMessages(thread, result) { + const messages = result.messages.map((messageData) => { + const ignored = this.currentUser.ignored_users || []; + const hidden = ignored.includes(messageData.user.username); + + return ChatMessage.create(thread.channel, { + ...messageData, + hidden, + expanded: !(hidden || messageData.deleted_at), + manager: this.messagesManager, + thread, + }); + }); + + return [messages, result.meta]; + } + + // NOTE: At some point we want to do this based on visible messages + // and scrolling; for now it's enough to do it when the thread panel + // opens/messages are loaded since we have no pagination for threads. + markThreadAsRead() { + if (!this.args.thread || this.args.thread.staged) { + return; + } + + return this.chatApi.markThreadAsRead( + this.args.thread.channel.id, + this.args.thread.id + ); + } + + @action + async onSendMessage(message) { + resetIdle(); + + await message.cook(); + if (message.editing) { + await this.#sendEditMessage(message); + } else { + await this.#sendNewMessage(message); + } + } + + @action + resetComposerMessage() { + this.chatThreadComposer.reset(this.args.thread); + } + + async #sendNewMessage(message) { + if (this.chatThreadPane.sending) { + return; + } + + this.chatThreadPane.sending = true; + this._ignoreNextScroll = true; + stackingContextFix(this.scrollable, async () => { + await this.args.thread.stageMessage(message); + }); + this.resetComposerMessage(); + + if (!this.messagesLoader.canLoadMoreFuture) { + this.scrollToLatestMessage(); + } + + try { + const response = await this.chatApi.sendMessage( + this.args.thread.channel.id, + { + message: message.message, + in_reply_to_id: message.thread.staged + ? message.thread.originalMessage?.id + : null, + staged_id: message.id, + upload_ids: message.uploads.map((upload) => upload.id), + thread_id: message.thread.staged ? null : message.thread.id, + staged_thread_id: message.thread.staged ? message.thread.id : null, + } + ); + + this.args.thread.currentUserMembership ??= + UserChatThreadMembership.create({ + notification_level: NotificationLevels.TRACKING, + last_read_message_id: response.message_id, + }); + + this.scrollToLatestMessage(); + } catch (error) { + this.#onSendError(message.id, error); + } finally { + this.chatThreadPane.sending = false; + } + } + + async #sendEditMessage(message) { + this.chatThreadPane.sending = true; + + const data = { + new_message: message.message, + upload_ids: message.uploads.map((upload) => upload.id), + }; + + this.resetComposerMessage(); + + try { + return await this.chatApi.editMessage( + message.channel.id, + message.id, + data + ); + } catch (e) { + popupAjaxError(e); + } finally { + this.chatThreadPane.sending = false; + } + } + + @action + scrollToBottom() { + this._ignoreNextScroll = true; + scrollListToBottom(this.scrollable); + } + + @action + scrollToTop() { + this._ignoreNextScroll = true; + scrollListToTop(this.scrollable); + } + + @action + resendStagedMessage() {} + + #onSendError(stagedId, error) { + const stagedMessage = + this.args.thread.messagesManager.findStagedMessage(stagedId); + if (stagedMessage) { + if (error.jqXHR?.responseJSON?.errors?.length) { + stagedMessage.error = error.jqXHR.responseJSON.errors[0]; + } else { + this.chat.markNetworkAsUnreliable(); + stagedMessage.error = "network_error"; + } + } + + this.resetComposerMessage(); + } + + #flushIgnoreNextScroll() { + const prev = this._ignoreNextScroll; + this._ignoreNextScroll = false; + return prev; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs new file mode 100644 index 00000000000..08980dba7aa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs @@ -0,0 +1,65 @@ +
    +
    +
    + + + +
    +
    + + + + + + + + +
    +
    + + {{this.title}} + +
    +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js new file mode 100644 index 00000000000..3e6b2487685 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js @@ -0,0 +1,19 @@ +import Component from "@glimmer/component"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import I18n from "I18n"; + +export default class ChatUploadDropZone extends Component { + get title() { + if (this.#isThread()) { + return I18n.t("chat.upload_to_thread"); + } else { + return I18n.t("chat.upload_to_channel", { + title: this.args.model.title, + }); + } + } + + #isThread() { + return this.args.model instanceof ChatThread; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs index ec31764ba70..5af7e46ddaa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload.hbs @@ -7,12 +7,17 @@ src={{@upload.url}} style={{this.imageStyle}} loading="lazy" + tabindex="0" {{on "load" this.imageLoaded}} /> {{else if (eq this.type this.VIDEO_TYPE)}} +{{else if (eq this.type this.AUDIO_TYPE)}} + {{else}} -
    - {{avatar this.user imageSize=this.avatarSize}} -
    -
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js deleted file mode 100644 index 0b3853e915e..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js +++ /dev/null @@ -1,22 +0,0 @@ -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.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs index cf858f4fb04..a68732864ab 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs @@ -1,15 +1,20 @@ {{#if this.shouldShowNameFirst}} - {{this.user.name}} + {{@user.name}} {{/if}} - + {{this.formattedUsername}} {{#if this.shouldShowNameLast}} - {{this.user.name}} + {{@user.name}} {{/if}} \ No newline at end of file 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 index 3130f9d6de6..3016ce869fa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js @@ -1,32 +1,26 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { formatUsername } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; export default class ChatUserDisplayName extends Component { - tagName = ""; - user = null; + @service siteSettings; - @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; + return this.args.user?.name && this.args.user.name.trim().length > 0; } - @computed("user.username") get formattedUsername() { - return formatUsername(this.user?.username); + return formatUsername(this.args.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-user-info.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-user-info.hbs new file mode 100644 index 00000000000..1dd5843bf8e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-info.hbs @@ -0,0 +1,8 @@ +{{#if @user}} + + + + + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-info.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-info.js new file mode 100644 index 00000000000..ccc7d150af6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-info.js @@ -0,0 +1,8 @@ +import Component from "@glimmer/component"; +import { userPath } from "discourse/lib/url"; + +export default class ChatUserInfo extends Component { + get userPath() { + return userPath(this.args.user.username); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js index 270e4a3bc95..546134aedb4 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js @@ -1,97 +1,71 @@ import { bind } from "discourse-common/utils/decorators"; import Component from "@ember/component"; +import { inject as service } from "@ember/service"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; const CSS_VAR = "--chat-vh"; -let pendingUpdate = false; +let lastVH; export default class ChatVh extends Component { + @service capabilities; + tagName = ""; didInsertElement() { this._super(...arguments); - this.setVHFromVisualViewPort(); - - (window?.visualViewport || window).addEventListener( - "resize", - this.setVHFromVisualViewPort - ); - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.addEventListener( - "geometrychange", - this.setVHFromKeyboard - ); + navigator.virtualKeyboard.overlaysContent = true; + navigator.virtualKeyboard.addEventListener("geometrychange", this.setVH); } + + this.activeWindow = window.visualViewport || window; + this.activeWindow.addEventListener("resize", this.setVH); + this.setVH(); } willDestroyElement() { this._super(...arguments); + this.activeWindow?.removeEventListener("resize", this.setVH); + lastVH = null; + if ("virtualKeyboard" in navigator) { navigator.virtualKeyboard.removeEventListener( "geometrychange", - this.setVHFromKeyboard + this.setVH ); + } + } + + @bind + setVH() { + if (isZoomed()) { + return; + } + + let height; + if ("virtualKeyboard" in navigator) { + height = + window.visualViewport.height - + navigator.virtualKeyboard.boundingRect.height; } else { - (window?.visualViewport || window).removeEventListener( - "resize", - this.setVHFromVisualViewPort - ); + height = this.activeWindow?.height || window.innerHeight; } - pendingUpdate = false; + const vh = height * 0.01; + + if (lastVH === vh) { + return; + } + lastVH = vh; + + document.documentElement.style.setProperty(CSS_VAR, `${vh}px`); } - @bind - setVHFromKeyboard(event) { - if (pendingUpdate) { - return; + #blurActiveElement() { + if (document.activeElement?.blur) { + document.activeElement.blur(); } - - 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/chat/admin/export-messages.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.hbs new file mode 100644 index 00000000000..732d4be7dbb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.hbs @@ -0,0 +1,10 @@ +
    +

    {{i18n "chat.admin.export_messages.title"}}

    +

    {{i18n "chat.admin.export_messages.description"}}

    + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js new file mode 100644 index 00000000000..8ae8017781e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js @@ -0,0 +1,22 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +export default class ChatAdminExportMessages extends Component { + @service chatAdminApi; + @service dialog; + + @action + async exportMessages() { + try { + await this.chatAdminApi.exportMessages(); + this.dialog.alert( + I18n.t("chat.admin.export_messages.export_has_started") + ); + } catch (error) { + popupAjaxError(error); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.hbs new file mode 100644 index 00000000000..4e12924ce77 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.hbs @@ -0,0 +1,5 @@ +
    + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.js new file mode 100644 index 00000000000..f91ff5d29be --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/button.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class ChatComposerButton extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js new file mode 100644 index 00000000000..ed67eede4c9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js @@ -0,0 +1,115 @@ +import ChatComposer from "../../chat-composer"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { action } from "@ember/object"; + +export default class ChatComposerChannel extends ChatComposer { + @service("chat-channel-composer") composer; + @service("chat-channel-pane") pane; + @service chatDraftsManager; + @service currentUser; + + context = "channel"; + + composerId = "channel-composer"; + + get shouldRenderReplyingIndicator() { + return this.args.channel; + } + + get presenceChannelName() { + const channel = this.args.channel; + return `/chat-reply/${channel.id}`; + } + + get disabled() { + return ( + !this.chat.userCanInteractWithChat || + !this.args.channel.canModifyMessages(this.currentUser) + ); + } + + @action + reset() { + this.composer.reset(this.args.channel); + } + + @action + persistDraft() { + this.chatDraftsManager.add(this.currentMessage); + + this._persistHandler = discourseDebounce( + this, + this._debouncedPersistDraft, + this.args.channel.id, + this.currentMessage.toJSONDraft(), + 2000 + ); + } + + @action + _debouncedPersistDraft(channelId, jsonDraft) { + this.chatApi.saveDraft(channelId, jsonDraft).then(() => { + if (this.currentMessage) { + this.currentMessage.draftSaved = true; + } + }); + } + + get lastMessage() { + return this.args.channel.lastMessage; + } + + lastUserMessage(user) { + return this.args.channel.messagesManager.findLastUserMessage(user); + } + + get placeholder() { + if (!this.args.channel.canModifyMessages(this.currentUser)) { + return I18n.t( + `chat.placeholder_new_message_disallowed.${this.args.channel.status}` + ); + } + + if (!this.chat.userCanInteractWithChat) { + return I18n.t("chat.placeholder_silenced"); + } else { + return this.#messageRecipients(this.args.channel); + } + } + + handleEscape(event) { + event.stopPropagation(); + + if (this.currentMessage?.inReplyTo) { + this.reset(); + } else if (this.currentMessage?.editing) { + this.composer.cancel(this.args.channel); + } else { + event.target.blur(); + } + } + + #messageRecipients(channel) { + if (channel.isDirectMessageChannel) { + const directMessageRecipients = channel.chatable.users; + if ( + directMessageRecipients.length === 1 && + directMessageRecipients[0].id === this.currentUser.id + ) { + return I18n.t("chat.placeholder_self"); + } + + return I18n.t("chat.placeholder_users", { + commaSeparatedNames: directMessageRecipients + .map((u) => u.name || `@${u.username}`) + .join(I18n.t("word_connector.comma")), + }); + } else { + return I18n.t("chat.placeholder_channel", { + channelName: `#${channel.title}`, + }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.hbs new file mode 100644 index 00000000000..5991a7287e9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.hbs @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.js new file mode 100644 index 00000000000..47b899f4625 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/separator.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class ChatComposerSeparator extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js new file mode 100644 index 00000000000..dd061e33b0c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js @@ -0,0 +1,61 @@ +import ChatComposer from "../../chat-composer"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import { action } from "@ember/object"; + +export default class ChatComposerThread extends ChatComposer { + @service("chat-channel-composer") channelComposer; + @service("chat-thread-composer") composer; + @service("chat-thread-pane") pane; + @service currentUser; + + context = "thread"; + + composerId = "thread-composer"; + + @action + reset() { + this.composer.reset(this.args.thread); + } + + get shouldRenderReplyingIndicator() { + return this.args.thread; + } + + get disabled() { + return ( + !this.chat.userCanInteractWithChat || + !this.args.thread.channel.canModifyMessages(this.currentUser) + ); + } + + get presenceChannelName() { + const thread = this.args.thread; + return `/chat-reply/${thread.channel.id}/thread/${thread.id}`; + } + + get placeholder() { + return I18n.t("chat.placeholder_thread"); + } + + lastUserMessage(user) { + return this.args.thread.messagesManager.findLastUserMessage(user); + } + + handleEscape(event) { + if (this.currentMessage.editing) { + event.stopPropagation(); + this.composer.cancel(this.args.thread); + return; + } + + if (this.isFocused) { + event.stopPropagation(); + this.composer.blur(); + } else { + this.pane.close().then(() => { + this.channelComposer.focus(); + }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/header/icon.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon.hbs new file mode 100644 index 00000000000..00fb2474a4f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon.hbs @@ -0,0 +1,14 @@ + + {{d-icon "d-chat"}} + {{#unless this.currentUserInDnD}} + + {{/unless}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon.js b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon.js similarity index 50% rename from plugins/chat/assets/javascripts/discourse/components/chat-header-icon.js rename to plugins/chat/assets/javascripts/discourse/components/chat/header/icon.js index 9753f8ab005..6dbf81707d7 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-header-icon.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon.js @@ -1,31 +1,38 @@ import { inject as service } from "@ember/service"; import Component from "@glimmer/component"; +import getURL from "discourse-common/lib/get-url"; export default class ChatHeaderIcon extends Component { @service currentUser; @service site; @service chatStateManager; + @service router; get currentUserInDnD() { - return this.currentUser.isInDoNotDisturb(); - } - - get href() { - if (this.chatStateManager.isFullPageActive && this.site.mobileView) { - return "/chat"; - } - - if (this.chatStateManager.isDrawerActive) { - return "/chat"; - } else { - return this.chatStateManager.lastKnownChatURL || "/chat"; - } + return this.args.currentUserInDnD || this.currentUser.isInDoNotDisturb(); } get isActive() { return ( + this.args.isActive || this.chatStateManager.isFullPageActive || this.chatStateManager.isDrawerActive ); } + + get href() { + if (this.chatStateManager.isFullPageActive) { + if (this.site.mobileView) { + return getURL("/chat"); + } else { + return getURL(this.router.currentURL); + } + } + + if (this.chatStateManager.isDrawerActive) { + return getURL("/chat"); + } + + return getURL(this.chatStateManager.lastKnownChatURL || "/chat"); + } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.hbs new file mode 100644 index 00000000000..973955c09c3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.hbs @@ -0,0 +1,9 @@ +{{#if this.showUrgentIndicator}} +
    +
    + {{this.unreadCountLabel}} +
    +
    +{{else if this.showUnreadIndicator}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.js new file mode 100644 index 00000000000..41b8c3e9855 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/header/icon/unread-indicator.js @@ -0,0 +1,65 @@ +import { inject as service } from "@ember/service"; +import Component from "@glimmer/component"; +import { + HEADER_INDICATOR_PREFERENCE_ALL_NEW, + HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS, + HEADER_INDICATOR_PREFERENCE_NEVER, +} from "discourse/plugins/chat/discourse/controllers/preferences-chat"; + +export default class ChatHeaderIconUnreadIndicator extends Component { + @service chatTrackingStateManager; + @service currentUser; + + get urgentCount() { + return ( + this.args.urgentCount || + this.chatTrackingStateManager.allChannelUrgentCount + ); + } + + get unreadCount() { + return ( + this.args.unreadCount || + this.chatTrackingStateManager.publicChannelUnreadCount + ); + } + + get indicatorPreference() { + return ( + this.args.indicatorPreference || + this.currentUser.user_option.chat_header_indicator_preference + ); + } + + get showUrgentIndicator() { + return ( + this.urgentCount > 0 && + this.#hasAnyIndicatorPreference([ + HEADER_INDICATOR_PREFERENCE_ALL_NEW, + HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS, + ]) + ); + } + + get showUnreadIndicator() { + return ( + this.unreadCount > 0 && + this.#hasAnyIndicatorPreference([HEADER_INDICATOR_PREFERENCE_ALL_NEW]) + ); + } + + get unreadCountLabel() { + return this.urgentCount > 99 ? "99+" : this.urgentCount; + } + + #hasAnyIndicatorPreference(preferences) { + if ( + !this.currentUser || + this.indicatorPreference === HEADER_INDICATOR_PREFERENCE_NEVER + ) { + return false; + } + + return preferences.includes(this.indicatorPreference); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs new file mode 100644 index 00000000000..c0bbc86234e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs @@ -0,0 +1,141 @@ +
    +
    +
    +
    +
    + {{d-icon "search" class="chat-message-creator__search-icon"}} +
    + + {{#each this.selection as |selection|}} +
    + {{component + (concat "chat/message-creator/" selection.type "-selection") + selection=selection + }} + +
    + {{/each}} + + +
    + + +
    + + {{#if this.showResults}} + + {{/if}} + + {{#if this.showFooter}} + + {{/if}} +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js new file mode 100644 index 00000000000..292b78098d1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js @@ -0,0 +1,542 @@ +import Component from "@glimmer/component"; +import { cached, tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import { schedule } from "@ember/runloop"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { getOwner, setOwner } from "@ember/application"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import I18n from "I18n"; +import ChatChatable from "discourse/plugins/chat/discourse/models/chat-chatable"; +import { escapeExpression } from "discourse/lib/utilities"; +import { htmlSafe } from "@ember/template"; + +const MAX_RESULTS = 10; +const USER_PREFIX = "@"; +const CHANNEL_PREFIX = "#"; +const CHANNEL_TYPE = "channel"; +const USER_TYPE = "user"; + +class Search { + @service("chat-api") api; + @service chat; + @service chatChannelsManager; + + @tracked loading = false; + @tracked value = []; + @tracked query = ""; + + constructor(owner, options = {}) { + setOwner(this, owner); + + options.preload ??= false; + options.onlyUsers ??= false; + + if (!options.term && !options.preload) { + return; + } + + if (!options.term && options.preload) { + this.value = this.#loadExistingChannels(); + return; + } + + this.loading = true; + + this.api + .chatables({ term: options.term }) + .then((results) => { + let chatables = [ + ...results.users, + ...results.direct_message_channels, + ...results.category_channels, + ]; + + if (options.excludeUserId) { + chatables = chatables.filter( + (item) => item.identifier !== `u-${options.excludeUserId}` + ); + } + + this.value = chatables + .map((item) => { + const chatable = ChatChatable.create(item); + chatable.tracking = this.#injectTracking(chatable); + return chatable; + }) + .slice(0, MAX_RESULTS); + }) + .catch(() => (this.value = [])) + .finally(() => (this.loading = false)); + } + + #loadExistingChannels() { + return this.chatChannelsManager.allChannels + .map((channel) => { + let chatable; + if (channel.chatable?.users?.length === 1) { + chatable = ChatChatable.createUser(channel.chatable.users[0]); + chatable.tracking = this.#injectTracking(chatable); + } else { + chatable = ChatChatable.createChannel(channel); + chatable.tracking = channel.tracking; + } + return chatable; + }) + .filter(Boolean) + .slice(0, MAX_RESULTS); + } + + #injectTracking(chatable) { + switch (chatable.type) { + case CHANNEL_TYPE: + return this.chatChannelsManager.allChannels.find( + (channel) => channel.id === chatable.model.id + )?.tracking; + break; + case USER_TYPE: + return this.chatChannelsManager.directMessageChannels.find( + (channel) => + channel.chatable.users.length === 1 && + channel.chatable.users[0].id === chatable.model.id + )?.tracking; + break; + } + } +} + +export default class ChatMessageCreator extends Component { + @service("chat-api") api; + @service("chat-channel-composer") composer; + @service chat; + @service site; + @service router; + @service currentUser; + @service siteSettings; + + @tracked selection = new TrackedArray(); + @tracked activeSelection = new TrackedArray(); + @tracked query = ""; + @tracked queryElement = null; + @tracked loading = false; + @tracked activeSelectionIdentifiers = new TrackedArray(); + @tracked selectedIdentifiers = []; + @tracked _activeResultIdentifier = null; + + get placeholder() { + if ( + this.siteSettings.enable_public_channels && + this.chat.userCanDirectMessage + ) { + if (this.hasSelectedUsers) { + return I18n.t("chat.new_message_modal.user_search_placeholder"); + } else { + return I18n.t("chat.new_message_modal.default_search_placeholder"); + } + } else if (this.siteSettings.enable_public_channels) { + return I18n.t( + "chat.new_message_modal.default_channel_search_placeholder" + ); + } else if (this.chat.userCanDirectMessage) { + if (this.hasSelectedUsers) { + return I18n.t("chat.new_message_modal.user_search_placeholder"); + } else { + return I18n.t("chat.new_message_modal.default_user_search_placeholder"); + } + } + } + + get showFooter() { + return this.showShortcut || this.hasSelectedUsers; + } + + get showResults() { + if (this.hasSelectedUsers && !this.query.length) { + return false; + } + + return true; + } + + get shortcutLabel() { + let username; + + if (this.activeResult?.isUser) { + username = this.activeResult.model.username; + } else { + username = this.activeResult.model.chatable.users[0].username; + } + + return htmlSafe( + I18n.t("chat.new_message_modal.add_user_long", { + username: escapeExpression(username), + }) + ); + } + + get showShortcut() { + return ( + !this.hasSelectedUsers && + this.searchRequest?.value?.length && + this.site.desktopView && + (this.activeResult?.isUser || this.activeResult?.isSingleUserChannel) + ); + } + + get activeResultIdentifier() { + return ( + this._activeResultIdentifier || + this.searchRequest.value.find((result) => result.enabled)?.identifier + ); + } + + get hasSelectedUsers() { + return this.selection.some((s) => s.isUser); + } + + get activeResult() { + return this.searchRequest.value.findBy( + "identifier", + this.activeResultIdentifier + ); + } + + set activeResult(result) { + if (!result?.enabled) { + return; + } + + this._activeResultIdentifier = result?.identifier; + } + + get selectionIdentifiers() { + return this.selection.mapBy("identifier"); + } + + get openChannelLabel() { + const users = this.selection.mapBy("model"); + + return I18n.t("chat.placeholder_users", { + commaSeparatedNames: users + .map((u) => u.name || u.username) + .join(I18n.t("word_connector.comma")), + }); + } + + @cached + get searchRequest() { + let term = this.query; + + if (term?.length) { + if (this.hasSelectedUsers && term.startsWith(CHANNEL_PREFIX)) { + term = term.replace(/^#/, USER_PREFIX); + } + + if (this.hasSelectedUsers && !term.startsWith(USER_PREFIX)) { + term = USER_PREFIX + term; + } + } + + return new Search(getOwner(this), { + term, + preload: !this.selection?.length, + onlyUsers: this.hasSelectedUsers, + excludeUserId: this.hasSelectedUsers ? this.currentUser?.id : null, + }); + } + + @action + onFilter(term) { + this._activeResultIdentifier = null; + this.activeSelectionIdentifiers = []; + this.query = term; + } + + @action + setQueryElement(element) { + this.queryElement = element; + } + + @action + focusInput() { + schedule("afterRender", () => { + this.queryElement.focus(); + }); + } + + @action + handleKeydown(event) { + if (event.key === "Escape") { + if (this.activeSelectionIdentifiers.length > 0) { + this.activeSelectionIdentifiers = []; + event.preventDefault(); + event.stopPropagation(); + return; + } + } + + if (event.key === "a" && (event.metaKey || event.ctrlKey)) { + this.activeSelectionIdentifiers = this.selection.mapBy("identifier"); + return; + } + + if (event.key === "Enter") { + if (this.activeSelectionIdentifiers.length > 0) { + this.activeSelectionIdentifiers.forEach((identifier) => { + this.removeSelection(identifier); + }); + this.activeSelectionIdentifiers = []; + event.preventDefault(); + return; + } else if (this.activeResultIdentifier) { + this.toggleSelection(this.activeResultIdentifier, { + altSelection: event.shiftKey || event.ctrlKey, + }); + event.preventDefault(); + return; + } else if (this.query?.length === 0) { + this.openChannel(this.selection); + event.preventDefault(); + return; + } + } + + if (event.key === "ArrowDown" && this.searchRequest.value.length > 0) { + this.activeSelectionIdentifiers = []; + this._activeResultIdentifier = this.#getNextResult()?.identifier; + event.preventDefault(); + return; + } + + if (event.key === "ArrowUp" && this.searchRequest.value.length > 0) { + this.activeSelectionIdentifiers = []; + this._activeResultIdentifier = this.#getPreviousResult()?.identifier; + event.preventDefault(); + return; + } + + const digit = this.#getDigit(event.code); + if (event.ctrlKey && digit) { + this._activeResultIdentifier = this.searchRequest.value.objectAt( + digit - 1 + )?.identifier; + event.preventDefault(); + return; + } + + if (event.target.selectionEnd !== 0 || event.target.selectionStart !== 0) { + return; + } + + if (event.key === "Backspace" && this.selection.length) { + if (!this.activeSelectionIdentifiers.length) { + this.activeSelectionIdentifiers = [this.#getLastSelection().identifier]; + event.preventDefault(); + return; + } else { + this.activeSelectionIdentifiers.forEach((identifier) => { + this.removeSelection(identifier); + }); + this.activeSelectionIdentifiers = []; + event.preventDefault(); + return; + } + } + + if (event.key === "ArrowLeft" && !event.shiftKey) { + this._activeResultIdentifier = null; + this.activeSelectionIdentifiers = [ + this.#getPreviousSelection()?.identifier, + ].filter(Boolean); + event.preventDefault(); + return; + } + + if (event.key === "ArrowRight" && !event.shiftKey) { + this._activeResultIdentifier = null; + this.activeSelectionIdentifiers = [ + this.#getNextSelection()?.identifier, + ].filter(Boolean); + event.preventDefault(); + return; + } + } + + @action + replaceActiveSelection(selection) { + this.activeSelection.clear(); + this.activeSelection.push(selection.identifier); + } + + @action + handleInput(event) { + discourseDebounce(this, this.onFilter, event.target.value, INPUT_DELAY); + } + + @action + toggleSelection(identifier, options = {}) { + if (this.selectionIdentifiers.includes(identifier)) { + this.removeSelection(identifier, options); + } else { + this.addSelection(identifier, options); + } + + this.focusInput(); + } + + @action + handleRowClick(identifier, event) { + this.toggleSelection(identifier, { + altSelection: event.shiftKey || event.ctrlKey, + }); + event.preventDefault(); + } + + @action + removeSelection(identifier) { + this.selection = this.selection.filter( + (selection) => selection.identifier !== identifier + ); + + this.#handleSelectionChange(); + } + + @action + addSelection(identifier, options = {}) { + let selection = this.searchRequest.value.findBy("identifier", identifier); + + if (!selection || !selection.enabled) { + return; + } + + if (selection.type === CHANNEL_TYPE && !selection.isSingleUserChannel) { + this.openChannel([selection]); + return; + } + + if ( + !this.hasSelectedUsers && + !options.altSelection && + !this.site.mobileView + ) { + this.openChannel([selection]); + return; + } + + if (selection.isSingleUserChannel) { + const user = selection.model.chatable.users[0]; + selection = new ChatChatable({ + identifier: `u-${user.id}`, + type: USER_TYPE, + model: user, + }); + } + + this.selection = [ + ...this.selection.filter((s) => s.type !== CHANNEL_TYPE), + selection, + ]; + this.#handleSelectionChange(); + } + + @action + openChannel(selection) { + if (selection.length === 1 && selection[0].type === CHANNEL_TYPE) { + const channel = selection[0].model; + this.router.transitionTo("chat.channel", ...channel.routeModels); + this.args.onClose?.(); + return; + } + + const users = selection.filterBy("type", USER_TYPE).mapBy("model"); + this.chat + .upsertDmChannelForUsernames(users.mapBy("username")) + .then((channel) => { + this.router.transitionTo("chat.channel", ...channel.routeModels); + this.args.onClose?.(); + }); + } + + #handleSelectionChange() { + this.query = ""; + this.activeSelectionIdentifiers = []; + this._activeResultIdentifier = null; + } + + #getPreviousSelection() { + return this.#getPrevious( + this.selection, + this.activeSelectionIdentifiers?.[0] + ); + } + + #getNextSelection() { + return this.#getNext(this.selection, this.activeSelectionIdentifiers?.[0]); + } + + #getLastSelection() { + return this.selection[this.selection.length - 1]; + } + + #getPreviousResult() { + return this.#getPrevious( + this.searchRequest.value, + this.activeResultIdentifier + ); + } + + #getNextResult() { + return this.#getNext(this.searchRequest.value, this.activeResultIdentifier); + } + + #getNext(list, currentIdentifier = null) { + if (list.length === 0) { + return null; + } + + list = list.filterBy("enabled"); + + if (currentIdentifier) { + const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier); + + if (currentIndex < list.length - 1) { + return list.objectAt(currentIndex + 1); + } else { + return list[0]; + } + } else { + return list[0]; + } + } + + #getPrevious(list, currentIdentifier = null) { + if (list.length === 0) { + return null; + } + + list = list.filterBy("enabled"); + + if (currentIdentifier) { + const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier); + + if (currentIndex > 0) { + return list.objectAt(currentIndex - 1); + } else { + return list.objectAt(list.length - 1); + } + } else { + return list.objectAt(list.length - 1); + } + } + + #getDigit(input) { + if (typeof input === "string") { + const match = input.match(/Digit(\d+)/); + if (match) { + return parseInt(match[1], 10); + } + } + return false; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs new file mode 100644 index 00000000000..ac4f7767f7a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs @@ -0,0 +1,11 @@ + + +{{#if (gt @content.tracking.unreadCount 0)}} +
    +{{/if}} + +{{#if this.site.desktopView}} + {{this.openChannelLabel}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js new file mode 100644 index 00000000000..52869e4cf1e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; + +export default class ChatMessageCreatorChannelRow extends Component { + @service site; + + get openChannelLabel() { + return htmlSafe(I18n.t("chat.new_message_modal.open_channel")); + } + + get isUrgent() { + return ( + this.args.content.model.isDirectMessageChannel || + (this.args.content.model.isCategoryChannel && + this.args.content.model.tracking.mentionCount > 0) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs new file mode 100644 index 00000000000..91785854dba --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs @@ -0,0 +1,36 @@ + + + +{{#if (gt @content.tracking.unreadCount 0)}} +
    +{{/if}} + +{{user-status @content.model currentUser=this.currentUser}} + +{{#unless @content.enabled}} + + {{i18n "chat.new_message_modal.disabled_user"}} + +{{/unless}} + +{{#if @selected}} + {{#if this.site.mobileView}} + + {{d-icon "check"}} + + {{else}} + + {{d-icon (if @active "times" "check")}} + + {{/if}} +{{else}} + {{#if this.site.desktopView}} + {{#if @hasSelectedUsers}} + {{this.addUserLabel}} + {{else}} + {{this.openChannelLabel}} + {{/if}} + {{/if}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js new file mode 100644 index 00000000000..4ae13f5cafe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js @@ -0,0 +1,17 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; + +export default class ChatMessageCreatorUserRow extends Component { + @service currentUser; + @service site; + + get openChannelLabel() { + return htmlSafe(I18n.t("chat.new_message_modal.open_channel")); + } + + get addUserLabel() { + return htmlSafe(I18n.t("chat.new_message_modal.add_user_short")); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs new file mode 100644 index 00000000000..a5bf850b37e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs @@ -0,0 +1,5 @@ + + + + {{@selection.model.username}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/avatar.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message/avatar.hbs new file mode 100644 index 00000000000..c393efa893d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/avatar.hbs @@ -0,0 +1,7 @@ +
    + {{#if @message.chatWebhookEvent.emoji}} + + {{else}} + + {{/if}} +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/error.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message/error.hbs new file mode 100644 index 00000000000..dc9ca63b24a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/error.hbs @@ -0,0 +1,20 @@ +{{#if @message.error}} +
    + {{#if (eq @message.error "network_error")}} + + + {{i18n "chat.retry_staged_message.title"}} + + + {{i18n "chat.retry_staged_message.action"}} + + + {{else}} + {{@message.error}} + {{/if}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/info.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message/info.hbs new file mode 100644 index 00000000000..3ccf1f6adb5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/info.hbs @@ -0,0 +1,63 @@ +{{#if @show}} +
    + {{#if @message.chatWebhookEvent}} + {{#if @message.chatWebhookEvent.username}} + + {{@message.chatWebhookEvent.username}} + + {{/if}} + + + {{i18n "chat.bot"}} + + {{else}} + + {{this.name}} + {{#if this.showStatus}} +
    + +
    + {{/if}} +
    + {{/if}} + + + {{format-chat-date @message}} + + + {{#if @message.bookmark}} + + + + {{/if}} + + {{#if this.isFlagged}} + + {{#if @message.reviewableId}} + + {{d-icon "flag" title="chat.flagged"}} + + {{else}} + {{d-icon "flag" title="chat.you_flagged"}} + {{/if}} + + {{/if}} +
    +{{/if}} \ No newline at end of file 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 00000000000..e42be62a896 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/info.js @@ -0,0 +1,72 @@ +import Component from "@glimmer/component"; +import { prioritizeNameInUx } from "discourse/lib/settings"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatMessageInfo extends Component { + @service siteSettings; + + @bind + trackStatus() { + this.#user?.trackStatus?.(); + } + + @bind + stopTrackingStatus() { + this.#user?.stopTrackingStatus?.(); + } + + get usernameClasses() { + const user = this.#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.new_user) { + classes.push("is-new-user"); + } + if (user.primary_group_name) { + classes.push("group--" + user.primary_group_name); + } + return classes.join(" "); + } + + get name() { + return this.prioritizeName + ? this.#user?.get("name") + : this.#user?.get("username"); + } + + get isFlagged() { + return this.#message?.reviewableId || this.#message?.userFlagStatus === 0; + } + + get prioritizeName() { + return ( + this.siteSettings.display_name_on_posts && + prioritizeNameInUx(this.#user?.get("name")) + ); + } + + get showStatus() { + return !!this.#user?.get("status"); + } + + get #user() { + return this.#message?.user; + } + + get #message() { + return this.args.message; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.hbs similarity index 72% rename from plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs rename to plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.hbs index 68ce6be6d6a..d83658b4b1f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.hbs @@ -1,19 +1,19 @@
    - {{#if @message.reviewable_id}} + {{#if @message.reviewableId}} {{d-icon "flag" title="chat.flagged"}} - {{else if (eq @message.user_flag_status 0)}} + {{else if (eq @message.userFlagStatus 0)}}
    {{d-icon "flag" title="chat.you_flagged"}}
    - {{else}} + {{else if this.site.desktopView}} - {{format-chat-date @message @details "tiny"}} + {{format-chat-date @message "tiny"}} {{/if}} {{#if @message.bookmark}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.js b/plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.js new file mode 100644 index 00000000000..b60adf92b89 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/left-gutter.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageLeftGutter extends Component { + @service site; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.hbs new file mode 100644 index 00000000000..705e809fb29 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.hbs @@ -0,0 +1,56 @@ +{{#if this.shouldRender}} +
    + {{#if this.mentionWarning.invitationSent}} + + {{d-icon "check"}} + + {{i18n + "chat.mention_warning.invitations_sent" + count=this.mentionWarning.withoutMembership.length + }} + + + {{else}} + + + {{#if this.mentionWarning.cannotSee}} +

    + {{this.mentionedCannotSeeText}} +

    + {{/if}} + + {{#if this.mentionWarning.withoutMembership}} +

    + {{this.mentionedWithoutMembershipText}} + + {{i18n "chat.mention_warning.invite"}} + +

    + {{/if}} + + {{#if this.mentionWarning.groupWithMentionsDisabled}} +

    + {{this.groupsWithDisabledMentions}} +

    + {{/if}} + + {{#if this.mentionWarning.groupsWithTooManyMembers}} +

    + {{this.groupsWithTooManyMembers}} +

    + {{/if}} + {{/if}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.js b/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.js new file mode 100644 index 00000000000..ae2849da2c4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message/mention-warning.js @@ -0,0 +1,98 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; + +export default class ChatMessageMentionWarning extends Component { + @service("chat-api") api; + + @action + async onSendInvite() { + const userIds = this.mentionWarning.withoutMembership.mapBy("id"); + + try { + await this.api.invite(this.args.message.channel.id, userIds, { + messageId: this.args.message.id, + }); + + this.mentionWarning.invitationSent = true; + } catch (error) { + popupAjaxError(error); + } + } + + @action + onDismissInvitationSent() { + this.mentionWarning.invitationSent = false; + } + + @action + onDismissMentionWarning() { + this.args.message.mentionWarning = null; + } + + get shouldRender() { + return ( + this.mentionWarning && + (this.mentionWarning.groupWithMentionsDisabled?.length || + this.mentionWarning.cannotSee?.length || + this.mentionWarning.withoutMembership?.length || + this.mentionWarning.groupsWithTooManyMembers?.length) + ); + } + + get mentionWarning() { + return this.args.message.mentionWarning; + } + + get mentionedCannotSeeText() { + return this.#findTranslatedWarning( + "chat.mention_warning.cannot_see", + "chat.mention_warning.cannot_see_multiple", + { + username: this.mentionWarning?.cannotSee?.[0]?.username, + count: this.mentionWarning?.cannotSee?.length, + } + ); + } + + get mentionedWithoutMembershipText() { + return this.#findTranslatedWarning( + "chat.mention_warning.without_membership", + "chat.mention_warning.without_membership_multiple", + { + username: this.mentionWarning?.withoutMembership?.[0]?.username, + count: this.mentionWarning?.withoutMembership?.length, + } + ); + } + + get groupsWithDisabledMentions() { + return this.#findTranslatedWarning( + "chat.mention_warning.group_mentions_disabled", + "chat.mention_warning.group_mentions_disabled_multiple", + { + group_name: this.mentionWarning?.groupWithMentionsDisabled?.[0], + count: this.mentionWarning?.groupWithMentionsDisabled?.length, + } + ); + } + + get groupsWithTooManyMembers() { + return this.#findTranslatedWarning( + "chat.mention_warning.too_many_members", + "chat.mention_warning.too_many_members_multiple", + { + group_name: this.mentionWarning.groupsWithTooManyMembers?.[0], + count: this.mentionWarning.groupsWithTooManyMembers?.length, + } + ); + } + + #findTranslatedWarning(oneKey, multipleKey, args) { + const translationKey = args.count === 1 ? oneKey : multipleKey; + args.count--; + return I18n.t(translationKey, args); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.hbs new file mode 100644 index 00000000000..e73ec9fc469 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.hbs @@ -0,0 +1,32 @@ + + <:body> +

    + {{this.instructionsText}} +

    + + + <:footer> + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.js new file mode 100644 index 00000000000..f8f5b11b4e6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/archive-channel.js @@ -0,0 +1,112 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; +import discourseLater from "discourse-common/lib/later"; +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 { isEmpty } from "@ember/utils"; + +export default class ChatModalArchiveChannel extends Component { + @service chatApi; + @service siteSettings; + + @tracked selection = NEW_TOPIC_SELECTION; + @tracked saving = false; + @tracked topicTitle = null; + @tracked categoryId = null; + @tracked tags = null; + @tracked selectedTopicId = null; + @tracked flash; + @tracked flashType; + + get channel() { + return this.args.model.channel; + } + + get newTopic() { + return this.selection === NEW_TOPIC_SELECTION; + } + + get existingTopic() { + return this.selection === EXISTING_TOPIC_SELECTION; + } + + get buttonDisabled() { + if (this.saving) { + return true; + } + + if ( + this.newTopic && + (!this.topicTitle || + this.topicTitle.length < this.siteSettings.min_topic_title_length || + this.topicTitle.length > this.siteSettings.max_topic_title_length) + ) { + return true; + } + + if (this.existingTopic && isEmpty(this.selectedTopicId)) { + return true; + } + + return false; + } + + get 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; + } + + get instructionsText() { + return htmlSafe( + I18n.t("chat.channel_archive.instructions", { + channelTitle: this.channel.escapedTitle, + }) + ); + } + + @action + archiveChannel() { + this.saving = true; + + return this.chatApi + .createChannelArchive(this.channel.id, this.#data()) + .then(() => { + this.flash = I18n.t("chat.channel_archive.process_started"); + this.flashType = "success"; + this.channel.status = CHANNEL_STATUSES.archived; + + discourseLater(() => { + this.args.closeModal(); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => (this.saving = false)); + } + + #data() { + const data = { type: this.selection }; + 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; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.hbs new file mode 100644 index 00000000000..0e16015ae21 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.hbs @@ -0,0 +1,22 @@ + + <:body> + {{i18n "chat.summarization.description"}} + + +

    {{this.summary}}

    +
    + + <:footer> + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js new file mode 100644 index 00000000000..affee4658af --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js @@ -0,0 +1,48 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; + +export default class ChatModalChannelSummary extends Component { + @service chatApi; + + @tracked sinceHours = null; + @tracked loading = false; + @tracked summary = null; + + availableSummaries = {}; + + sinceOptions = [1, 3, 6, 12, 24, 72, 168].map((hours) => { + return { + name: I18n.t("chat.summarization.since", { count: hours }), + value: hours, + }; + }); + + get channelId() { + return this.args.model.channelId; + } + + @action + summarize(since) { + this.sinceHours = since; + this.loading = true; + + if (this.availableSummaries[since]) { + this.summary = this.availableSummaries[since]; + this.loading = false; + return; + } + + return this.chatApi + .summarize(this.channelId, { since }) + .then((data) => { + this.availableSummaries[this.sinceHours] = data.summary; + this.summary = this.availableSummaries[this.sinceHours]; + }) + .catch(popupAjaxError) + .finally(() => (this.loading = false)); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.hbs new file mode 100644 index 00000000000..cd30124048e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.hbs @@ -0,0 +1,134 @@ + + <:body> +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + + {{#if this.categoryPermissionsHint}} +
    + {{this.categoryPermissionsHint}} +
    + {{/if}} +
    + + {{#if this.autoJoinAvailable}} +
    + +
    + {{/if}} + +
    + +
    + + <:footer> + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.js new file mode 100644 index 00000000000..f615a4eb5ba --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/create-channel.js @@ -0,0 +1,264 @@ +import { escapeExpression } from "discourse/lib/utilities"; +import { ajax } from "discourse/lib/ajax"; +import { cancel } from "@ember/runloop"; +import discourseDebounce from "discourse-common/lib/debounce"; +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { isBlank, isPresent } from "@ember/utils"; +import { htmlSafe } from "@ember/template"; +import { tracked } from "@glimmer/tracking"; + +const DEFAULT_HINT = htmlSafe( + I18n.t("chat.create_channel.choose_category.default_hint", { + link: "/categories", + category: "category", + }) +); + +export default class ChatModalCreateChannel extends Component { + @service chat; + @service dialog; + @service chatChannelsManager; + @service chatApi; + @service router; + @service currentUser; + @service siteSettings; + @service site; + + @tracked flash; + @tracked name; + @tracked category; + @tracked categoryId; + @tracked autoGeneratedSlug = ""; + @tracked categoryPermissionsHint; + @tracked autoJoinWarning = ""; + @tracked loadingPermissionHint = false; + + #generateSlugHandler = null; + + willDestroy() { + cancel(this.#generateSlugHandler); + } + + get autoJoinAvailable() { + return this.siteSettings.max_chat_auto_joined_users > 0; + } + + get categorySelected() { + return isPresent(this.category); + } + + get createDisabled() { + return !this.categorySelected || isBlank(this.name); + } + + get categoryName() { + return this.categorySelected && isPresent(this.name) + ? escapeExpression(this.name) + : null; + } + + @action + onShow() { + this.categoryPermissionsHint = DEFAULT_HINT; + } + + @action + onCategoryChange(categoryId) { + const category = categoryId + ? this.site.categories.findBy("id", categoryId) + : null; + this.#updatePermissionsHint(category); + + const name = this.name || category?.name || ""; + this.categoryId = categoryId; + this.category = category; + this.name = name; + this.#debouncedGenerateSlug(name); + } + + @action + onNameChange(name) { + this.#debouncedGenerateSlug(name); + } + + @action + onSave(event) { + event.preventDefault(); + + if (this.createDisabled) { + return; + } + + const formData = new FormData(event.currentTarget); + const data = Object.fromEntries(formData.entries()); + data.auto_join_users = data.auto_join_users === "on"; + data.slug ??= this.autoGeneratedSlug; + data.threading_enabled = data.threading_enabled === "on"; + + if (data.auto_join_users) { + this.dialog.yesNoConfirm({ + message: this.autoJoinWarning, + didConfirm: () => this.#createChannel(data), + }); + } else { + this.#createChannel(data); + } + } + + #createChannel(data) { + return this.chatApi + .createChannel(data) + .then((channel) => { + this.args.closeModal(); + this.chatChannelsManager.follow(channel); + this.router.transitionTo("chat.channel", ...channel.routeModels); + }) + .catch((e) => { + this.flash = e.jqXHR.responseJSON.errors[0]; + }); + } + + #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; + let warning; + + if (catPermissions.private) { + switch (allowedGroups.length) { + case 1: + warning = I18n.t( + "chat.create_channel.auto_join_users.warning_1_group", + { + count: catPermissions.members_count, + group: escapeExpression(allowedGroups[0]), + } + ); + break; + case 2: + warning = I18n.t( + "chat.create_channel.auto_join_users.warning_2_groups", + { + count: catPermissions.members_count, + group1: escapeExpression(allowedGroups[0]), + group2: escapeExpression(allowedGroups[1]), + } + ); + break; + default: + warning = I18n.messageFormat( + "chat.create_channel.auto_join_users.warning_multiple_groups_MF", + { + groupCount: allowedGroups.length - 1, + userCount: catPermissions.members_count, + groupName: escapeExpression(allowedGroups[0]), + } + ); + break; + } + } else { + warning = I18n.t( + "chat.create_channel.auto_join_users.public_category_warning", + { + category: escapeExpression(category.name), + } + ); + } + + this.autoJoinWarning = warning; + } + + #updatePermissionsHint(category) { + if (category) { + const fullSlug = this.#buildCategorySlug(category); + + this.loadingPermissionHint = true; + + return this.chatApi + .categoryPermissions(category.id) + .then((catPermissions) => { + this.#updateAutoJoinConfirmWarning(category, catPermissions); + const allowedGroups = catPermissions.allowed_groups; + const settingLink = `/c/${escapeExpression(fullSlug)}/edit/security`; + let hint; + + switch (allowedGroups.length) { + case 1: + hint = I18n.t( + "chat.create_channel.choose_category.hint_1_group", + { + settingLink, + group: escapeExpression(allowedGroups[0]), + } + ); + break; + case 2: + hint = I18n.t( + "chat.create_channel.choose_category.hint_2_groups", + { + settingLink, + group1: escapeExpression(allowedGroups[0]), + group2: escapeExpression(allowedGroups[1]), + } + ); + break; + default: + hint = I18n.t( + "chat.create_channel.choose_category.hint_multiple_groups", + { + settingLink, + group: escapeExpression(allowedGroups[0]), + count: allowedGroups.length - 1, + } + ); + break; + } + + this.categoryPermissionsHint = htmlSafe(hint); + }) + .finally(() => { + this.loadingPermissionHint = false; + }); + } else { + this.categoryPermissionsHint = DEFAULT_HINT; + this.autoJoinWarning = ""; + } + } + + // intentionally not showing AJAX error for this, we will autogenerate + // the slug server-side if they leave it blank + #generateSlug(name) { + return ajax("/slugs.json", { type: "POST", data: { name } }).then( + (response) => { + this.autoGeneratedSlug = response.slug; + } + ); + } + + #debouncedGenerateSlug(name) { + cancel(this.#generateSlugHandler); + this.autoGeneratedSlug = ""; + + if (!name) { + return; + } + + this.#generateSlugHandler = discourseDebounce( + this, + this.#generateSlug, + name, + 300 + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.hbs new file mode 100644 index 00000000000..3e7c026a977 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.hbs @@ -0,0 +1,32 @@ + + <:body> +

    + {{this.instructionsText}} +

    + + + + <:footer> + + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.js new file mode 100644 index 00000000000..affb248edc6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/delete-channel.js @@ -0,0 +1,68 @@ +import Component from "@glimmer/component"; +import { isEmpty } from "@ember/utils"; +import I18n from "I18n"; +import { action } from "@ember/object"; +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"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatModalDeleteChannel extends Component { + @service chatApi; + @service router; + + @tracked channelNameConfirmation; + @tracked deleting = false; + @tracked confirmed = false; + @tracked flash; + @tracked flashType; + + get channel() { + return this.args.model.channel; + } + + get buttonDisabled() { + if (this.deleting || this.confirmed) { + return true; + } + + if ( + isEmpty(this.channelNameConfirmation) || + this.channelNameConfirmation.toLowerCase() !== + this.channel.title.toLowerCase() + ) { + return true; + } + + return false; + } + + get instructionsText() { + return htmlSafe( + I18n.t("chat.channel_delete.instructions", { + name: this.channel.escapedTitle, + }) + ); + } + + @action + deleteChannel() { + this.deleting = true; + + return this.chatApi + .destroyChannel(this.channel.id, this.channelNameConfirmation) + .then(() => { + this.confirmed = true; + this.flash = I18n.t("chat.channel_delete.process_started"); + this.flashType = "success"; + + discourseLater(() => { + this.args.closeModal(); + this.router.transitionTo("chat"); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => (this.deleting = false)); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.hbs new file mode 100644 index 00000000000..3a21233edbd --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.hbs @@ -0,0 +1,37 @@ + + <:body> + {{i18n + "chat.channel_edit_description_modal.description" + }} + + + + + <:footer> + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.js new file mode 100644 index 00000000000..2336c6cc040 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-description.js @@ -0,0 +1,49 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; + +const DESCRIPTION_MAX_LENGTH = 280; + +export default class ChatModalEditChannelDescription extends Component { + @service chatApi; + + @tracked editedDescription = this.channel.description || ""; + @tracked flash; + + get channel() { + return this.args.model; + } + + get isSaveDisabled() { + return ( + this.channel.description === this.editedDescription || + this.editedDescription?.length > DESCRIPTION_MAX_LENGTH + ); + } + + get descriptionMaxLength() { + return DESCRIPTION_MAX_LENGTH; + } + + @action + onSaveChatChannelDescription() { + return this.chatApi + .updateChannel(this.channel.id, { description: this.editedDescription }) + .then((result) => { + this.channel.description = result.channel.description; + this.args.closeModal(); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash = event.jqXHR.responseJSON.errors.join("\n"); + } + }); + } + + @action + onChangeChatChannelDescription(description) { + this.flash = null; + this.editedDescription = description; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.hbs new file mode 100644 index 00000000000..b4d8ee1df1c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.hbs @@ -0,0 +1,54 @@ + + <:body> +
    + + +
    + +
    + + +
    + + <:footer> + + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.js new file mode 100644 index 00000000000..433d6ac0ea6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/edit-channel-name.js @@ -0,0 +1,84 @@ +import Component from "@glimmer/component"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { ajax } from "discourse/lib/ajax"; +import { cancel } from "@ember/runloop"; +import { action } from "@ember/object"; +import { extractError } from "discourse/lib/ajax-error"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; + +export default class ChatModalEditChannelName extends Component { + @service chatApi; + @service siteSettings; + + @tracked editedName = this.channel.title; + @tracked editedSlug = this.channel.slug; + @tracked autoGeneratedSlug = ""; + @tracked flash; + + #generateSlugHandler = null; + + get channel() { + return this.args.model; + } + + get isSaveDisabled() { + return ( + (this.channel.title === this.editedName && + this.channel.slug === this.editedSlug) || + this.editedName?.length > this.siteSettings.max_topic_title_length + ); + } + + @action + onSave() { + return this.chatApi + .updateChannel(this.channel.id, { + name: this.editedName, + slug: this.editedSlug || this.autoGeneratedSlug || this.channel.slug, + }) + .then((result) => { + this.channel.title = result.channel.title; + this.args.closeModal(); + }) + .catch((error) => (this.flash = extractError(error))); + } + + @action + onChangeChatChannelName(title) { + this.flash = null; + this.#debouncedGenerateSlug(title); + } + + @action + onChangeChatChannelSlug() { + this.flash = null; + this.#debouncedGenerateSlug(this.editedName); + } + + #debouncedGenerateSlug(name) { + cancel(this.#generateSlugHandler); + this.autoGeneratedSlug = ""; + + if (!name) { + return; + } + + this.#generateSlugHandler = discourseDebounce( + this, + this.#generateSlug, + name, + 300 + ); + } + + // intentionally not showing AJAX error for this, we will autogenerate + // the slug server-side if they leave it blank + #generateSlug(name) { + return ajax("/slugs.json", { type: "POST", data: { name } }).then( + (response) => { + this.autoGeneratedSlug = response.slug; + } + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/move-message-to-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/move-message-to-channel.hbs new file mode 100644 index 00000000000..2d09a13ba89 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/move-message-to-channel.hbs @@ -0,0 +1,29 @@ + + <:body> + {{#if this.selectedMessageCount}} +

    {{this.instructionsText}}

    + {{/if}} + + + + <:footer> + + + +
    \ No newline at end of file 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/modal/move-message-to-channel.js similarity index 58% rename from plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js rename to plugins/chat/assets/javascripts/discourse/components/chat/modal/move-message-to-channel.js index 7d404060d25..071a59a00f6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/move-message-to-channel.js @@ -1,34 +1,49 @@ -import Component from "@ember/component"; -import I18n from "I18n"; -import { reads } from "@ember/object/computed"; +import Component from "@glimmer/component"; import { isBlank } from "@ember/utils"; -import { action, computed } from "@ember/object"; +import { action } from "@ember/object"; import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; -export default class MoveToChannelModalInner extends Component { +export default class ChatModalMoveMessageToChannel extends Component { @service chat; @service chatApi; @service router; @service chatChannelsManager; - tagName = ""; - sourceChannel = null; - destinationChannelId = null; - selectedMessageIds = null; - @reads("selectedMessageIds.length") selectedMessageCount; + @tracked destinationChannelId; + + get sourceChannel() { + return this.args.model.sourceChannel; + } + + get selectedMessageIds() { + return this.args.model.selectedMessageIds; + } + + get selectedMessageCount() { + return this.selectedMessageIds?.length; + } - @computed("destinationChannelId") get disableMoveButton() { return isBlank(this.destinationChannelId); } - @computed("chatChannelsManager.publicMessageChannels.[]") get availableChannels() { - return this.chatChannelsManager.publicMessageChannels.rejectBy( - "id", - this.sourceChannel.id + return ( + this.args.model.availableChannels || + this.chatChannelsManager.publicMessageChannels + ).rejectBy("id", this.sourceChannel.id); + } + + get instructionsText() { + return htmlSafe( + I18n.t("chat.move_to_channel.instructions", { + channelTitle: this.sourceChannel.escapedTitle, + count: this.selectedMessageCount, + }) ); } @@ -40,21 +55,14 @@ export default class MoveToChannelModalInner extends Component { destination_channel_id: this.destinationChannelId, }) .then((response) => { - return this.chat.openChannelAtMessage( + this.args.closeModal(); + this.router.transitionTo( + "chat.channel.near-message", + "-", response.destination_channel_id, 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/modal/new-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/new-message.hbs new file mode 100644 index 00000000000..883cf9903b2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/new-message.hbs @@ -0,0 +1,10 @@ +{{#if this.shouldRender}} + + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/new-message.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/new-message.js new file mode 100644 index 00000000000..14bf3e405e0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/new-message.js @@ -0,0 +1,14 @@ +import Component from "@glimmer/component"; + +import { inject as service } from "@ember/service"; + +export default class ChatModalNewMessage extends Component { + @service chat; + @service siteSettings; + + get shouldRender() { + return ( + this.siteSettings.enable_public_channels || this.chat.userCanDirectMessage + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.hbs new file mode 100644 index 00000000000..348bf2ccd77 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.hbs @@ -0,0 +1,26 @@ + + <:body> + + + + <:footer> + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.js new file mode 100644 index 00000000000..6871c195444 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.js @@ -0,0 +1,38 @@ +import Component from "@glimmer/component"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatModalThreadSettings extends Component { + @service chatApi; + + @tracked editedTitle = this.thread.title || ""; + @tracked saving = false; + + get buttonDisabled() { + return this.saving; + } + + get thread() { + return this.args.model; + } + + @action + saveThread() { + this.saving = true; + + this.chatApi + .editThread(this.thread.channel.id, this.thread.id, { + title: this.editedTitle, + }) + .then(() => { + this.thread.title = this.editedTitle; + this.args.closeModal(); + }) + .catch(popupAjaxError) + .finally(() => { + this.saving = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.hbs new file mode 100644 index 00000000000..85e35103ef5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.hbs @@ -0,0 +1,20 @@ + + <:body> +

    {{this.instructions}}

    + + <:footer> + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.js similarity index 65% rename from plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js rename to plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.js index 5ed0b2dbdf5..d801aa67fe5 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/toggle-channel-status.js @@ -1,40 +1,37 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; +import { action } from "@ember/object"; 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 { inject as service } from "@ember/service"; import { popupAjaxError } from "discourse/lib/ajax-error"; -export default class ChatChannelToggleView extends Component { - @service chat; +export default class ChatModalToggleChannelStatus extends Component { @service chatApi; @service router; - tagName = ""; - channel = null; - onStatusChange = null; - @computed("channel.isClosed") + get channel() { + return this.args.model; + } + get buttonLabel() { - if (this.channel.isClosed) { + 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) { + 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) { + if (this.channel?.isClosed) { return "chat.channel_open.title"; } else { return "chat.channel_close.title"; @@ -42,15 +39,16 @@ export default class ChatChannelToggleView extends Component { } @action - changeChannelStatus() { + onStatusChange() { const status = this.channel.isClosed ? CHANNEL_STATUSES.open : CHANNEL_STATUSES.closed; return this.chatApi .updateChannelStatus(this.channel.id, status) - .finally(() => { - this.onStatusChange?.(this.channel); + .then(() => { + this.args.closeModal(); + this.router.transitionTo("chat.channel", ...this.channel.routeModels); }) .catch(popupAjaxError); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs new file mode 100644 index 00000000000..93d5f270a2d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/scroll-to-bottom-arrow.hbs @@ -0,0 +1,14 @@ +
    + + + {{d-icon "arrow-down"}} + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/selection-manager.hbs similarity index 54% rename from plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.hbs rename to plugins/chat/assets/javascripts/discourse/components/chat/selection-manager.hbs index 66b616894ba..7468f8b4ada 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/selection-manager.hbs @@ -1,36 +1,33 @@ -
    -
    +
    +
    {{#if this.site.desktopView}} {{/if}} - {{#if this.showMoveMessageButton}} + {{#if this.enableMove}} {{/if}} @@ -39,14 +36,16 @@ @icon="times" @class="btn-secondary cancel-btn" @label="chat.selection.cancel" - @title="chat.selection.cancel" - @action={{this.cancelSelecting}} + @action={{@pane.cancelSelecting}} />
    - {{#if this.showChatQuoteSuccess}} -
    + {{#if this.showCopySuccess}} + {{i18n "chat.quote.copy_success"}} -
    + {{/if}}
    \ No newline at end of file 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 00000000000..9614e7b6d13 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/selection-manager.js @@ -0,0 +1,113 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { clipboardCopyAsync } from "discourse/lib/utilities"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { isTesting } from "discourse-common/config/environment"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; +import ChatModalMoveMessageToChannel from "discourse/plugins/chat/discourse/components/chat/modal/move-message-to-channel"; + +export default class ChatSelectionManager extends Component { + @service("composer") topicComposer; + @service router; + @service modal; + @service site; + @service("chat-api") api; + + // NOTE: showCopySuccess is used to display the message which animates + // after a delay. The on-animation-end helper is not really usable in + // system specs because it fires straight away, so we use lastCopySuccessful + // with a data attr instead so it's not instantly mutated. + @tracked showCopySuccess = false; + @tracked lastCopySuccessful = false; + + get enableMove() { + return this.args.enableMove ?? false; + } + + get anyMessagesSelected() { + return this.args.pane.selectedMessageIds.length > 0; + } + + @bind + async generateQuote() { + const { markdown } = await this.api.generateQuote( + this.args.pane.channel.id, + this.args.pane.selectedMessageIds + ); + + return new Blob([markdown], { type: "text/plain" }); + } + + @action + openMoveMessageModal() { + this.modal.show(ChatModalMoveMessageToChannel, { + model: { + sourceChannel: this.args.pane.channel, + selectedMessageIds: this.args.pane.selectedMessageIds, + }, + }); + } + + @action + async quoteMessages() { + let quoteMarkdown; + + try { + const quoteMarkdownBlob = await this.generateQuote(); + quoteMarkdown = await quoteMarkdownBlob.text(); + } catch (error) { + popupAjaxError(error); + } + + const openOpts = {}; + if (this.args.pane.channel.isCategoryChannel) { + openOpts.categoryId = this.args.pane.channel.chatableId; + } + + if (this.site.mobileView) { + // go to the relevant chatable (e.g. category) and open the + // composer to insert text + if (this.args.pane.channel.chatableUrl) { + this.router.transitionTo(this.args.pane.channel.chatableUrl); + } + + await this.topicComposer.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 container = getOwner(this); + const topic = container.lookup("controller:topic"); + await this.topicComposer.focusComposer({ + fallbackToNewTopic: true, + topic: topic?.model, + insertText: quoteMarkdown, + openOpts, + }); + } + } + + @action + async copyMessages() { + try { + this.lastCopySuccessful = false; + this.showCopySuccess = false; + + if (!isTesting()) { + // clipboard API throws errors in tests + await clipboardCopyAsync(this.generateQuote); + } + + this.showCopySuccess = true; + this.lastCopySuccessful = true; + } catch (error) { + popupAjaxError(error); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.hbs new file mode 100644 index 00000000000..ed91b635bc3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.hbs @@ -0,0 +1,38 @@ +{{#if this.shouldRender}} +
    + {{#if @includeHeader}} + + {{/if}} + +
    + {{#each this.sortedThreads as |thread|}} + + {{else}} + {{#if this.threadsCollection.fetchedOnce}} +
    + {{i18n "chat.threads.none"}} +
    + {{/if}} + {{/each}} + +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.js new file mode 100644 index 00000000000..c3d572d5560 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list.js @@ -0,0 +1,155 @@ +import Component from "@glimmer/component"; +import { bind } from "discourse-common/utils/decorators"; +import { cached } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadList extends Component { + @service chat; + @service chatApi; + @service messageBus; + @service chatTrackingStateManager; + + get threadsManager() { + return this.args.channel.threadsManager; + } + + // NOTE: This replicates sort logic from the server. We need this because + // the thread unread count + last reply date + time update when new messages + // are sent to the thread, and we want the list to react in realtime to this. + get sortedThreads() { + return this.threadsManager.threads + .filter( + (thread) => + thread.currentUserMembership && !thread.originalMessage.deletedAt + ) + .sort((threadA, threadB) => { + // If both are unread we just want to sort by last reply date + time descending. + if (threadA.tracking.unreadCount && threadB.tracking.unreadCount) { + if ( + threadA.preview.lastReplyCreatedAt > + threadB.preview.lastReplyCreatedAt + ) { + return -1; + } else { + return 1; + } + } + + // If one is unread and the other is not, we want to sort the unread one first. + if (threadA.tracking.unreadCount) { + return -1; + } + + if (threadB.tracking.unreadCount) { + return 1; + } + + // If both are read, we want to sort by last reply date + time descending. + if ( + threadA.preview.lastReplyCreatedAt > + threadB.preview.lastReplyCreatedAt + ) { + return -1; + } else { + return 1; + } + }); + } + + get shouldRender() { + return !!this.args.channel; + } + + @action + loadThreads() { + return this.threadsCollection.load({ limit: 10 }); + } + + @action + subscribe() { + this.#unsubscribe(); + + this.messageBus.subscribe( + `/chat/${this.args.channel.id}`, + this.onMessageBus, + this.args.channel.messageBusLastId + ); + } + + @bind + onMessageBus(busData) { + switch (busData.type) { + case "delete": + this.handleDeleteMessage(busData); + break; + case "restore": + this.handleRestoreMessage(busData); + break; + } + } + + handleDeleteMessage(data) { + const deletedOriginalMessageThread = this.threadsManager.threads.findBy( + "originalMessage.id", + data.deleted_id + ); + + if (!deletedOriginalMessageThread) { + return; + } + + deletedOriginalMessageThread.originalMessage.deletedAt = new Date(); + } + + handleRestoreMessage(data) { + const restoredOriginalMessageThread = this.threadsManager.threads.findBy( + "originalMessage.id", + data.chat_message.id + ); + + if (!restoredOriginalMessageThread) { + return; + } + + restoredOriginalMessageThread.originalMessage.deletedAt = null; + } + + @cached + get threadsCollection() { + return this.chatApi.threads(this.args.channel.id, this.handleLoadedThreads); + } + + @bind + handleLoadedThreads(result) { + return result.threads.map((thread) => { + const threadModel = this.threadsManager.add(this.args.channel, thread, { + replace: true, + }); + + this.chatTrackingStateManager.setupChannelThreadState( + this.args.channel, + result.tracking + ); + + return threadModel; + }); + } + + @action + teardown() { + this.#unsubscribe(); + } + + #unsubscribe() { + // TODO (joffrey) In drawer we won't have channel anymore at this point + if (!this.args.channel) { + return; + } + + this.messageBus.unsubscribe( + `/chat/${this.args.channel.id}`, + this.onMessageBus + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/header.hbs new file mode 100644 index 00000000000..9ddb96fed23 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/header.hbs @@ -0,0 +1,16 @@ +
    + + {{replace-emoji (i18n "chat.threads.list")}} + + +
    + + {{d-icon "times"}} + +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.hbs new file mode 100644 index 00000000000..09e5177fd37 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.hbs @@ -0,0 +1,43 @@ +
    +
    +
    +
    +
    + +
    +
    + {{replace-emoji this.title}} +
    +
    + +
    +
    + +
    + {{replace-emoji (html-safe @thread.originalMessage.excerpt)}} +
    + + +
    +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.js new file mode 100644 index 00000000000..0e504f71f9a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.js @@ -0,0 +1,16 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatThreadListItem extends Component { + @service router; + + get title() { + return this.args.thread.escapedTitle; + } + + @action + openThread(thread) { + this.router.transitionTo("chat.channel.thread", ...thread.routeModels); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.hbs new file mode 100644 index 00000000000..8f389b8db14 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.hbs @@ -0,0 +1,7 @@ +{{#if this.showUnreadIndicator}} +
    +
    + {{this.unreadCountLabel}} +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.js new file mode 100644 index 00000000000..7dce93ea2ed --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item/unread-indicator.js @@ -0,0 +1,15 @@ +import Component from "@glimmer/component"; + +export default class ChatThreadListItemUnreadIndicator extends Component { + get unreadCount() { + return this.args.thread.tracking.unreadCount; + } + + get showUnreadIndicator() { + return this.unreadCount > 0; + } + + get unreadCountLabel() { + return this.unreadCount > 99 ? "99+" : this.unreadCount; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.hbs new file mode 100644 index 00000000000..d29401a549c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.hbs @@ -0,0 +1,11 @@ +{{#if this.showUnreadIndicator}} +
    +
    {{this.unreadCountLabel}}
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.js new file mode 100644 index 00000000000..f81c0ca7755 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header-unread-indicator.js @@ -0,0 +1,22 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadHeaderUnreadIndicator extends Component { + @service currentUser; + + get currentUserInDnD() { + return this.currentUser.isInDoNotDisturb(); + } + + get unreadCount() { + return this.args.channel.threadsManager.unreadThreadCount; + } + + get showUnreadIndicator() { + return !this.currentUserInDnD && this.unreadCount > 0; + } + + get unreadCountLabel() { + return this.unreadCount > 99 ? "99+" : this.unreadCount; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.hbs new file mode 100644 index 00000000000..a4fd62890af --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.hbs @@ -0,0 +1,47 @@ +
    +
    + {{#if @thread}} + + + {{d-icon "chevron-left"}} + + {{/if}} +
    + + + {{replace-emoji this.label}} + + +
    + + {{#if this.canChangeThreadSettings}} + + {{/if}} + + {{d-icon "times"}} + +
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.js new file mode 100644 index 00000000000..4b863f04094 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.js @@ -0,0 +1,100 @@ +import Component from "@glimmer/component"; +import { NotificationLevels } from "discourse/lib/notification-levels"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership"; +import { tracked } from "@glimmer/tracking"; +import ChatModalThreadSettings from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings"; + +export default class ChatThreadHeader extends Component { + @service currentUser; + @service chatApi; + @service router; + @service chatStateManager; + @service chatHistory; + @service site; + @service modal; + + @tracked persistedNotificationLevel = true; + + get backLink() { + let route; + + if ( + this.chatHistory.previousRoute?.name === "chat.channel.index" && + this.site.mobileView + ) { + route = "chat.channel.index"; + } else { + route = "chat.channel.threads"; + } + + return { + route, + models: this.args.channel.routeModels, + }; + } + + get label() { + return this.args.thread.escapedTitle; + } + + get canChangeThreadSettings() { + if (!this.args.thread) { + return false; + } + + return ( + this.currentUser.staff || + this.currentUser.id === this.args.thread.originalMessage.user.id + ); + } + + get threadNotificationLevel() { + return this.membership?.notificationLevel || NotificationLevels.REGULAR; + } + + get membership() { + return this.args.thread.currentUserMembership; + } + + @action + openThreadSettings() { + this.modal.show(ChatModalThreadSettings, { model: this.args.thread }); + } + + @action + updateThreadNotificationLevel(newNotificationLevel) { + this.persistedNotificationLevel = false; + + let currentNotificationLevel; + + if (this.membership) { + currentNotificationLevel = this.membership.notificationLevel; + this.membership.notificationLevel = newNotificationLevel; + } else { + this.args.thread.currentUserMembership = UserChatThreadMembership.create({ + notification_level: newNotificationLevel, + last_read_message_id: null, + }); + } + + this.chatApi + .updateCurrentUserThreadNotificationsSettings( + this.args.thread.channel.id, + this.args.thread.id, + { notificationLevel: newNotificationLevel } + ) + .then((response) => { + this.membership.last_read_message_id = + response.membership.last_read_message_id; + + this.persistedNotificationLevel = true; + }) + .catch((err) => { + this.membership.notificationLevel = currentNotificationLevel; + popupAjaxError(err); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/participants.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/participants.hbs new file mode 100644 index 00000000000..e928326cf8b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/participants.hbs @@ -0,0 +1,21 @@ +{{#if (gt @thread.preview.participantUsers.length 1)}} +
    +
    + {{#each @thread.preview.participantUsers as |user|}} + + {{/each}} +
    + {{#if @thread.preview.otherParticipantCount}} +
    + {{i18n + "chat.thread.participants_other_count" + count=@thread.preview.otherParticipantCount + }} +
    + {{/if}} +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.hbs new file mode 100644 index 00000000000..c4b7263d1b3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.hbs @@ -0,0 +1,13 @@ + + {{d-icon "discourse-threads"}} + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.js b/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.js new file mode 100644 index 00000000000..c383e020f9d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/threads-list-button.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class ChatThreadsListButton extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/user-avatar.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/user-avatar.hbs new file mode 100644 index 00000000000..d943a4bc652 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/user-avatar.hbs @@ -0,0 +1,9 @@ +
    +
    + {{avatar @user imageSize=this.avatarSize}} +
    +
    \ No newline at end of file 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 00000000000..b4a616d78c9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/user-avatar.js @@ -0,0 +1,26 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatUserAvatar extends Component { + @service chat; + + get avatarSize() { + return this.args.avatarSize || "tiny"; + } + + get showPresence() { + return this.args.showPresence ?? true; + } + + get isOnline() { + const users = (this.args.chat || this.chat).presenceChannel?.users; + + return ( + this.showPresence && + !!users?.find( + ({ id, username }) => + this.args.user?.id === id || this.args.user?.username === username + ) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.hbs new file mode 100644 index 00000000000..21898c98ec5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.hbs @@ -0,0 +1,8 @@ +{{#if this.shouldRender}} + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.js b/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.js new file mode 100644 index 00000000000..c8466370943 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/user-card-button.js @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatUserCardButton extends Component { + @service chat; + @service appEvents; + @service router; + + get shouldRender() { + return this.chat.userCanDirectMessage && !this.args.user.suspended; + } + + @action + startChatting() { + return this.chat + .upsertDmChannelForUsernames([this.args.user.username]) + .then((channel) => { + this.router.transitionTo("chat.channel", ...channel.routeModels); + this.appEvents.trigger("card:close"); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/collapser.hbs b/plugins/chat/assets/javascripts/discourse/components/collapser.hbs index 180186ee62b..7bee61a37b2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/collapser.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/collapser.hbs @@ -16,6 +16,11 @@ {{/if}}
    -
    - {{yield}} +
    + {{yield this.collapsed}}
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/collapser.js b/plugins/chat/assets/javascripts/discourse/components/collapser.js index f7d05cf8376..db6a355a6a1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/collapser.js +++ b/plugins/chat/assets/javascripts/discourse/components/collapser.js @@ -6,14 +6,17 @@ export default Component.extend({ collapsed: false, header: null, + onToggle: null, @action open() { this.set("collapsed", false); + this.onToggle?.(false); }, @action close() { this.set("collapsed", true); + this.onToggle?.(true); }, }); diff --git a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.hbs b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.hbs deleted file mode 100644 index 5e7b37cd1a8..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.hbs +++ /dev/null @@ -1,3 +0,0 @@ -
    -
    -
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js deleted file mode 100644 index 4889d541a17..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js +++ /dev/null @@ -1,111 +0,0 @@ -// 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.hbs b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.hbs index 17ac5f4ef40..a3d465fe24e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.hbs @@ -11,6 +11,7 @@ - {{/in-element}} -{{/if}} - -{{#if (and this.channel.isDraft (not this.isLoading))}} -
    -
    - - - {{i18n "chat.direct_message_creator.prefix"}} - - -
    - {{#each this.selectedUsers as |selectedUser|}} - - - {{selectedUser.username}} - {{d-icon "times"}} - - {{/each}} - - -
    -
    - - {{#if this.shouldRenderResults}} - {{#if this.users}} -
    -
      - {{#each this.users as |user|}} -
    • - - -
    • - {{/each}} -
    -
    - {{else}} - {{#if this.term.length}} -
    -

    - {{i18n "chat.direct_message_creator.no_results"}} -

    -
    - {{/if}} - {{/if}} - {{/if}} -
    -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js deleted file mode 100644 index 66cd36bcee9..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js +++ /dev/null @@ -1,316 +0,0 @@ -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.hbs b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs index eb443dabcd8..97fc62b9593 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs @@ -1,7 +1,6 @@ -{{#if this.chat.activeChannel}} - {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js index a736c4e9011..94d9c7039f7 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js @@ -1,84 +1,6 @@ -import Component from "@ember/component"; -import { bind } from "discourse-common/utils/decorators"; -import { action } from "@ember/object"; +import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; -export default Component.extend({ - tagName: "", - router: service(), - chat: service(), - - init() { - this._super(...arguments); - }, - - didInsertElement() { - this._super(...arguments); - - this._scrollSidebarToBottom(); - document.addEventListener("keydown", this._autoFocusChatComposer); - }, - - willDestroyElement() { - this._super(...arguments); - - 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; - } - }, - - @action - navigateToIndex() { - this.router.transitionTo("chat.index"); - }, - - @action - switchChannel(channel) { - return this.chat.openChannel(channel); - }, -}); +export default class FullPageChat extends Component { + @service chat; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.hbs b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.hbs deleted file mode 100644 index 16e646ab742..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.hbs +++ /dev/null @@ -1 +0,0 @@ -
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js deleted file mode 100644 index 1cda1aaf901..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js +++ /dev/null @@ -1,48 +0,0 @@ -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/reviewable-chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.hbs index b3f653bbe0d..8401719457d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.hbs @@ -1,8 +1,11 @@
    diff --git a/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.js b/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.js index 1857a1b9f11..3331382cec8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/reviewable-chat-message.js @@ -1,15 +1,14 @@ import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; import { cached } from "@glimmer/tracking"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; export default class ReviewableChatMessage extends Component { @service store; + @service chatChannelsManager; @cached get chatChannel() { - return this.store.createRecord( - "chat-channel", - this.args.reviewable.chat_channel - ); + return ChatChannel.create(this.args.reviewable.chat_channel); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.hbs b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.hbs deleted file mode 100644 index 9f1adaff508..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#if this.isDisplayed}} - -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js deleted file mode 100644 index 68884a01910..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js +++ /dev/null @@ -1,20 +0,0 @@ -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/styleguide/chat-composer-message-details.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer-message-details.hbs new file mode 100644 index 00000000000..b4d02f4342c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer-message-details.hbs @@ -0,0 +1,15 @@ + + + + + + + + {{#if this.message.editing}} + + {{else}} + + {{/if}} + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer-message-details.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer-message-details.js new file mode 100644 index 00000000000..e3b6bc8827f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer-message-details.js @@ -0,0 +1,28 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { action } from "@ember/object"; +import { cached } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; + +export default class ChatStyleguideChatComposerMessageDetails extends Component { + @service site; + @service session; + @service keyValueStore; + @service currentUser; + + @cached + get message() { + return fabricators.message({ user: this.currentUser }); + } + + @action + toggleMode() { + if (this.message.editing) { + this.message.editing = false; + this.message.inReplyTo = fabricators.message(); + } else { + this.message.editing = true; + this.message.inReplyTo = null; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.hbs new file mode 100644 index 00000000000..24f529be21d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.hbs @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.js new file mode 100644 index 00000000000..ef2bd88ac60 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-composer.js @@ -0,0 +1,31 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default class ChatStyleguideChatComposer extends Component { + @service chatChannelComposer; + @service chatChannelPane; + + channel = fabricators.channel({ id: -999 }); + + @action + toggleDisabled() { + if (this.channel.status === CHANNEL_STATUSES.open) { + this.channel.status = CHANNEL_STATUSES.readOnly; + } else { + this.channel.status = CHANNEL_STATUSES.open; + } + } + + @action + toggleSending() { + this.chatChannelPane.sending = !this.chatChannelPane.sending; + } + + @action + onSendMessage() { + this.chatChannelComposer.reset(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.hbs new file mode 100644 index 00000000000..373008cf8fb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.hbs @@ -0,0 +1,59 @@ + + +
    +
      +
    • + +
    • +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.js new file mode 100644 index 00000000000..b0135656656 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-header-icon.js @@ -0,0 +1,49 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { + HEADER_INDICATOR_PREFERENCE_ALL_NEW, + HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS, + HEADER_INDICATOR_PREFERENCE_NEVER, +} from "discourse/plugins/chat/discourse/controllers/preferences-chat"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatStyleguideChatHeaderIcon extends Component { + @tracked isActive = false; + @tracked currentUserInDnD = false; + @tracked urgentCount; + @tracked unreadCount; + @tracked indicatorPreference = HEADER_INDICATOR_PREFERENCE_ALL_NEW; + + get indicatorPreferences() { + return [ + HEADER_INDICATOR_PREFERENCE_ALL_NEW, + HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS, + HEADER_INDICATOR_PREFERENCE_NEVER, + ]; + } + + @action + toggleIsActive() { + this.isActive = !this.isActive; + } + + @action + toggleCurrentUserInDnD() { + this.currentUserInDnD = !this.currentUserInDnD; + } + + @action + updateUnreadCount(event) { + this.unreadCount = event.target.value; + } + + @action + updateUrgentCount(event) { + this.urgentCount = event.target.value; + } + + @action + updateIndicatorPreference(value) { + this.indicatorPreference = value; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.hbs new file mode 100644 index 00000000000..76a5d861bb2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.hbs @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.js new file mode 100644 index 00000000000..4b22bae1138 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message-mention-warning.js @@ -0,0 +1,75 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageMentionWarning extends Component { + @service currentUser; + + constructor() { + super(...arguments); + this.message = fabricators.message({ user: this.currentUser }); + } + + @action + toggleCannotSee() { + if (this.message.mentionWarning?.cannotSee) { + this.message.mentionWarning = null; + } else { + this.message.mentionWarning = fabricators.messageMentionWarning( + this.message, + { + cannot_see: [fabricators.user({ username: "bob" })].map((u) => { + return { username: u.username, id: u.id }; + }), + } + ); + } + } + + @action + toggleGroupWithMentionsDisabled() { + if (this.message.mentionWarning?.groupWithMentionsDisabled) { + this.message.mentionWarning = null; + } else { + this.message.mentionWarning = fabricators.messageMentionWarning( + this.message, + { + group_mentions_disabled: [fabricators.group()].mapBy("name"), + } + ); + } + } + + @action + toggleGroupsWithTooManyMembers() { + if (this.message.mentionWarning?.groupsWithTooManyMembers) { + this.message.mentionWarning = null; + } else { + this.message.mentionWarning = fabricators.messageMentionWarning( + this.message, + { + groups_with_too_many_members: [ + fabricators.group(), + fabricators.group({ name: "Moderators" }), + ].mapBy("name"), + } + ); + } + } + @action + toggleWithoutMembership() { + if (this.message.mentionWarning?.withoutMembership) { + this.message.mentionWarning = null; + } else { + this.message.mentionWarning = fabricators.messageMentionWarning( + this.message, + { + without_membership: [fabricators.user()].map((u) => { + return { username: u.username, id: u.id }; + }), + } + ); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.hbs new file mode 100644 index 00000000000..58b6f536db6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.hbs @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.js new file mode 100644 index 00000000000..0d471a6fa2e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-message.js @@ -0,0 +1,92 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { action } from "@ember/object"; +import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { inject as service } from "@ember/service"; + +export default class ChatStyleguideChatMessage extends Component { + @service currentUser; + + manager = new ChatMessagesManager(getOwner(this)); + + constructor() { + super(...arguments); + this.message = fabricators.message({ user: this.currentUser }); + this.message.cook(); + } + + @action + toggleDeleted() { + if (this.message.deletedAt) { + this.message.deletedAt = null; + } else { + this.message.deletedAt = moment(); + } + } + + @action + toggleBookmarked() { + if (this.message.bookmark) { + this.message.bookmark = null; + } else { + this.message.bookmark = fabricators.bookmark(); + } + } + + @action + toggleHighlighted() { + this.message.highlighted = !this.message.highlighted; + } + + @action + toggleEdited() { + this.message.edited = !this.message.edited; + } + + @action + toggleLastVisit() { + this.message.newest = !this.message.newest; + } + + @action + toggleThread() { + if (this.message.thread) { + this.message.channel.threadingEnabled = false; + this.message.thread = null; + } else { + this.message.thread = fabricators.thread({ + channel: this.message.channel, + }); + this.message.thread.preview.replyCount = 1; + this.message.channel.threadingEnabled = true; + } + } + + @action + async updateMessage(event) { + this.message.message = event.target.value; + await this.message.cook(); + } + + @action + toggleReaction() { + if (this.message.reactions?.length) { + this.message.reactions = []; + } else { + this.message.reactions = [ + fabricators.reaction({ emoji: "heart" }), + fabricators.reaction({ emoji: "rocket", reacted: true }), + ]; + } + } + + @action + toggleUpload() { + if (this.message.uploads?.length) { + this.message.uploads = []; + } else { + this.message.uploads = [fabricators.upload(), fabricators.upload()]; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.hbs new file mode 100644 index 00000000000..d2409d2f27c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.js new file mode 100644 index 00000000000..57bcb49834a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-archive-channel.js @@ -0,0 +1,20 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatModalArchiveChannel from "discourse/plugins/chat/discourse/components/chat/modal/archive-channel"; +import Component from "@glimmer/component"; + +export default class ChatStyleguideChatModalArchiveChannel extends Component { + @service modal; + + channel = fabricators.channel(); + + @action + openModal() { + return this.modal.show(ChatModalArchiveChannel, { + model: { + channel: this.channel, + }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.hbs new file mode 100644 index 00000000000..099820a0a0f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.js new file mode 100644 index 00000000000..79008022af7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-channel-summary.js @@ -0,0 +1,16 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import Component from "@glimmer/component"; +import ChatModalChannelSummary from "discourse/plugins/chat/discourse/components/chat/modal/channel-summary"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatStyleguideChatModalChannelSummary extends Component { + @service modal; + + @action + openModal() { + return this.modal.show(ChatModalChannelSummary, { + model: { channelId: fabricators.channel().id }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.hbs new file mode 100644 index 00000000000..e2747d289e6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.js new file mode 100644 index 00000000000..3d37663d555 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-create-channel.js @@ -0,0 +1,13 @@ +import Component from "@glimmer/component"; +import ChatModalCreateChannel from "discourse/plugins/chat/discourse/components/chat/modal/create-channel"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatStyleguideChatModalCreateChannel extends Component { + @service modal; + + @action + openModal() { + return this.modal.show(ChatModalCreateChannel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.hbs new file mode 100644 index 00000000000..f9787048ce6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.js new file mode 100644 index 00000000000..02bba90987b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-delete-channel.js @@ -0,0 +1,18 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatModalDeleteChannel from "discourse/plugins/chat/discourse/components/chat/modal/delete-channel"; +import Component from "@glimmer/component"; + +export default class ChatStyleguideChatModalDeleteChannel extends Component { + @service modal; + + channel = fabricators.channel(); + + @action + openModal() { + return this.modal.show(ChatModalDeleteChannel, { + model: { channel: this.channel }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.hbs new file mode 100644 index 00000000000..b7d5dcc2804 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.js new file mode 100644 index 00000000000..018108bc2c7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-description.js @@ -0,0 +1,18 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatModalEditChannelDescription from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-description"; +import Component from "@glimmer/component"; + +export default class ChatStyleguideChatModalEditChannelDescription extends Component { + @service modal; + + channel = fabricators.channel(); + + @action + openModal() { + return this.modal.show(ChatModalEditChannelDescription, { + model: this.channel, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.hbs new file mode 100644 index 00000000000..5a40bff03cd --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.js new file mode 100644 index 00000000000..7e9770840ba --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-edit-channel-name.js @@ -0,0 +1,18 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name"; +import Component from "@glimmer/component"; + +export default class ChatStyleguideChatModalEditChannelName extends Component { + @service modal; + + channel = fabricators.channel(); + + @action + openModal() { + return this.modal.show(ChatModalEditChannelName, { + model: this.channel, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.hbs new file mode 100644 index 00000000000..e508bd7810a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.js new file mode 100644 index 00000000000..513dda5dcfe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-move-message-to-channel.js @@ -0,0 +1,26 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatModalMoveMessageToChannel from "discourse/plugins/chat/discourse/components/chat/modal/move-message-to-channel"; + +export default class ChatStyleguideChatModalMoveMessageToChannel extends Component { + @service modal; + + channel = fabricators.channel(); + selectedMessageIds = [fabricators.message({ channel: this.channel })].mapBy( + "id" + ); + + @action + openModal() { + return this.modal.show(ChatModalMoveMessageToChannel, { + model: { + sourceChannel: this.channel, + selectedMessageIds: [ + fabricators.message({ channel: this.channel }), + ].mapBy("id"), + }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.hbs new file mode 100644 index 00000000000..e39c631ef7c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.js new file mode 100644 index 00000000000..1ace191332a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-new-message.js @@ -0,0 +1,13 @@ +import Component from "@glimmer/component"; +import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatStyleguideChatModalNewMessage extends Component { + @service modal; + + @action + openModal() { + return this.modal.show(ChatModalNewMessage); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.hbs new file mode 100644 index 00000000000..496b4784b58 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.js new file mode 100644 index 00000000000..106faef111d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-thread-settings.js @@ -0,0 +1,16 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import Component from "@glimmer/component"; +import ChatModalThreadSettings from "discourse/plugins/chat/discourse/components/modal/chat/thread-settings"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatStyleguideChatModalThreadSettings extends Component { + @service modal; + + @action + openModal() { + return this.modal.show(ChatModalThreadSettings, { + model: fabricators.thread(), + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.hbs new file mode 100644 index 00000000000..c11dd520807 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.js new file mode 100644 index 00000000000..afd29aab914 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-modal-toggle-channel-status.js @@ -0,0 +1,16 @@ +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import Component from "@glimmer/component"; +import ChatModalToggleChannelStatus from "discourse/plugins/chat/discourse/components/chat/modal/toggle-channel-status"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatStyleguideChatModalToggleChannelStatus extends Component { + @service modal; + + @action + openModal() { + return this.modal.show(ChatModalToggleChannelStatus, { + model: fabricators.channel(), + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.hbs new file mode 100644 index 00000000000..d6ceaf4ae31 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.hbs @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.js b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.js new file mode 100644 index 00000000000..b6108bb0271 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/chat-thread-list-item.js @@ -0,0 +1,9 @@ +import Component from "@glimmer/component"; +import fabricators from "discourse/plugins/chat/discourse/lib/fabricators"; +import { inject as service } from "@ember/service"; + +export default class ChatStyleguideChatThreadListItem extends Component { + @service currentUser; + + thread = fabricators.thread(); +} diff --git a/plugins/chat/assets/javascripts/discourse/components/styleguide/organisms/chat.hbs b/plugins/chat/assets/javascripts/discourse/components/styleguide/organisms/chat.hbs new file mode 100644 index 00000000000..e883457235a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/styleguide/organisms/chat.hbs @@ -0,0 +1,19 @@ + + + + + + + +

    Modals

    + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.hbs b/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.hbs deleted file mode 100644 index 764aa1ae8e6..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{#if this.chat.userCanDirectMessage}} - -{{/if}} \ No newline at end of file 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 deleted file mode 100644 index 8d9f7d40ca3..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js +++ /dev/null @@ -1,17 +0,0 @@ -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/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs new file mode 100644 index 00000000000..f99d5cc1a02 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs new file mode 100644 index 00000000000..fb4d97e44ae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs new file mode 100644 index 00000000000..496de999995 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs @@ -0,0 +1 @@ + \ No newline at end of file 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 deleted file mode 100644 index fbc4d8f3cc5..00000000000 --- a/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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 index d60987db2ef..03a17675fa7 100644 --- 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 @@ -1,3 +1,3 @@ {{#if this.user.can_chat_user}} - + {{/if}} \ No newline at end of file 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/user-nav__preferences-chat.hbs similarity index 56% rename from plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs rename to plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/user-nav__preferences-chat.hbs index a00d96a6c3d..aeeef0a9a5e 100644 --- a/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs +++ b/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/user-nav__preferences-chat.hbs @@ -1,5 +1,6 @@ {{#if (or this.model.can_chat this.currentUser.admin)}} - {{i18n "chat.title_capitalized"}} + {{d-icon "d-chat"}} + {{i18n "chat.title_capitalized"}} {{/if}} \ No newline at end of file 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 deleted file mode 100644 index d46a9f241d0..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js +++ /dev/null @@ -1,8 +0,0 @@ -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-edit-description.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js deleted file mode 100644 index 85e834963ae..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js +++ /dev/null @@ -1,51 +0,0 @@ -import Controller from "@ember/controller"; -import { action, computed } from "@ember/object"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { inject as service } from "@ember/service"; - -export default class ChatChannelEditDescriptionController extends Controller.extend( - ModalFunctionality -) { - @service chatApi; - 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 this.chatApi - .updateChannel(this.model.id, { - description: this.editedDescription, - }) - .then((result) => { - this.model.set("description", result.channel.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-name.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-name.js deleted file mode 100644 index fcba0d77118..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-name.js +++ /dev/null @@ -1,50 +0,0 @@ -import Controller from "@ember/controller"; -import { action, computed } from "@ember/object"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { inject as service } from "@ember/service"; -export default class ChatChannelEditTitleController extends Controller.extend( - ModalFunctionality -) { - @service chatApi; - editedName = ""; - - @computed("model.title", "editedName") - get isSaveDisabled() { - return ( - this.model.title === this.editedName || - this.editedName?.length > this.siteSettings.max_topic_title_length - ); - } - - onShow() { - this.set("editedName", this.model.title || ""); - } - - onClose() { - this.set("editedName", ""); - this.clearFlash(); - } - - @action - onSaveChatChannelName() { - return this.chatApi - .updateChannel(this.model.id, { - name: this.editedName, - }) - .then((result) => { - this.model.set("title", result.channel.title); - this.send("closeModal"); - }) - .catch((event) => { - if (event.jqXHR?.responseJSON?.errors) { - this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); - } - }); - } - - @action - onChangeChatChannelName(title) { - this.clearFlash(); - this.set("editedName", title); - } -} 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 index 73514a7bd6d..8748478788f 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js @@ -1,19 +1,25 @@ import Controller from "@ember/controller"; import { action } from "@ember/object"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import showModal from "discourse/lib/show-modal"; +import { inject as service } from "@ember/service"; +import ChatModalEditChannelDescription from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-description"; +import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name"; export default class ChatChannelInfoAboutController extends Controller.extend( ModalFunctionality ) { + @service modal; + @action onEditChatChannelName() { - showModal("chat-channel-edit-name", { model: this.model }); + return this.modal.show(ChatModalEditChannelName, { + model: this.model, + }); } @action onEditChatChannelDescription() { - showModal("chat-channel-edit-description", { + return this.modal.show(ChatModalEditChannelDescription, { model: this.model, }); } 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 deleted file mode 100644 index ac07dd24838..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js +++ /dev/null @@ -1,12 +0,0 @@ -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-thread.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-thread.js new file mode 100644 index 00000000000..f4c56aceb46 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-thread.js @@ -0,0 +1,9 @@ +import Controller from "@ember/controller"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatChannelThreadController extends Controller { + @service chat; + + @tracked targetMessageId = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js deleted file mode 100644 index e3dfaf5a613..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js +++ /dev/null @@ -1,18 +0,0 @@ -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 index 75d4a3b4ee9..7984545c101 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js @@ -1,14 +1,12 @@ import Controller from "@ember/controller"; -import { action } from "@ember/object"; import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; export default class ChatChannelController extends Controller { @service chat; - queryParams = ["messageId"]; + @tracked targetMessageId = null; - @action - switchChannel(channel) { - this.chat.openChannel(channel); - } + // Backwards-compatibility + queryParams = ["messageId"]; } diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js index 162d6a72ef7..2f7f827d27f 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js @@ -1,12 +1,6 @@ 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 index fd2b50ff962..4a8fb8178cf 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js @@ -1,12 +1,6 @@ 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 deleted file mode 100644 index 86ebd1e8ab4..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js +++ /dev/null @@ -1,8 +0,0 @@ -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 index 1ea1e5a478a..888996dd833 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat.js @@ -3,6 +3,8 @@ import { inject as service } from "@ember/service"; export default class ChatController extends Controller { @service chat; + @service chatStateManager; + @service router; get shouldUseChatSidebar() { if (this.site.mobileView) { @@ -19,4 +21,21 @@ export default class ChatController extends Controller { get shouldUseCoreSidebar() { return this.siteSettings.navigation_menu === "sidebar"; } + + get mainOutletModifierClasses() { + let modifierClasses = []; + + if (this.chatStateManager.isSidePanelExpanded) { + modifierClasses.push("has-side-panel-expanded"); + } + + if ( + !this.router.currentRouteName.startsWith("chat.channel.info") && + !this.router.currentRouteName.startsWith("chat.browse") + ) { + modifierClasses.push("chat-view"); + } + + return modifierClasses.join(" "); + } } diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js deleted file mode 100644 index ff24ba44d4d..00000000000 --- a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js +++ /dev/null @@ -1,171 +0,0 @@ -import { escapeExpression } from "discourse/lib/utilities"; -import Controller from "@ember/controller"; -import I18n from "I18n"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -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; - @service chatChannelsManager; - @service chatApi; - - 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 = { - chatable_id: this.categoryId, - name: this.name, - description: this.description, - auto_join_users: this.autoJoinUsers, - }; - - return this.chatApi - .createChannel(data) - .then((channel) => { - this.send("closeModal"); - this.chatChannelsManager.follow(channel); - this.chat.openChannel(channel); - }) - .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 this.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: this.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 index bf4ef8a8ae8..b5e346b62c4 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js @@ -13,17 +13,39 @@ const CHAT_ATTRS = [ "ignore_channel_wide_mention", "chat_sound", "chat_email_frequency", + "chat_header_indicator_preference", ]; +export const HEADER_INDICATOR_PREFERENCE_NEVER = "never"; +export const HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS = "dm_and_mentions"; +export const HEADER_INDICATOR_PREFERENCE_ALL_NEW = "all_new"; + const EMAIL_FREQUENCY_OPTIONS = [ { name: I18n.t(`chat.email_frequency.never`), value: "never" }, { name: I18n.t(`chat.email_frequency.when_away`), value: "when_away" }, ]; +const HEADER_INDICATOR_OPTIONS = [ + { + name: I18n.t(`chat.header_indicator_preference.all_new`), + value: HEADER_INDICATOR_PREFERENCE_ALL_NEW, + }, + { + name: I18n.t(`chat.header_indicator_preference.dm_and_mentions`), + value: HEADER_INDICATOR_PREFERENCE_DM_AND_MENTIONS, + }, + { + name: I18n.t(`chat.header_indicator_preference.never`), + value: HEADER_INDICATOR_PREFERENCE_NEVER, + }, +]; + export default class PreferencesChatController extends Controller { @service chatAudioManager; + subpageTitle = I18n.t("chat.admin.title"); emailFrequencyOptions = EMAIL_FREQUENCY_OPTIONS; + headerIndicatorOptions = HEADER_INDICATOR_OPTIONS; @discourseComputed chatSounds() { diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js index 6226d541ed1..0281c252480 100644 --- a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js +++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js @@ -4,29 +4,25 @@ 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(); +registerUnbound("format-chat-date", function (message, mode) { + const currentUser = User.current(); + const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess(); + const date = moment(new Date(message.createdAt), tz); - let tz = currentUser ? currentUser.user_option.timezone : 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 = + const title = date.format(I18n.t("dates.long_with_year")); + const display = mode === "tiny" ? date.format(I18n.t("chat.dates.time_tiny")) : date.format(I18n.t("dates.time")); - return htmlSafe( - `${display}` - ); + if (message.staged) { + return htmlSafe( + `${display}` + ); + } else { + const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`); + return htmlSafe( + `${display}` + ); + } }); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js deleted file mode 100644 index adcbee709f7..00000000000 --- a/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js +++ /dev/null @@ -1,8 +0,0 @@ -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/initializers/chat-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js index 360820ac785..d1564bf8cd9 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js @@ -1,4 +1,5 @@ -import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators"; +import { decorateGithubOneboxBody } from "discourse/instance-initializers/onebox-decorators"; +import { decorateHashtags } from "discourse/lib/hashtag-autocomplete"; import { withPluginApi } from "discourse/lib/plugin-api"; import highlightSyntax from "discourse/lib/highlight-syntax"; import I18n from "I18n"; @@ -12,6 +13,9 @@ export default { initializeWithPluginApi(api, container) { const siteSettings = container.lookup("service:site-settings"); + const lightboxService = container.lookup("service:lightbox"); + const site = container.lookup("service:site"); + api.decorateChatMessage((element) => decorateGithubOneboxBody(element), { id: "onebox-github-body", }); @@ -63,13 +67,30 @@ export default { id: "linksNewTab", }); - api.decorateChatMessage( - (element) => - this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")), - { - id: "lightbox", - } - ); + if (siteSettings.enable_experimental_lightbox) { + api.decorateChatMessage( + (element) => { + lightboxService.setupLightboxes({ + container: element, + selector: "img:not(.emoji, .avatar, .site-icon)", + }); + }, + { + id: "experimental-chat-lightbox", + } + ); + } else { + api.decorateChatMessage( + (element) => + this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")), + { + id: "lightbox", + } + ); + } + api.decorateChatMessage((element) => decorateHashtags(element, site), { + id: "hashtagIcons", + }); }, _getScrollParent(node, maxParentSelector) { diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js index ae70c3e4543..ba6a1b4434b 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -1,9 +1,6 @@ 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"; +import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts"; +import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; export default { name: "chat-keyboard-shortcuts", @@ -16,15 +13,19 @@ export default { const router = container.lookup("service:router"); const appEvents = container.lookup("service:app-events"); + const modal = container.lookup("service:modal"); const chatStateManager = container.lookup("service:chat-state-manager"); - const openChannelSelector = (e) => { + const chatThreadPane = container.lookup("service:chat-thread-pane"); + const chatThreadListPane = container.lookup( + "service:chat-thread-list-pane" + ); + const chatChannelsManager = container.lookup( + "service:chat-channels-manager" + ); + const openQuickChannelSelector = (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"); - } + modal.show(ChatModalNewMessage); }; const handleMoveUpShortcut = (e) => { @@ -39,7 +40,8 @@ export default { chatService.switchChannelUpOrDown("down"); }; - const isChatComposer = (el) => el.classList.contains("chat-composer-input"); + const isChatComposer = (el) => + el.classList.contains("chat-composer__input"); const isInputSelection = (el) => { const inputs = ["input", "textarea", "select", "button"]; const elementTagName = el?.tagName.toLowerCase(); @@ -55,7 +57,10 @@ export default { } event.preventDefault(); event.stopPropagation(); - appEvents.trigger("chat:modify-selection", { type }); + appEvents.trigger("chat:modify-selection", event, { + type, + context: event.target.dataset.chatComposerContext, + }); }; const openInsertLinkModal = (event) => { @@ -64,7 +69,9 @@ export default { } event.preventDefault(); event.stopPropagation(); - appEvents.trigger("chat:open-insert-link-modal", { event }); + appEvents.trigger("chat:open-insert-link-modal", event, { + context: event.target.dataset.chatComposerContext, + }); }; const openChatDrawer = (event) => { @@ -78,32 +85,60 @@ export default { router.transitionTo(chatStateManager.lastKnownChatURL || "chat"); }; - const closeChatDrawer = (event) => { - if (!chatStateManager.isDrawerActive) { + const closeChat = (event) => { + // TODO (joffrey): removes this when we move from magnific popup + // there's no proper way to prevent propagation in mfp + if (event.srcElement?.classList?.value?.includes("mfp-wrap")) { return; } - if (!isChatComposer(event.target)) { + if (chatStateManager.isDrawerActive) { + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:toggle-close", event); return; } + if (chatThreadPane.isOpened) { + event.preventDefault(); + event.stopPropagation(); + chatThreadPane.close(); + return; + } + + if (chatThreadListPane.isOpened) { + event.preventDefault(); + event.stopPropagation(); + chatThreadListPane.close(); + return; + } + }; + + const markAllChannelsRead = (event) => { event.preventDefault(); event.stopPropagation(); - appEvents.trigger("chat:toggle-close", event); + + if (chatStateManager.isActive) { + chatChannelsManager.markAllChannelsRead(); + } }; 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( + `${PLATFORM_KEY_MODIFIER}+k`, + openQuickChannelSelector, + { + 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: { @@ -122,7 +157,7 @@ export default { global: true, }); api.addKeyboardShortcut( - `${KEY_MODIFIER}+b`, + `${PLATFORM_KEY_MODIFIER}+b`, (event) => modifyComposerSelection(event, "bold"), { global: true, @@ -137,7 +172,7 @@ export default { } ); api.addKeyboardShortcut( - `${KEY_MODIFIER}+i`, + `${PLATFORM_KEY_MODIFIER}+i`, (event) => modifyComposerSelection(event, "italic"), { global: true, @@ -152,7 +187,7 @@ export default { } ); api.addKeyboardShortcut( - `${KEY_MODIFIER}+e`, + `${PLATFORM_KEY_MODIFIER}+e`, (event) => modifyComposerSelection(event, "code"), { global: true, @@ -167,7 +202,7 @@ export default { } ); api.addKeyboardShortcut( - `${KEY_MODIFIER}+l`, + `${PLATFORM_KEY_MODIFIER}+l`, (event) => openInsertLinkModal(event), { global: true, @@ -191,7 +226,7 @@ export default { }, }, }); - api.addKeyboardShortcut("esc", (event) => closeChatDrawer(event), { + api.addKeyboardShortcut("esc", (event) => closeChat(event), { global: true, help: { category: "chat", @@ -201,6 +236,21 @@ export default { }, }, }); + api.addKeyboardShortcut( + `shift+esc`, + (event) => markAllChannelsRead(event), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.mark_all_channels_read", + definition: { + keys1: ["shift", "esc"], + keysDelimiter: "plus", + }, + }, + } + ); }); }, }; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js index 30faee83fb8..877fb9c319b 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js @@ -34,19 +34,6 @@ export default { } ); } - - api.decorateChatMessage( - (element) => { - element - .querySelectorAll(".lazyYT:not(.lazyYT-video-loaded)") - .forEach((iframe) => { - $(iframe).lazyYT(); - }); - }, - { - id: "lazy-yt", - } - ); }, initialize(container) { diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index 69ee6ab84ee..295e1ba6f1f 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -4,22 +4,43 @@ 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"; +import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel"; +import { replaceIcon } from "discourse-common/lib/icon-library"; +import chatStyleguide from "../components/styleguide/organisms/chat"; let _lastForcedRefreshAt; const MIN_REFRESH_DURATION_MS = 180000; // 3 minutes +replaceIcon("d-chat", "comment"); + export default { name: "chat-setup", + before: "hashtag-css-generator", + initialize(container) { + this.router = container.lookup("service:router"); this.chatService = container.lookup("service:chat"); + this.chatHistory = container.lookup("service:chat-history"); + this.site = container.lookup("service:site"); this.siteSettings = container.lookup("service:site-settings"); - this.appEvents = container.lookup("service:appEvents"); + this.currentUser = container.lookup("service:current-user"); + this.appEvents = container.lookup("service:app-events"); this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged"); if (!this.chatService.userCanChat) { return; } + withPluginApi("0.12.1", (api) => { + api.onPageChange((path) => { + const route = this.router.recognize(path); + if (route.name.startsWith("chat.")) { + this.chatHistory.visit(route); + } + }); + + api.registerHashtagType("channel", new ChannelHashtagType(container)); + api.registerChatComposerButton({ id: "chat-upload-btn", icon: "far-image", @@ -49,16 +70,54 @@ export default { label: "chat.emoji", id: "emoji", class: "chat-emoji-btn", - icon: "discourse-emojis", - position: "dropdown", + icon: "far-smile", + position: this.site.desktopView ? "inline" : "dropdown", + context: "channel", action() { const chatEmojiPickerManager = container.lookup( "service:chat-emoji-picker-manager" ); - chatEmojiPickerManager.startFromComposer(this.didSelectEmoji); + chatEmojiPickerManager.open({ context: "channel" }); }, }); + api.registerChatComposerButton({ + label: "chat.emoji", + id: "channel-emoji", + class: "chat-emoji-btn", + icon: "discourse-emojis", + position: "dropdown", + context: "thread", + action() { + const chatEmojiPickerManager = container.lookup( + "service:chat-emoji-picker-manager" + ); + chatEmojiPickerManager.open({ context: "thread" }); + }, + }); + + const summarizationAllowedGroups = + this.siteSettings.custom_summarization_allowed_groups + .split("|") + .map((id) => parseInt(id, 10)); + + const canSummarize = + this.siteSettings.summarization_strategy && + this.currentUser && + this.currentUser.groups.some((g) => + summarizationAllowedGroups.includes(g.id) + ); + + if (canSummarize) { + api.registerChatComposerButton({ + translatedLabel: "chat.summarization.title", + id: "channel-summary", + icon: "magic", + position: "dropdown", + action: "showChannelSummaryModal", + }); + } + // we want to decorate the chat quote dates regardless // of whether the current user has chat enabled api.decorateCookedElement( @@ -97,6 +156,9 @@ export default { document.body.classList.add("chat-enabled"); const currentUser = api.getCurrentUser(); + + // NOTE: chat_channels is more than a simple array, it also contains + // tracking and membership data, see Chat::StructuredChannelSerializer if (currentUser?.chat_channels) { this.chatService.setupWithPreloadedChannels(currentUser.chat_channels); } @@ -115,13 +177,27 @@ export default { api.addToHeaderIcons("chat-header-icon"); + api.addStyleguideSection?.({ + component: chatStyleguide, + category: "organisms", + id: "chat", + }); + + api.addChatDrawerStateCallback(({ isDrawerActive }) => { + if (isDrawerActive) { + document.body.classList.add("chat-drawer-active"); + } else { + document.body.classList.remove("chat-drawer-active"); + } + }); + api.decorateChatMessage(function (chatMessage, chatChannel) { if (!this.currentUser) { return; } const highlightable = [`@${this.currentUser.username}`]; - if (chatChannel.allow_channel_wide_mentions) { + if (chatChannel.allowChannelWideMentions) { highlightable.push(...MENTION_KEYWORDS.map((k) => `@${k}`)); } diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js index 547632c6c77..010698b56b1 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js @@ -1,16 +1,16 @@ 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 { escapeExpression } from "discourse/lib/utilities"; +import { avatarUrl } from "discourse-common/lib/avatar-utils"; 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"; +import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; export default { name: "chat-sidebar", @@ -21,158 +21,166 @@ export default { return; } + this.siteSettings = container.lookup("service:site-settings"); + withPluginApi("1.3.0", (api) => { - api.addSidebarSection( - (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { - const SidebarChatChannelsSectionLink = class extends BaseCustomSidebarSectionLink { - constructor({ channel, chatService }) { - super(...arguments); - this.channel = channel; - this.chatService = chatService; - } - - get name() { - return dasherize(slugifyChannel(this.channel)); - } - - @computed("chatService.activeChannel") - get classNames() { - const classes = []; - - if (this.channel.currentUserMembership.muted) { - classes.push("sidebar-section-link--muted"); + if (this.siteSettings.enable_public_channels) { + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const SidebarChatChannelsSectionLink = class extends BaseCustomSidebarSectionLink { + constructor({ channel, chatService }) { + super(...arguments); + this.channel = channel; + this.chatService = chatService; } - if (this.channel.id === this.chatService.activeChannel?.id) { - classes.push("sidebar-section-link--active"); + get name() { + return dasherize(this.channel.slugifiedTitle); } - classes.push(`channel-${this.channel.id}`); + get classNames() { + const classes = []; - return classes.join(" "); - } + if (this.channel.currentUserMembership.muted) { + classes.push("sidebar-section-link--muted"); + } - get route() { - return "chat.channel"; - } + if (this.channel.id === this.chatService.activeChannel?.id) { + classes.push("sidebar-section-link--active"); + } - get models() { - return [this.channel.id, slugifyChannel(this.channel)]; - } + classes.push(`channel-${this.channel.id}`); - get text() { - return htmlSafe(emojiUnescape(this.channel.escapedTitle)); - } - - get prefixType() { - return "icon"; - } - - get prefixValue() { - return "hashtag"; - } - - get prefixColor() { - return this.channel.chatable.color; - } - - get title() { - return this.channel.escapedDescription - ? htmlSafe(this.channel.escapedDescription) - : `${this.channel.escapedTitle} ${I18n.t("chat.title")}`; - } - - get prefixBadge() { - return this.channel.chatable.read_restricted ? "lock" : ""; - } - - get suffixType() { - return "icon"; - } - - get suffixValue() { - return this.channel.currentUserMembership.unread_count > 0 - ? "circle" - : ""; - } - - get suffixCSSClass() { - return this.channel.currentUserMembership.unread_mentions > 0 - ? "urgent" - : "unread"; - } - }; - - const SidebarChatChannelsSection = class extends BaseCustomSidebarSection { - @tracked currentUserCanJoinPublicChannels = - this.sidebar.currentUser && - (this.sidebar.currentUser.staff || - this.sidebar.currentUser.has_joinable_public_channels); - - constructor() { - super(...arguments); - - if (container.isDestroyed) { - return; + return classes.join(" "); } - this.chatService = container.lookup("service:chat"); - this.chatChannelsManager = container.lookup( - "service:chat-channels-manager" - ); - this.router = container.lookup("service:router"); - } - get sectionLinks() { - return this.chatChannelsManager.publicMessageChannels.map( - (channel) => - new SidebarChatChannelsSectionLink({ - channel, - chatService: this.chatService, - }) - ); - } + get route() { + return "chat.channel"; + } - get name() { - return "chat-channels"; - } + get models() { + return this.channel.routeModels; + } - get title() { - return I18n.t("chat.chat_channels"); - } + get text() { + return htmlSafe(emojiUnescape(this.channel.escapedTitle)); + } - get text() { - return I18n.t("chat.chat_channels"); - } + get prefixType() { + return "icon"; + } - get actions() { - return [ - { - id: "browseChannels", - title: I18n.t("chat.channels_list_popup.browse"), - action: () => this.router.transitionTo("chat.browse.open"), - }, - ]; - } + get prefixValue() { + return "d-chat"; + } - get actionsIcon() { - return "pencil-alt"; - } + get prefixColor() { + return this.channel.chatable.color; + } - get links() { - return this.sectionLinks; - } + get title() { + return this.channel.escapedDescription + ? htmlSafe(this.channel.escapedDescription) + : `${this.channel.escapedTitle} ${I18n.t("chat.title")}`; + } - get displaySection() { - return ( - this.sectionLinks.length > 0 || - this.currentUserCanJoinPublicChannels - ); - } - }; + get prefixBadge() { + return this.channel.chatable.read_restricted ? "lock" : ""; + } - return SidebarChatChannelsSection; - } - ); + get suffixType() { + return "icon"; + } + + get suffixValue() { + return this.channel.tracking.unreadCount > 0 || + // We want to do this so we don't show a blue dot if the user is inside + // the channel and a new unread thread comes in. + (this.chatService.activeChannel?.id !== this.channel.id && + this.channel.unreadThreadsCountSinceLastViewed > 0) + ? "circle" + : ""; + } + + get suffixCSSClass() { + return this.channel.tracking.mentionCount > 0 + ? "urgent" + : "unread"; + } + }; + + const SidebarChatChannelsSection = class extends BaseCustomSidebarSection { + @service currentUser; + @tracked currentUserCanJoinPublicChannels = + this.currentUser && + (this.currentUser.staff || + this.currentUser.has_joinable_public_channels); + + constructor() { + super(...arguments); + + if (container.isDestroyed) { + return; + } + this.chatService = container.lookup("service:chat"); + this.chatChannelsManager = container.lookup( + "service:chat-channels-manager" + ); + this.router = container.lookup("service:router"); + } + + get sectionLinks() { + return this.chatChannelsManager.publicMessageChannels.map( + (channel) => + new SidebarChatChannelsSectionLink({ + channel, + chatService: this.chatService, + }) + ); + } + + 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) => { @@ -195,10 +203,9 @@ export default { } get name() { - return slugifyChannel(this.channel); + return this.channel.slugifiedTitle; } - @computed("chatService.activeChannel") get classNames() { const classes = []; @@ -220,12 +227,12 @@ export default { } get models() { - return [this.channel.id, slugifyChannel(this.channel)]; + return this.channel.routeModels; } get title() { - return I18n.t("chat.placeholder_others", { - messageRecipient: this.channel.escapedTitle, + return I18n.t("chat.placeholder_channel", { + channelName: this.channel.escapedTitle, }); } @@ -233,15 +240,19 @@ export default { return this.channel.chatable.users.length === 1; } + get contentComponentArgs() { + return this.channel.chatable.users[0].get("status"); + } + + get contentComponent() { + return "user-status-message"; + } + get text() { const username = this.channel.escapedTitle.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)}${decorateUsername( escapeExpression(username) )}` ); @@ -286,9 +297,7 @@ export default { } get suffixValue() { - return this.channel.currentUserMembership.unread_count > 0 - ? "circle" - : ""; + return this.channel.tracking.unreadCount > 0 ? "circle" : ""; } get suffixCSSClass() { @@ -315,14 +324,6 @@ export default { 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)}`; @@ -341,6 +342,7 @@ export default { const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection { @service site; + @service modal; @service router; @tracked userCanDirectMessage = this.chatService.userCanDirectMessage; @@ -389,7 +391,7 @@ export default { id: "startDm", title: I18n.t("chat.direct_messages.new"), action: () => { - this.router.transitionTo("chat.draft-channel"); + this.modal.show(ChatModalNewMessage); }, }, ]; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js index 62758b9b0c5..b2c0d4ca8c5 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js @@ -24,11 +24,9 @@ export default { title: this.notification.data.chat_channel_title, slug: this.notification.data.chat_channel_slug, }); - return `/chat/channel/${ + return `/chat/c/${slug || "-"}/${ this.notification.data.chat_channel_id - }/${slug || "-"}?messageId=${ - this.notification.data.chat_message_id - }`; + }/${this.notification.data.chat_message_id}`; } get linkTitle() { @@ -61,11 +59,16 @@ export default { title: this.notification.data.chat_channel_title, slug: this.notification.data.chat_channel_slug, }); - return `/chat/channel/${ + + let notificationRoute = `/chat/c/${slug || "-"}/${ this.notification.data.chat_channel_id - }/${slug || "-"}?messageId=${ - this.notification.data.chat_message_id }`; + if (this.notification.data.chat_thread_id) { + notificationRoute += `/t/${this.notification.data.chat_thread_id}`; + } else { + notificationRoute += `/${this.notification.data.chat_message_id}`; + } + return notificationRoute; } get linkTitle() { @@ -73,7 +76,7 @@ export default { } get icon() { - return "comment"; + return "d-chat"; } get label() { @@ -116,7 +119,7 @@ export default { } get icon() { - return "comment"; + return "d-chat"; } get count() { diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js index e7b9af19561..c410763d713 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js @@ -5,6 +5,7 @@ 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"; +const CHAT_HEADER_INDICATOR_PREFERENCE = "chat_header_indicator_preference"; export default { name: "chat-user-options", @@ -18,6 +19,7 @@ export default { api.addSaveableUserOptionField(IGNORE_CHANNEL_WIDE_MENTION); api.addSaveableUserOptionField(CHAT_SOUND); api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY); + api.addSaveableUserOptionField(CHAT_HEADER_INDICATOR_PREFERENCE); } }); }, diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js index 94ca01e9729..9d9390a1df1 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js @@ -66,54 +66,60 @@ export function chatComposerButtonsDependentKeys() { ); } -export function chatComposerButtons(context, position) { +export function chatComposerButtons(composer, position, context) { return Object.values(_chatComposerButtons) - .filter( - (button) => - computeButton(context, button, "displayed") && - computeButton(context, button, "position") === position - ) + .filter((button) => { + let valid = + computeButton(composer, button, "displayed") && + computeButton(composer, button, "position") === position; + + if (button.context) { + valid = valid && computeButton(composer, button, "context") === context; + } + + return valid; + }) .map((button) => { const result = { id: button.id }; - const label = computeButton(context, button, "label"); + const label = computeButton(composer, button, "label"); result.label = label ? label - : computeButton(context, button, "translatedLabel"); + : computeButton(composer, button, "translatedLabel"); - const ariaLabel = computeButton(context, button, "ariaLabel"); + const ariaLabel = computeButton(composer, button, "ariaLabel"); if (ariaLabel) { result.ariaLabel = I18n.t(ariaLabel); } else { const translatedAriaLabel = computeButton( - context, + composer, button, "translatedAriaLabel" ); result.ariaLabel = translatedAriaLabel || result.label; } - const title = computeButton(context, button, "title"); + const title = computeButton(composer, button, "title"); result.title = title ? I18n.t(title) - : computeButton(context, button, "translatedTitle"); + : computeButton(composer, button, "translatedTitle"); result.classNames = ( - computeButton(context, button, "classNames") || [] + computeButton(composer, button, "classNames") || [] ).join(" "); - result.icon = computeButton(context, button, "icon"); - result.disabled = computeButton(context, button, "disabled"); - result.priority = computeButton(context, button, "priority"); + result.icon = computeButton(composer, button, "icon"); + result.disabled = computeButton(composer, button, "disabled"); + result.priority = computeButton(composer, button, "priority"); if (isFunction(button.action)) { result.action = () => { - button.action.apply(context); + button.action.apply(composer, [context]); }; } else { const actionName = button.action; result.action = () => { - context[actionName](); + composer[actionName](context); }; } diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-constants.js b/plugins/chat/assets/javascripts/discourse/lib/chat-constants.js new file mode 100644 index 00000000000..6b1f9abdd73 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-constants.js @@ -0,0 +1,4 @@ +export const PAST = "past"; +export const FUTURE = "future"; +export const READ_INTERVAL_MS = 1000; +export const DEFAULT_MESSAGE_PAGE_SIZE = 50; diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js b/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js new file mode 100644 index 00000000000..7d70c11e048 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js @@ -0,0 +1,47 @@ +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; +import { capabilities } from "discourse/services/capabilities"; +import { next, schedule } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling +// we use different hacks to work around this +// if you change any line in this method, make sure to test on iOS +export function stackingContextFix(scrollable, callback) { + if (capabilities.isIOS) { + scrollable.style.overflow = "hidden"; + scrollable + .querySelectorAll(".chat-message-separator__text-container") + .forEach((container) => (container.style.zIndex = "1")); + } + + callback?.(); + + if (capabilities.isIOS) { + next(() => { + schedule("afterRender", () => { + scrollable.style.overflow = "auto"; + discourseLater(() => { + if (!scrollable) { + return; + } + + scrollable + .querySelectorAll(".chat-message-separator__text-container") + .forEach((container) => (container.style.zIndex = "2")); + }, 50); + }); + }); + } +} + +export function bodyScrollFix() { + // when keyboard is visible this will ensure body + // doesn’t scroll out of viewport + if ( + capabilities.isIOS && + document.documentElement.classList.contains("keyboard-visible") && + !isZoomed() + ) { + document.documentElement.scrollTo(0, 0); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js new file mode 100644 index 00000000000..ccf5aef3f14 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js @@ -0,0 +1,13 @@ +import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; + +export default function chatMessageContainer(id, context) { + let selector; + + if (context === MESSAGE_CONTEXT_THREAD) { + selector = `.chat-thread .chat-message-container[data-id="${id}"]`; + } else { + selector = `.chat-channel .chat-message-container[data-id="${id}"]`; + } + + return document.querySelector(selector); +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js index 60a20c2206a..9bd86b4ab40 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js @@ -38,7 +38,7 @@ export default class ChatMessageFlag { let flagsAvailable = site.flagTypes; flagsAvailable = flagsAvailable.filter((flag) => { - return model.available_flags.includes(flag.name_key); + return model.availableFlags.includes(flag.name_key); }); // "message user" option should be at the top diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js new file mode 100644 index 00000000000..0eba137a036 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js @@ -0,0 +1,399 @@ +import getURL from "discourse-common/lib/get-url"; +import { bind } from "discourse-common/utils/decorators"; +import showModal from "discourse/lib/show-modal"; +import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; +import Bookmark from "discourse/models/bookmark"; +import BookmarkModal from "discourse/components/modal/bookmark"; +import { BookmarkFormData } from "discourse/lib/bookmark"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { isTesting } from "discourse-common/config/environment"; +import { clipboardCopy } from "discourse/lib/utilities"; +import ChatMessageReaction, { + REACTIONS, +} from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import { setOwner } from "@ember/application"; +import { tracked } from "@glimmer/tracking"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; +import I18n from "I18n"; + +const removedSecondaryActions = new Set(); + +export function removeChatComposerSecondaryActions(actionIds) { + actionIds.forEach((id) => removedSecondaryActions.add(id)); +} + +export function resetRemovedChatComposerSecondaryActions() { + removedSecondaryActions.clear(); +} + +export default class ChatMessageInteractor { + @service appEvents; + @service dialog; + @service chat; + @service chatEmojiReactionStore; + @service chatEmojiPickerManager; + @service chatChannelComposer; + @service chatThreadComposer; + @service chatChannelPane; + @service chatThreadPane; + @service chatApi; + @service currentUser; + @service site; + @service router; + @service modal; + @service capabilities; + + @tracked message = null; + @tracked context = null; + + cachedFavoritesReactions = null; + + constructor(owner, message, context) { + setOwner(this, owner); + + this.message = message; + this.context = context; + this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites; + } + + get pane() { + return this.context === MESSAGE_CONTEXT_THREAD + ? this.chatThreadPane + : this.chatChannelPane; + } + + get emojiReactions() { + let favorites = this.cachedFavoritesReactions; + + // may be a {} if no defaults defined in some production builds + if (!favorites || !favorites.slice) { + return []; + } + + return favorites.slice(0, 3).map((emoji) => { + return ( + this.message.reactions.find((reaction) => reaction.emoji === emoji) || + ChatMessageReaction.create({ emoji }) + ); + }); + } + + get canEdit() { + return ( + !this.message.deletedAt && + this.currentUser.id === this.message.user.id && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canInteractWithMessage() { + return ( + !this.message?.deletedAt && + this.message?.channel?.canModifyMessages(this.currentUser) + ); + } + + get canRestoreMessage() { + return ( + this.message?.deletedAt && + (this.currentUser.staff || + (this.message?.user?.id === this.currentUser.id && + this.message?.deletedById === this.currentUser.id)) && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canBookmark() { + return this.message?.channel?.canModifyMessages?.(this.currentUser); + } + + get canReply() { + return ( + this.canInteractWithMessage && this.context !== MESSAGE_CONTEXT_THREAD + ); + } + + get canReact() { + return this.canInteractWithMessage; + } + + get canFlagMessage() { + return ( + this.currentUser.id !== this.message?.user?.id && + this.message?.userFlagStatus === undefined && + this.message.channel?.canFlag && + !this.message?.chatWebhookEvent && + !this.message?.deletedAt + ); + } + + get canRebakeMessage() { + return ( + this.currentUser.staff && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canDeleteMessage() { + return ( + this.canDelete && + !this.message?.deletedAt && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canDelete() { + return this.currentUser.id === this.message.user.id + ? this.message.channel?.canDeleteSelf + : this.message.channel?.canDeleteOthers; + } + + get composer() { + return this.context === MESSAGE_CONTEXT_THREAD + ? this.chatThreadComposer + : this.chatChannelComposer; + } + + get secondaryActions() { + const buttons = []; + + buttons.push({ + id: "copyLink", + name: I18n.t("chat.copy_link"), + icon: "link", + }); + + if (this.canEdit) { + buttons.push({ + id: "edit", + name: I18n.t("chat.edit"), + icon: "pencil-alt", + }); + } + + if (!this.pane.selectingMessages) { + buttons.push({ + id: "select", + name: I18n.t("chat.select"), + icon: "tasks", + }); + } + + if (this.canFlagMessage) { + buttons.push({ + id: "flag", + name: I18n.t("chat.flag"), + icon: "flag", + }); + } + + if (this.canDeleteMessage) { + buttons.push({ + id: "delete", + name: I18n.t("chat.delete"), + icon: "trash-alt", + }); + } + + if (this.canRestoreMessage) { + buttons.push({ + id: "restore", + name: I18n.t("chat.restore"), + icon: "undo", + }); + } + + if (this.canRebakeMessage) { + buttons.push({ + id: "rebake", + name: I18n.t("chat.rebake_message"), + icon: "sync-alt", + }); + } + + return buttons.reject((button) => removedSecondaryActions.has(button.id)); + } + + select(checked = true) { + this.message.selected = checked; + this.pane.onSelectMessage(this.message); + } + + bulkSelect(checked) { + const manager = this.message.manager; + const lastSelectedIndex = manager.findIndexOfMessage( + this.pane.lastSelectedMessage.id + ); + const newlySelectedIndex = manager.findIndexOfMessage(this.message.id); + const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( + (a, b) => a - b + ); + + for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { + manager.messages[i].selected = checked; + } + } + + copyLink() { + const { protocol, host } = window.location; + const channelId = this.message.channel.id; + const threadId = this.message.thread?.id; + + let url; + if (this.context === MESSAGE_CONTEXT_THREAD && threadId) { + url = getURL(`/chat/c/-/${channelId}/t/${threadId}/${this.message.id}`); + } else { + url = getURL(`/chat/c/-/${channelId}/${this.message.id}`); + } + + url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; + clipboardCopy(url); + } + + @action + react(emoji, reactAction) { + if (!this.chat.userCanInteractWithChat) { + return; + } + + if (this.pane.reacting) { + return; + } + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + + if (this.site.mobileView) { + this.chat.activeMessage = null; + } + + if (reactAction === REACTIONS.add) { + this.chatEmojiReactionStore.track(`:${emoji}:`); + } + + this.pane.reacting = true; + + this.message.react( + emoji, + reactAction, + this.currentUser, + this.currentUser.id + ); + + return this.chatApi + .publishReaction( + this.message.channel.id, + this.message.id, + emoji, + reactAction + ) + .catch((errResult) => { + popupAjaxError(errResult); + this.message.react( + emoji, + REACTIONS.remove, + this.currentUser, + this.currentUser.id + ); + }) + .finally(() => { + this.pane.reacting = false; + }); + } + + @action + toggleBookmark() { + this.modal.show(BookmarkModal, { + model: { + bookmark: new BookmarkFormData( + this.message.bookmark || + Bookmark.createFor( + this.currentUser, + "Chat::Message", + this.message.id + ) + ), + afterSave: (savedData) => { + const bookmark = Bookmark.create(savedData); + this.message.bookmark = bookmark; + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + }, + afterDelete: () => { + this.message.bookmark = null; + }, + }, + }); + } + + @action + flag() { + const model = new ChatMessage(this.message.channel, this.message); + model.username = this.message.user?.username; + model.user_id = this.message.user?.id; + const controller = showModal("flag", { model }); + controller.set("flagTarget", new ChatMessageFlag()); + } + + @action + delete() { + return this.chatApi + .trashMessage(this.message.channel.id, this.message.id) + .catch(popupAjaxError); + } + + @action + restore() { + return this.chatApi + .restoreMessage(this.message.channel.id, this.message.id) + .catch(popupAjaxError); + } + + @action + rebake() { + return this.chatApi + .rebakeMessage(this.message.channel.id, this.message.id) + .catch(popupAjaxError); + } + + @action + reply() { + this.composer.replyTo(this.message); + } + + @action + edit() { + this.composer.edit(this.message); + } + + @action + openEmojiPicker(_, { target }) { + const pickerState = { + didSelectEmoji: this.selectReaction, + trigger: target, + context: "chat-channel-message", + }; + this.chatEmojiPickerManager.open(pickerState); + } + + @bind + selectReaction(emoji) { + if (!this.chat.userCanInteractWithChat) { + return; + } + + this.react(emoji, REACTIONS.add); + } + + @action + handleSecondaryActions(id) { + this[id](this.message); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-messages-loader.js b/plugins/chat/assets/javascripts/discourse/lib/chat-messages-loader.js new file mode 100644 index 00000000000..98ea712062a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-messages-loader.js @@ -0,0 +1,127 @@ +import { setOwner } from "@ember/application"; +import { tracked } from "@glimmer/tracking"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { inject as service } from "@ember/service"; +import { + DEFAULT_MESSAGE_PAGE_SIZE, + FUTURE, + PAST, +} from "discourse/plugins/chat/discourse/lib/chat-constants"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default class ChatMessagesLoader { + @service chatApi; + + @tracked loading = false; + @tracked canLoadMorePast = false; + @tracked canLoadMoreFuture = false; + @tracked fetchedOnce = false; + + constructor(owner, model) { + setOwner(this, owner); + this.model = model; + } + + get loadedPast() { + return this.canLoadMorePast === false && this.fetchedOnce; + } + + async loadMore(args = {}) { + if (this.canLoadMoreFuture === false && args.direction === FUTURE) { + return; + } + + if (this.canLoadMorePast === false && args.direction === PAST) { + return; + } + + const nextTargetMessage = this.#computeNextTargetMessage( + args.direction, + this.model + ); + + args = { + direction: args.direction, + page_size: DEFAULT_MESSAGE_PAGE_SIZE, + target_message_id: nextTargetMessage?.id, + }; + + args = this.#cleanArgs(args); + + let result; + try { + this.loading = true; + result = await this.#apiFunction(args); + this.canLoadMoreFuture = result.meta.can_load_more_future; + this.canLoadMorePast = result.meta.can_load_more_past; + } catch (error) { + this.#handleError(error); + } finally { + this.loading = false; + } + + return result; + } + + async load(args = {}) { + this.canLoadMorePast = true; + this.canLoadMoreFuture = true; + this.fetchedOnce = false; + this.loading = true; + + args.page_size ??= DEFAULT_MESSAGE_PAGE_SIZE; + + args = this.#cleanArgs(args); + + let result; + try { + result = await this.#apiFunction(args); + this.canLoadMoreFuture = result.meta.can_load_more_future; + this.canLoadMorePast = result.meta.can_load_more_past; + this.fetchedOnce = true; + } catch (error) { + this.#handleError(error); + } finally { + this.loading = false; + } + + return result; + } + + #apiFunction(args = {}) { + if (this.model instanceof ChatChannel) { + return this.chatApi.channelMessages(this.model.id, args); + } else { + return this.chatApi.channelThreadMessages( + this.model.channel.id, + this.model.id, + args + ); + } + } + + #cleanArgs(args) { + return Object.keys(args) + .filter((k) => args[k] != null) + .reduce((a, k) => ({ ...a, [k]: args[k] }), {}); + } + + #computeNextTargetMessage(direction, model) { + return direction === PAST + ? model.messagesManager.messages.find((message) => !message.staged) + : model.messagesManager.messages.findLast((message) => !message.staged); + } + + #handleError(error) { + switch (error?.jqXHR?.status) { + case 429: + popupAjaxError(error); + break; + case 404: + popupAjaxError(error); + break; + default: + throw error; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-messages-manager.js b/plugins/chat/assets/javascripts/discourse/lib/chat-messages-manager.js new file mode 100644 index 00000000000..6063e2f7ff1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-messages-manager.js @@ -0,0 +1,74 @@ +import { cached, tracked } from "@glimmer/tracking"; +import { setOwner } from "@ember/application"; + +export default class ChatMessagesManager { + @tracked messages = []; + + constructor(owner) { + setOwner(this, owner); + } + + @cached + get stagedMessages() { + return this.messages.filterBy("staged"); + } + + @cached + get selectedMessages() { + return this.messages.filterBy("selected"); + } + + clearSelectedMessages() { + this.selectedMessages.forEach((message) => (message.selected = false)); + } + + clear() { + this.messages = []; + } + + addMessages(messages = []) { + this.messages = this.messages + .concat(messages) + .uniqBy("id") + .sort((a, b) => a.createdAt - b.createdAt); + } + + findMessage(messageId) { + return this.messages.find( + (message) => message.id === parseInt(messageId, 10) + ); + } + + findFirstMessageOfDay(a) { + return this.messages.find( + (b) => + a.getFullYear() === b.createdAt.getFullYear() && + a.getMonth() === b.createdAt.getMonth() && + a.getDate() === b.createdAt.getDate() + ); + } + + removeMessage(message) { + return this.messages.removeObject(message); + } + + findStagedMessage(stagedMessageId) { + return this.stagedMessages.find( + (message) => message.id === stagedMessageId + ); + } + + findIndexOfMessage(id) { + return this.messages.findIndex((m) => m.id === id); + } + + findLastMessage() { + return this.messages.findLast((message) => !message.deletedAt); + } + + findLastUserMessage(user) { + return this.messages.findLast( + (message) => message.user.id === user.id && !message.deletedAt + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-notification-levels.js b/plugins/chat/assets/javascripts/discourse/lib/chat-notification-levels.js new file mode 100644 index 00000000000..d23c3d97823 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-notification-levels.js @@ -0,0 +1,9 @@ +import { + NotificationLevels, + buttonDetails, +} from "discourse/lib/notification-levels"; + +export const threadNotificationButtonLevels = [ + NotificationLevels.TRACKING, + NotificationLevels.REGULAR, +].map(buttonDetails); diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js new file mode 100644 index 00000000000..9fbac99477d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js @@ -0,0 +1,114 @@ +import { inject as service } from "@ember/service"; +import { setOwner } from "@ember/application"; +import Promise from "rsvp"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import { cached, tracked } from "@glimmer/tracking"; +import { TrackedMap, TrackedObject } from "@ember-compat/tracked-built-ins"; + +/* + The ChatThreadsManager is responsible for managing the loaded chat threads + for a ChatChannel model. + + It provides helpers to facilitate using and managing loaded threads instead of constantly + fetching them from the server. +*/ + +export default class ChatThreadsManager { + @service chatTrackingStateManager; + @service chatChannelsManager; + @service chatApi; + + @tracked _cached = new TrackedObject(); + @tracked _unreadThreadOverview = new TrackedMap(); + + constructor(owner) { + setOwner(this, owner); + } + + get unreadThreadCount() { + return this.unreadThreadOverview.size; + } + + get unreadThreadOverview() { + return this._unreadThreadOverview; + } + + set unreadThreadOverview(unreadThreadOverview) { + this._unreadThreadOverview.clear(); + + for (const [threadId, lastReplyCreatedAt] of Object.entries( + unreadThreadOverview + )) { + this.markThreadUnread(threadId, lastReplyCreatedAt); + } + } + + markThreadUnread(threadId, lastReplyCreatedAt) { + this.unreadThreadOverview.set( + parseInt(threadId, 10), + new Date(lastReplyCreatedAt) + ); + } + + @cached + get threads() { + return Object.values(this._cached); + } + + async find(channelId, threadId, options = { fetchIfNotFound: true }) { + const existingThread = this.#getFromCache(threadId); + + if (existingThread) { + return Promise.resolve(existingThread); + } else if (options.fetchIfNotFound) { + return this.#fetchFromServer(channelId, threadId); + } else { + return Promise.resolve(); + } + } + + remove(threadObject) { + delete this._cached[threadObject.id]; + } + + add(channel, threadObject, options = {}) { + let model; + + if (!options.replace) { + model = this.#getFromCache(threadObject.id); + } + + if (!model) { + if (threadObject instanceof ChatThread) { + model = threadObject; + } else { + model = ChatThread.create(channel, threadObject); + } + + this.#cache(model); + } + + if (threadObject?.meta?.message_bus_last_ids?.thread_message_bus_last_id) { + model.threadMessageBusLastId = + threadObject.meta.message_bus_last_ids.thread_message_bus_last_id; + } + + return model; + } + + #cache(thread) { + this._cached[thread.id] = thread; + } + + #getFromCache(id) { + return this._cached[id]; + } + + async #fetchFromServer(channelId, threadId) { + return this.chatApi.thread(channelId, threadId).then((result) => { + return this.chatChannelsManager.find(channelId).then((channel) => { + return channel.threadsManager.add(channel, result.thread); + }); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/check-message-visibility.js b/plugins/chat/assets/javascripts/discourse/lib/check-message-visibility.js new file mode 100644 index 00000000000..ee35cd62e0d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/check-message-visibility.js @@ -0,0 +1,11 @@ +export function checkMessageBottomVisibility(list, message) { + const distanceToTop = window.pageYOffset + list.getBoundingClientRect().top; + const bounding = message.getBoundingClientRect(); + return bounding.bottom - distanceToTop <= list.clientHeight + 1; +} + +export function checkMessageTopVisibility(list, message) { + const distanceToTop = window.pageYOffset + list.getBoundingClientRect().top; + const bounding = message.getBoundingClientRect(); + return bounding.top - distanceToTop >= -1; +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/collection.js b/plugins/chat/assets/javascripts/discourse/lib/collection.js new file mode 100644 index 00000000000..e472d45bffe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/collection.js @@ -0,0 +1,99 @@ +import { ajax } from "discourse/lib/ajax"; +import { tracked } from "@glimmer/tracking"; +import { bind } from "discourse-common/utils/decorators"; +import { Promise } from "rsvp"; + +/** + * Handles a paginated API response. + */ +export default class Collection { + @tracked items = []; + @tracked meta = {}; + @tracked loading = false; + @tracked fetchedOnce = false; + + constructor(resourceURL, handler) { + this._resourceURL = resourceURL; + this._handler = handler; + this._fetchedAll = false; + } + + get loadMoreURL() { + return this.meta?.load_more_url; + } + + get totalRows() { + return this.meta?.total_rows; + } + + get length() { + return this.items?.length; + } + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols + [Symbol.iterator]() { + let index = 0; + + return { + next: () => { + if (index < this.length) { + return { value: this.items[index++], done: false }; + } else { + return { done: true }; + } + }, + }; + } + + /** + * Loads first batch of results + * @returns {Promise} + */ + @bind + load(params = {}) { + if ( + this.loading || + this._fetchedAll || + (this.totalRows && this.items.length >= this.totalRows) + ) { + return Promise.resolve(); + } + + this.loading = true; + + let endpoint; + if (this.loadMoreURL) { + endpoint = this.loadMoreURL; + } else { + const filteredQueryParams = Object.entries(params).filter( + ([, v]) => v !== undefined + ); + + const queryString = new URLSearchParams(filteredQueryParams).toString(); + endpoint = this._resourceURL + (queryString ? `?${queryString}` : ""); + } + + return this.#fetch(endpoint) + .then((result) => { + const items = this._handler(result); + + if (items.length) { + this.items = (this.items ?? []).concat(items); + } + + if (!items.length || items.length < params.limit) { + this._fetchedAll = true; + } + + this.meta = result.meta; + this.fetchedOnce = true; + }) + .finally(() => { + this.loading = false; + }); + } + + #fetch(url) { + return ajax(url, { type: "GET" }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/fabricators.js b/plugins/chat/assets/javascripts/discourse/lib/fabricators.js new file mode 100644 index 00000000000..7c360f020d6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/fabricators.js @@ -0,0 +1,194 @@ +/* +Fabricators are used to create fake data for testing purposes. +The following fabricators are available in lib folder to allow +styleguide to use them, and eventually to generate dummy data +in a placeholder component. It should not be used for any other case. +*/ + +import ChatChannel, { + CHANNEL_STATUSES, + CHATABLE_TYPES, +} from "discourse/plugins/chat/discourse/models/chat-channel"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import ChatThreadPreview from "discourse/plugins/chat/discourse/models/chat-thread-preview"; +import ChatDirectMessage from "discourse/plugins/chat/discourse/models/chat-direct-message"; +import ChatMessageMentionWarning from "discourse/plugins/chat/discourse/models/chat-message-mention-warning"; +import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import User from "discourse/models/user"; +import Bookmark from "discourse/models/bookmark"; +import Category from "discourse/models/category"; +import Group from "discourse/models/group"; + +let sequence = 0; + +function messageFabricator(args = {}) { + const channel = args.channel || channelFabricator(); + + const message = ChatMessage.create( + channel, + Object.assign( + { + id: args.id || sequence++, + user: args.user || userFabricator(), + message: + args.message || + "@discobot **abc**defghijklmnopqrstuvwxyz [discourse](discourse.org) :rocket: ", + created_at: args.created_at || moment(), + }, + args + ) + ); + + const excerptLength = 50; + const text = message.message.toString(); + if (text.length <= excerptLength) { + message.excerpt = text; + } else { + message.excerpt = text.slice(0, excerptLength) + "..."; + } + + return message; +} + +function channelFabricator(args = {}) { + const id = args.id || sequence++; + + const channel = ChatChannel.create({ + id, + chatable_type: + args.chatable?.type || + args.chatable_type || + CHATABLE_TYPES.categoryChannel, + chatable_id: args.chatable?.id || args.chatable_id, + title: args.title || "General", + description: args.description, + chatable: args.chatable || categoryFabricator(), + status: args.status || CHANNEL_STATUSES.open, + slug: args.chatable?.slug || "general", + meta: Object.assign({ can_delete_self: true }, args.meta || {}), + archive_failed: args.archive_failed ?? false, + }); + + channel.lastMessage = messageFabricator({ channel }); + + return channel; +} + +function categoryFabricator(args = {}) { + return Category.create({ + id: args.id || sequence++, + color: args.color || "D56353", + read_restricted: false, + name: args.name || "General", + slug: args.slug || "general", + }); +} + +function directMessageFabricator(args = {}) { + return ChatDirectMessage.create({ + id: args.id || sequence++, + users: args.users || [userFabricator(), userFabricator()], + }); +} + +function directMessageChannelFabricator(args = {}) { + const directMessage = + args.chatable || + directMessageFabricator({ + id: args.chatable_id || sequence++, + }); + + return channelFabricator( + Object.assign(args, { + chatable_type: CHATABLE_TYPES.directMessageChannel, + chatable_id: directMessage.id, + chatable: directMessage, + }) + ); +} + +function userFabricator(args = {}) { + return User.create({ + id: args.id || sequence++, + username: args.username || "hawk", + name: args.name, + avatar_template: "/letter_avatar_proxy/v3/letter/t/41988e/{size}.png", + suspended_till: args.suspended_till, + }); +} + +function bookmarkFabricator(args = {}) { + return Bookmark.create({ + id: args.id || sequence++, + }); +} + +function threadFabricator(args = {}) { + const channel = args.channel || channelFabricator(); + return ChatThread.create(channel, { + id: args.id || sequence++, + original_message: args.original_message || messageFabricator({ channel }), + preview: args.preview || threadPreviewFabricator({ channel }), + }); +} +function threadPreviewFabricator(args = {}) { + return ChatThreadPreview.create({ + last_reply_id: args.last_reply_id || sequence++, + last_reply_created_at: args.last_reply_created_at || Date.now(), + last_reply_excerpt: args.last_reply_excerpt || "This is a reply", + }); +} + +function reactionFabricator(args = {}) { + return ChatMessageReaction.create({ + count: args.count || 1, + users: args.users || [userFabricator()], + emoji: args.emoji || "heart", + reacted: args.reacted || false, + }); +} + +function groupFabricator(args = {}) { + return Group.create({ + name: args.name || "Engineers", + }); +} + +function messageMentionWarningFabricator(message, args = {}) { + return ChatMessageMentionWarning.create(message, args); +} + +function uploadFabricator() { + return { + 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, + }; +} + +export default { + bookmark: bookmarkFabricator, + user: userFabricator, + channel: channelFabricator, + directMessageChannel: directMessageChannelFabricator, + message: messageFabricator, + thread: threadFabricator, + threadPreview: threadPreviewFabricator, + reaction: reactionFabricator, + upload: uploadFabricator, + category: categoryFabricator, + directMessage: directMessageFabricator, + messageMentionWarning: messageMentionWarningFabricator, + group: groupFabricator, +}; diff --git a/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js b/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js new file mode 100644 index 00000000000..f49216bbb38 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js @@ -0,0 +1,36 @@ +import HashtagTypeBase from "discourse/lib/hashtag-types/base"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import { inject as service } from "@ember/service"; + +export default class ChannelHashtagType extends HashtagTypeBase { + @service chatChannelsManager; + @service currentUser; + + get type() { + return "channel"; + } + + get preloadedData() { + if (this.currentUser) { + return this.chatChannelsManager.publicMessageChannels; + } else { + return []; + } + } + + generateColorCssClasses(channel) { + return [ + `.d-icon.hashtag-color--${this.type}-${channel.id} { color: var(--category-${channel.chatable.id}-color); }`, + ]; + } + + generateIconHTML(hashtag) { + const hashtagId = parseInt(hashtag.id, 10); + const colorCssClass = !this.preloadedData.mapBy("id").includes(hashtagId) + ? "hashtag-missing" + : `hashtag-color--${this.type}-${hashtag.id}`; + return iconHTML(hashtag.icon, { + class: colorCssClass, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/scroll-helpers.js b/plugins/chat/assets/javascripts/discourse/lib/scroll-helpers.js new file mode 100644 index 00000000000..f059be73646 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/scroll-helpers.js @@ -0,0 +1,49 @@ +import { schedule } from "@ember/runloop"; +import { stackingContextFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; + +export function scrollListToBottom(list) { + stackingContextFix(list, () => { + list.scrollTo({ top: 0, behavior: "auto" }); + }); +} + +export function scrollListToTop(list) { + stackingContextFix(list, () => { + list.scrollTo({ top: -list.scrollHeight, behavior: "auto" }); + }); +} + +export function scrollListToMessage( + list, + message, + opts = { highlight: false, position: "start", autoExpand: false } +) { + if (!message) { + return; + } + + if (message?.deletedAt && opts.autoExpand) { + message.expanded = true; + } + + schedule("afterRender", () => { + const messageEl = list.querySelector( + `.chat-message-container[data-id='${message.id}']` + ); + + if (!messageEl) { + return; + } + + if (opts.highlight) { + message.highlight(); + } + + stackingContextFix(list, () => { + messageEl.scrollIntoView({ + behavior: "auto", + block: opts.position || "center", + }); + }); + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js index f7a2734879e..492a25a9ae5 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js +++ b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js @@ -4,6 +4,11 @@ export default function slugifyChannel(channel) { if (channel.slug) { return channel.slug; } + + if (!channel.escapedTitle && !channel.title) { + return "-"; + } + const slug = slugify(channel.escapedTitle || channel.title); const resolvedSlug = ( slug.length diff --git a/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js new file mode 100644 index 00000000000..2cdcb6ddf00 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js @@ -0,0 +1,100 @@ +import EmberObject from "@ember/object"; +import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; +import { next, schedule } from "@ember/runloop"; +import { setOwner } from "@ember/application"; +import { inject as service } from "@ember/service"; +import { registerDestructor } from "@ember/destroyable"; + +// This class sole purpose is to provide a way to interact with the textarea +// using the existing TextareaTextManipulation mixin without using it directly +// in the composer component. It will make future migration easier. +export default class TextareaInteractor extends EmberObject.extend( + TextareaTextManipulation +) { + @service capabilities; + @service site; + @service siteSettings; + + constructor(owner, textarea) { + super(...arguments); + setOwner(this, owner); + this.textarea = textarea; + this._textarea = textarea; + this.element = this._textarea; + this.ready = true; + this.composerFocusSelector = `#${textarea.id}`; + + this.init(); // mixin init wouldn't be called otherwise + this.composerEventPrefix = null; // we don't need app events + + // paste is using old native ember events defined on composer + this.textarea.addEventListener("paste", this.paste); + registerDestructor(this, (instance) => instance.teardown()); + } + + teardown() { + this.textarea.removeEventListener("paste", this.paste); + } + + set value(value) { + this._textarea.value = value; + const event = new Event("input", { + bubbles: true, + cancelable: true, + }); + this._textarea.dispatchEvent(event); + } + + blur() { + next(() => { + schedule("afterRender", () => { + this._textarea.blur(); + }); + }); + } + + focus(opts = { ensureAtEnd: false, refreshHeight: true, addText: null }) { + next(() => { + schedule("afterRender", () => { + if (opts.refreshHeight) { + this.refreshHeight(); + } + + if (opts.ensureAtEnd) { + this.ensureCaretAtEnd(); + } + + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + if (opts.addText) { + this.addText(this.getSelected(), opts.addText); + } + + this.focusTextArea(); + }); + }); + } + + ensureCaretAtEnd() { + schedule("afterRender", () => { + this._textarea.setSelectionRange( + this._textarea.value.length, + this._textarea.value.length + ); + }); + } + + refreshHeight() { + schedule("afterRender", () => { + // 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/models/chat-channel-archive.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel-archive.js new file mode 100644 index 00000000000..5626dc0b5a9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel-archive.js @@ -0,0 +1,21 @@ +import { tracked } from "@glimmer/tracking"; + +export default class ChatChannelArchive { + static create(args = {}) { + return new ChatChannelArchive(args); + } + + @tracked failed; + @tracked completed; + @tracked messages; + @tracked topicId; + @tracked totalMessages; + + constructor(args = {}) { + this.failed = args.archive_failed; + this.completed = args.archive_completed; + this.messages = args.archived_messages; + this.topicId = args.archive_topic_id; + this.totalMessages = args.total_messages; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index 182b01a1ec3..056c0356133 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -1,10 +1,17 @@ -import RestModel from "discourse/models/rest"; -import I18n from "I18n"; -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 ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import { escapeExpression } from "discourse/lib/utilities"; import { tracked } from "@glimmer/tracking"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager"; +import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; +import { getOwner } from "discourse-common/lib/get-owner"; +import guid from "pretty-text/guid"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import ChatDirectMessage from "discourse/plugins/chat/discourse/models/chat-direct-message"; +import ChatChannelArchive from "discourse/plugins/chat/discourse/models/chat-channel-archive"; +import Category from "discourse/models/category"; +import ChatTrackingState from "discourse/plugins/chat/discourse/models/chat-tracking-state"; export const CHATABLE_TYPES = { directMessageChannel: "DirectMessage", @@ -18,19 +25,6 @@ export const CHANNEL_STATUSES = { 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; @@ -57,13 +51,92 @@ const READONLY_STATUSES = [ CHANNEL_STATUSES.archived, ]; -export default class ChatChannel extends RestModel { - @tracked currentUserMembership = null; - @tracked isDraft = false; +export default class ChatChannel { + static create(args = {}) { + return new ChatChannel(args); + } + @tracked title; + @tracked slug; @tracked description; - @tracked chatableType; @tracked status; + @tracked activeThread = null; + @tracked meta; + @tracked chatableType; + @tracked chatableUrl; + @tracked autoJoinUsers = false; + @tracked allowChannelWideMentions = true; + @tracked membershipsCount = 0; + @tracked archive; + @tracked tracking; + @tracked threadingEnabled = false; + + threadsManager = new ChatThreadsManager(getOwner(this)); + messagesManager = new ChatMessagesManager(getOwner(this)); + + @tracked _currentUserMembership; + @tracked _lastMessage; + + constructor(args = {}) { + this.id = args.id; + this.chatableId = args.chatable_id; + this.chatableUrl = args.chatable_url; + this.chatableType = args.chatable_type; + this.membershipsCount = args.memberships_count; + this.slug = args.slug; + this.title = args.title; + this.status = args.status; + this.description = args.description; + this.threadingEnabled = args.threading_enabled; + this.autoJoinUsers = args.auto_join_users; + this.allowChannelWideMentions = args.allow_channel_wide_mentions; + this.chatable = this.isDirectMessageChannel + ? ChatDirectMessage.create({ + id: args.chatable?.id, + users: args.chatable?.users, + }) + : Category.create(args.chatable); + this.currentUserMembership = args.current_user_membership; + + if (args.archive_completed || args.archive_failed) { + this.archive = ChatChannelArchive.create(args); + } + + this.tracking = new ChatTrackingState(getOwner(this)); + this.lastMessage = args.last_message; + this.meta = args.meta; + } + + get unreadThreadsCountSinceLastViewed() { + return Array.from(this.threadsManager.unreadThreadOverview.values()).filter( + (lastReplyCreatedAt) => + lastReplyCreatedAt >= this.currentUserMembership.lastViewedAt + ).length; + } + + updateLastViewedAt() { + this.currentUserMembership.lastViewedAt = new Date(); + } + + get canDeleteSelf() { + return this.meta.can_delete_self; + } + + get canDeleteOthers() { + return this.meta.can_delete_others; + } + + get canFlag() { + return this.meta.can_flag; + } + + get userSilenced() { + return this.meta.user_silenced; + } + + get canModerate() { + return this.meta.can_moderate; + } get escapedTitle() { return escapeExpression(this.title); @@ -73,12 +146,20 @@ export default class ChatChannel extends RestModel { return escapeExpression(this.description); } + get slugifiedTitle() { + return this.slug || slugifyChannel(this); + } + + get routeModels() { + return [this.slugifiedTitle, this.id]; + } + get isDirectMessageChannel() { - return this.chatable_type === CHATABLE_TYPES.directMessageChannel; + return this.chatableType === CHATABLE_TYPES.directMessageChannel; } get isCategoryChannel() { - return this.chatable_type === CHATABLE_TYPES.categoryChannel; + return this.chatableType === CHATABLE_TYPES.categoryChannel; } get isOpen() { @@ -105,6 +186,45 @@ export default class ChatChannel extends RestModel { return this.currentUserMembership.following; } + get canJoin() { + return this.meta.can_join_chat_channel; + } + + createStagedThread(message) { + const clonedMessage = message.duplicate(); + + const thread = new ChatThread(this, { + id: `staged-thread-${message.channel.id}-${message.id}`, + original_message: message, + staged: true, + created_at: moment.utc().format(), + }); + + clonedMessage.thread = thread; + clonedMessage.manager = thread.messagesManager; + thread.messagesManager.addMessages([clonedMessage]); + + return thread; + } + + async stageMessage(message) { + message.id = guid(); + message.staged = true; + message.draft = false; + message.createdAt = new Date(); + message.channel = this; + + if (message.inReplyTo) { + if (!this.threadingEnabled) { + this.messagesManager.addMessages([message]); + } + } else { + this.messagesManager.addMessages([message]); + } + + message.manager = this.messagesManager; + } + canModifyMessages(user) { if (user.staff) { return !STAFF_READONLY_STATUSES.includes(this.status); @@ -113,74 +233,33 @@ export default class ChatChannel extends RestModel { return !READONLY_STATUSES.includes(this.status); } - updateMembership(membership) { - this.currentUserMembership.following = membership.following; - this.currentUserMembership.muted = membership.muted; - this.currentUserMembership.desktop_notification_level = - membership.desktop_notification_level; - this.currentUserMembership.mobile_notification_level = - membership.mobile_notification_level; + get currentUserMembership() { + return this._currentUserMembership; } - updateLastReadMessage(messageId) { - if (!this.isFollowing || !messageId) { + set currentUserMembership(membership) { + if (membership instanceof UserChatChannelMembership) { + this._currentUserMembership = membership; + } else { + this._currentUserMembership = + UserChatChannelMembership.create(membership); + } + } + + get lastMessage() { + return this._lastMessage; + } + + set lastMessage(message) { + if (!message) { + this._lastMessage = null; return; } - return ajax(`/chat/${this.id}/read/${messageId}.json`, { - method: "PUT", - }).then(() => { - this.currentUserMembership.last_read_message_id = messageId; - }); + if (message instanceof ChatMessage) { + this._lastMessage = message; + } else { + this._lastMessage = ChatMessage.create(this, message); + } } } - -ChatChannel.reopenClass({ - create(args) { - args = args || {}; - - this._initUserModels(args); - this._initUserMembership(args); - - args.chatableType = args.chatable_type; - args.membershipsCount = args.memberships_count; - - 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.currentUserMembership instanceof UserChatChannelMembership) { - return; - } - - args.currentUserMembership = UserChatChannelMembership.create( - args.current_user_membership || { - following: false, - muted: false, - unread_count: 0, - unread_mentions: 0, - } - ); - - delete args.current_user_membership; - }, -}); - -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-chatable.js b/plugins/chat/assets/javascripts/discourse/models/chat-chatable.js new file mode 100644 index 00000000000..63558ca8f2b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-chatable.js @@ -0,0 +1,72 @@ +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import User from "discourse/models/user"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; + +export default class ChatChatable { + static create(args = {}) { + return new ChatChatable(args); + } + + static createUser(model) { + return new ChatChatable({ + type: "user", + model, + identifier: `u-${model.id}`, + }); + } + + static createChannel(model) { + return new ChatChatable({ + type: "channel", + model, + identifier: `c-${model.id}`, + }); + } + + @service chatChannelsManager; + + @tracked identifier; + @tracked type; + @tracked model; + @tracked enabled = true; + @tracked tracking; + + constructor(args = {}) { + this.identifier = args.identifier; + this.type = args.type; + + switch (this.type) { + case "channel": + if (args.model.chatable?.users?.length === 1) { + this.enabled = args.model.chatable?.users[0].has_chat_enabled; + } + + if (args.model instanceof ChatChannel) { + this.model = args.model; + break; + } + + this.model = ChatChannel.create(args.model); + break; + case "user": + this.enabled = args.model.has_chat_enabled; + + if (args.model instanceof User) { + this.model = args.model; + break; + } + + this.model = User.create(args.model); + break; + } + } + + get isUser() { + return this.type === "user"; + } + + get isSingleUserChannel() { + return this.type === "channel" && this.model?.chatable?.users?.length === 1; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-direct-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-direct-message.js new file mode 100644 index 00000000000..00f996180ec --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-direct-message.js @@ -0,0 +1,29 @@ +import User from "discourse/models/user"; +import { tracked } from "@glimmer/tracking"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default class ChatDirectMessage { + static create(args = {}) { + return new ChatDirectMessage(args); + } + + @tracked id; + @tracked users = null; + + type = CHATABLE_TYPES.directMessageChannel; + + constructor(args = {}) { + this.id = args.id; + this.users = this.#initUsers(args.users || []); + } + + #initUsers(users) { + return users.map((user) => { + if (!user || user instanceof User) { + return user; + } + + return User.create(user); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-mention-warning.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-mention-warning.js new file mode 100644 index 00000000000..0aec8f7e16e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message-mention-warning.js @@ -0,0 +1,21 @@ +import { tracked } from "@glimmer/tracking"; + +export default class ChatMessageMentionWarning { + static create(message, args = {}) { + return new ChatMessageMentionWarning(message, args); + } + + @tracked invitationSent = false; + @tracked cannotSee; + @tracked withoutMembership; + @tracked groupsWithTooManyMembers; + @tracked groupWithMentionsDisabled; + + constructor(message, args = {}) { + this.message = args.message; + this.cannotSee = args.cannot_see; + this.withoutMembership = args.without_membership; + this.groupsWithTooManyMembers = args.groups_with_too_many_members; + this.groupWithMentionsDisabled = args.group_mentions_disabled; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js new file mode 100644 index 00000000000..86748cab894 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js @@ -0,0 +1,35 @@ +import { tracked } from "@glimmer/tracking"; +import User from "discourse/models/user"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; + +export const REACTIONS = { add: "add", remove: "remove" }; + +export default class ChatMessageReaction { + static create(args = {}) { + return new ChatMessageReaction(args); + } + + @tracked count = 0; + @tracked reacted = false; + @tracked users = []; + @tracked emoji; + + constructor(args = {}) { + this.count = args.count; + this.emoji = args.emoji; + this.users = this.#initUsersModels(args.users); + this.reacted = args.reacted; + } + + #initUsersModels(users = []) { + return new TrackedArray( + users.map((user) => { + if (user instanceof User) { + return user; + } + + return User.create(user); + }) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index 812eb8748db..1cc2f2598fe 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -1,18 +1,401 @@ -import RestModel from "discourse/models/rest"; import User from "discourse/models/user"; +import { cached, tracked } from "@glimmer/tracking"; +import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins"; +import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import Bookmark from "discourse/models/bookmark"; +import I18n from "I18n"; +import { generateCookFunction } from "discourse/lib/text"; +import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; +import { getOwner } from "discourse-common/lib/get-owner"; +import discourseLater from "discourse-common/lib/later"; -export default class ChatMessage extends RestModel {} +export default class ChatMessage { + static cookFunction = null; -ChatMessage.reopenClass({ - create(args) { - args = args || {}; - this._initUserModel(args); - return this._super(args); - }, + static create(channel, args = {}) { + return new ChatMessage(channel, args); + } - _initUserModel(args) { - if (args.user) { - args.user = User.create(args.user); + static createDraftMessage(channel, args = {}) { + args.draft = true; + return ChatMessage.create(channel, args); + } + + @tracked id; + @tracked error; + @tracked selected; + @tracked channel; + @tracked staged; + @tracked draftSaved; + @tracked draft; + @tracked createdAt; + @tracked uploads; + @tracked excerpt; + @tracked reactions; + @tracked reviewableId; + @tracked user; + @tracked inReplyTo; + @tracked expanded = true; + @tracked bookmark; + @tracked userFlagStatus; + @tracked hidden; + @tracked version = 0; + @tracked edited; + @tracked editing; + @tracked chatWebhookEvent = new TrackedObject(); + @tracked mentionWarning; + @tracked availableFlags; + @tracked newest; + @tracked highlighted; + @tracked firstOfResults; + @tracked message; + @tracked manager; + @tracked deletedById; + + @tracked _deletedAt; + @tracked _cooked; + @tracked _thread; + + constructor(channel, args = {}) { + // when modifying constructor, be sure to update duplicate function accordingly + this.id = args.id; + this.channel = channel; + this.manager = args.manager; + this.newest = args.newest || false; + this.draftSaved = args.draftSaved || args.draft_saved || false; + this.firstOfResults = args.firstOfResults || args.first_of_results || false; + this.staged = args.staged || false; + this.edited = args.edited || false; + this.editing = args.editing || false; + this.availableFlags = args.availableFlags || args.available_flags; + this.hidden = args.hidden || false; + this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event; + this.createdAt = args.created_at ? new Date(args.created_at) : null; + this.deletedById = args.deletedById || args.deleted_by_id; + this._deletedAt = args.deletedAt || args.deleted_at; + this.expanded = + this.hidden || this._deletedAt ? false : args.expanded || true; + this.excerpt = args.excerpt; + this.reviewableId = args.reviewableId || args.reviewable_id; + this.userFlagStatus = args.userFlagStatus || args.user_flag_status; + this.draft = args.draft; + this.message = args.message || ""; + this._cooked = args.cooked || ""; + this.inReplyTo = + args.inReplyTo || + (args.in_reply_to || args.replyToMsg + ? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg) + : null); + this.reactions = this.#initChatMessageReactionModel(args.reactions); + this.uploads = new TrackedArray(args.uploads || []); + this.user = this.#initUserModel(args.user); + this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; + this.mentionedUsers = this.#initMentionedUsers(args.mentioned_users); + + if (args.thread) { + this.thread = args.thread; } - }, -}); + } + + duplicate() { + // This is important as a message can exist in the context of a channel or a thread + // The current strategy is to have a different message object in each cases to avoid + // side effects + const message = new ChatMessage(this.channel, { + id: this.id, + newest: this.newest, + staged: this.staged, + edited: this.edited, + availableFlags: this.availableFlags, + hidden: this.hidden, + chatWebhookEvent: this.chatWebhookEvent, + createdAt: this.createdAt, + deletedAt: this.deletedAt, + excerpt: this.excerpt, + reviewableId: this.reviewableId, + userFlagStatus: this.userFlagStatus, + draft: this.draft, + message: this.message, + cooked: this.cooked, + }); + + message.reactions = this.reactions; + message.user = this.user; + message.inReplyTo = this.inReplyTo; + message.bookmark = this.bookmark; + message.uploads = this.uploads; + + return message; + } + + get replyable() { + return !this.staged && !this.error; + } + + get editable() { + return !this.staged && !this.error; + } + + get thread() { + return this._thread; + } + + set thread(thread) { + this._thread = this.channel.threadsManager.add(this.channel, thread, { + replace: true, + }); + } + + get deletedAt() { + return this._deletedAt; + } + + set deletedAt(value) { + this._deletedAt = value; + this.incrementVersion(); + return this._deletedAt; + } + + get cooked() { + return this._cooked; + } + + set cooked(newCooked) { + // some markdown is cooked differently on the server-side, e.g. + // quotes, avatar images etc. + if (newCooked !== this._cooked) { + this._cooked = newCooked; + this.incrementVersion(); + } + } + + async cook() { + const site = getOwner(this).lookup("service:site"); + + if (this.isDestroyed || this.isDestroying) { + return; + } + + const markdownOptions = { + featuresOverride: + site.markdown_additional_options?.chat?.limited_pretty_text_features, + markdownItRules: + site.markdown_additional_options?.chat + ?.limited_pretty_text_markdown_rules, + hashtagTypesInPriorityOrder: + site.hashtag_configurations?.["chat-composer"], + hashtagIcons: site.hashtag_icons, + }; + + if (ChatMessage.cookFunction) { + this.cooked = ChatMessage.cookFunction(this.message); + } else { + const cookFunction = await generateCookFunction(markdownOptions); + ChatMessage.cookFunction = (raw) => { + return simpleCategoryHashMentionTransform( + cookFunction(raw), + site.categories + ); + }; + + this.cooked = ChatMessage.cookFunction(this.message); + } + } + + get read() { + return this.channel.currentUserMembership?.lastReadMessageId >= this.id; + } + + @cached + get firstMessageOfTheDayAt() { + if (!this.previousMessage) { + return this.#startOfDay(this.createdAt); + } + + if ( + !this.#areDatesOnSameDay(this.previousMessage.createdAt, this.createdAt) + ) { + return this.#startOfDay(this.createdAt); + } + } + + @cached + get formattedFirstMessageDate() { + if (this.firstMessageOfTheDayAt) { + return this.#calendarDate(this.firstMessageOfTheDayAt); + } + } + + #calendarDate(date) { + return moment(date).calendar(moment(), { + sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, + lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, + lastWeek: "LL", + sameElse: "LL", + }); + } + + @cached + get index() { + return this.manager?.messages?.indexOf(this); + } + + @cached + get previousMessage() { + return this.manager?.messages?.objectAt?.(this.index - 1); + } + + @cached + get nextMessage() { + return this.manager?.messages?.objectAt?.(this.index + 1); + } + + highlight() { + this.highlighted = true; + + discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.highlighted = false; + }, 2000); + } + + incrementVersion() { + this.version++; + } + + toJSONDraft() { + if ( + this.message?.length === 0 && + this.uploads?.length === 0 && + !this.inReplyTo + ) { + return null; + } + + const data = {}; + + if (this.uploads?.length > 0) { + data.uploads = this.uploads; + } + + if (this.message?.length > 0) { + data.message = this.message; + } + + if (this.inReplyTo) { + data.replyToMsg = { + id: this.inReplyTo.id, + excerpt: this.inReplyTo.excerpt, + user: { + id: this.inReplyTo.user.id, + name: this.inReplyTo.user.name, + avatar_template: this.inReplyTo.user.avatar_template, + username: this.inReplyTo.user.username, + }, + }; + } + + if (this.editing) { + data.editing = true; + data.id = this.id; + data.excerpt = this.excerpt; + } + + return JSON.stringify(data); + } + + react(emoji, action, actor, currentUserId) { + const selfReaction = actor.id === currentUserId; + const existingReaction = this.reactions.find( + (reaction) => reaction.emoji === emoji + ); + + if (existingReaction) { + if (action === "add") { + if (selfReaction && existingReaction.reacted) { + return; + } + + // we might receive a message bus event while loading a channel who would + // already have the reaction added to the message + if (existingReaction.users.find((user) => user.id === actor.id)) { + return; + } + + existingReaction.count = existingReaction.count + 1; + if (selfReaction) { + existingReaction.reacted = true; + } + existingReaction.users.pushObject(actor); + } else { + const existingUserReaction = existingReaction.users.find( + (user) => user.id === actor.id + ); + + if (!existingUserReaction) { + return; + } + + if (selfReaction) { + existingReaction.reacted = false; + } + + if (existingReaction.count === 1) { + this.reactions.removeObject(existingReaction); + } else { + existingReaction.count = existingReaction.count - 1; + existingReaction.users.removeObject(existingUserReaction); + } + } + } else { + if (action === "add") { + this.reactions.pushObject( + ChatMessageReaction.create({ + count: 1, + emoji, + reacted: selfReaction, + users: [actor], + }) + ); + } + } + } + + #initChatMessageReactionModel(reactions = []) { + return reactions.map((reaction) => ChatMessageReaction.create(reaction)); + } + + #initMentionedUsers(mentionedUsers) { + const map = new Map(); + if (mentionedUsers) { + mentionedUsers.forEach((userData) => { + const user = User.create(userData); + map.set(user.id, user); + }); + } + return map; + } + + #initUserModel(user) { + if (!user || user instanceof User) { + return user; + } + + return User.create(user); + } + + #areDatesOnSameDay(a, b) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); + } + + #startOfDay(date) { + return moment(date).startOf("day").format(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-notice.js b/plugins/chat/assets/javascripts/discourse/models/chat-notice.js new file mode 100644 index 00000000000..0960846d16e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-notice.js @@ -0,0 +1,15 @@ +import { tracked } from "@glimmer/tracking"; + +export default class ChatNotice { + static create(args = {}) { + return new ChatNotice(args); + } + + @tracked channelId; + @tracked textContent; + + constructor(args = {}) { + this.channelId = args.channel_id; + this.textContent = args.text_content; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread-preview.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread-preview.js new file mode 100644 index 00000000000..22547a071aa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread-preview.js @@ -0,0 +1,39 @@ +import { tracked } from "@glimmer/tracking"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; + +export default class ChatThreadPreview { + static create(args = {}) { + return new ChatThreadPreview(args); + } + + @tracked replyCount; + @tracked lastReplyId; + @tracked lastReplyCreatedAt; + @tracked lastReplyExcerpt; + @tracked lastReplyUser; + @tracked participantCount; + @tracked participantUsers; + + constructor(args = {}) { + if (!args) { + args = {}; + } + + this.replyCount = args.reply_count || args.replyCount || 0; + this.lastReplyId = args.last_reply_id || args.lastReplyId; + this.lastReplyCreatedAt = new Date( + args.last_reply_created_at || args.lastReplyCreatedAt + ); + this.lastReplyExcerpt = args.last_reply_excerpt || args.lastReplyExcerpt; + this.lastReplyUser = args.last_reply_user || args.lastReplyUser; + this.participantCount = + args.participant_count || args.participantCount || 0; + this.participantUsers = new TrackedArray( + args.participant_users || args.participantUsers || [] + ); + } + + get otherParticipantCount() { + return this.participantCount - this.participantUsers.length; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js new file mode 100644 index 00000000000..c55166c25e0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -0,0 +1,85 @@ +import { getOwner } from "discourse-common/lib/get-owner"; +import I18n from "I18n"; +import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; +import { escapeExpression } from "discourse/lib/utilities"; +import { tracked } from "@glimmer/tracking"; +import guid from "pretty-text/guid"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import ChatTrackingState from "discourse/plugins/chat/discourse/models/chat-tracking-state"; +import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership"; +import ChatThreadPreview from "discourse/plugins/chat/discourse/models/chat-thread-preview"; + +export const THREAD_STATUSES = { + open: "open", + readOnly: "read_only", + closed: "closed", + archived: "archived", +}; + +export default class ChatThread { + static create(channel, args = {}) { + return new ChatThread(channel, args); + } + + @tracked id; + @tracked title; + @tracked status; + @tracked draft; + @tracked staged; + @tracked channel; + @tracked originalMessage; + @tracked threadMessageBusLastId; + @tracked replyCount; + @tracked tracking; + @tracked currentUserMembership = null; + @tracked preview = null; + + messagesManager = new ChatMessagesManager(getOwner(this)); + + constructor(channel, args = {}) { + this.id = args.id; + this.channel = channel; + this.status = args.status; + this.draft = args.draft; + this.staged = args.staged; + this.replyCount = args.reply_count; + + this.originalMessage = args.original_message + ? ChatMessage.create(channel, args.original_message) + : null; + + this.title = + args.title || + `${I18n.t("chat.thread.default_title", { + thread_id: this.id, + })}`; + + if (args.current_user_membership) { + this.currentUserMembership = UserChatThreadMembership.create( + args.current_user_membership + ); + } + + this.tracking = new ChatTrackingState(getOwner(this)); + this.preview = ChatThreadPreview.create(args.preview); + } + + async stageMessage(message) { + message.id = guid(); + message.staged = true; + message.draft = false; + message.createdAt ??= moment.utc().format(); + message.thread = this; + + this.messagesManager.addMessages([message]); + message.manager = this.messagesManager; + } + + get routeModels() { + return [...this.channel.routeModels, this.id]; + } + + get escapedTitle() { + return escapeExpression(this.title); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-tracking-state.js b/plugins/chat/assets/javascripts/discourse/models/chat-tracking-state.js new file mode 100644 index 00000000000..ff16ac54c4a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-tracking-state.js @@ -0,0 +1,45 @@ +import { setOwner } from "@ember/application"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatTrackingState { + @service chatTrackingStateManager; + + @tracked _unreadCount = 0; + @tracked _mentionCount = 0; + + constructor(owner, params = {}) { + setOwner(this, owner); + this._unreadCount = params.unreadCount || 0; + this._mentionCount = params.mentionCount || 0; + } + + reset() { + this._unreadCount = 0; + this._mentionCount = 0; + } + + get unreadCount() { + return this._unreadCount; + } + + set unreadCount(value) { + const valueChanged = this._unreadCount !== value; + if (valueChanged) { + this._unreadCount = value; + this.chatTrackingStateManager.triggerNotificationsChanged(); + } + } + + get mentionCount() { + return this._mentionCount; + } + + set mentionCount(value) { + const valueChanged = this._mentionCount !== value; + if (valueChanged) { + this._mentionCount = value; + this.chatTrackingStateManager.triggerNotificationsChanged(); + } + } +} 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 index 84a7d55cf98..7a87d280d8d 100644 --- a/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js +++ b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js @@ -1,28 +1,34 @@ -import RestModel from "discourse/models/rest"; import { tracked } from "@glimmer/tracking"; import User from "discourse/models/user"; -export default class UserChatChannelMembership extends RestModel { + +export default class UserChatChannelMembership { + static create(args = {}) { + return new UserChatChannelMembership(args); + } + @tracked following = false; @tracked muted = false; - @tracked unread_count = 0; - @tracked unread_mentions = 0; - @tracked desktop_notification_level = null; - @tracked mobile_notification_level = null; - @tracked last_read_message_id = null; -} + @tracked desktopNotificationLevel = null; + @tracked mobileNotificationLevel = null; + @tracked lastReadMessageId = null; + @tracked lastViewedAt = null; + @tracked user = null; -UserChatChannelMembership.reopenClass({ - create(args) { - args = args || {}; - this._initUser(args); - return this._super(args); - }, + constructor(args = {}) { + this.following = args.following; + this.muted = args.muted; + this.desktopNotificationLevel = args.desktop_notification_level; + this.mobileNotificationLevel = args.mobile_notification_level; + this.lastReadMessageId = args.last_read_message_id; + this.lastViewedAt = new Date(args.last_viewed_at); + this.user = this.#initUserModel(args.user); + } - _initUser(args) { - if (!args.user || args.user instanceof User) { - return; + #initUserModel(user) { + if (!user || user instanceof User) { + return user; } - args.user = User.create(args.user); - }, -}); + return User.create(user); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/user-chat-thread-membership.js b/plugins/chat/assets/javascripts/discourse/models/user-chat-thread-membership.js new file mode 100644 index 00000000000..4dd2ddd94a9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/user-chat-thread-membership.js @@ -0,0 +1,23 @@ +import { tracked } from "@glimmer/tracking"; +import { NotificationLevels } from "discourse/lib/notification-levels"; + +export default class UserChatThreadMembership { + static create(args = {}) { + return new UserChatThreadMembership(args); + } + + @tracked lastReadMessageId = null; + @tracked notificationLevel = null; + + constructor(args = {}) { + this.lastReadMessageId = args.last_read_message_id; + this.notificationLevel = args.notification_level; + } + + get isQuiet() { + return ( + this.notificationLevel === NotificationLevels.REGULAR || + this.notificationLevel === NotificationLevels.MUTED + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/later-fn.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/later-fn.js new file mode 100644 index 00000000000..e70651b426b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/later-fn.js @@ -0,0 +1,21 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { cancel } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +export default class ChatLaterFn extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [fn, delay]) { + this.handler = discourseLater(() => { + fn?.(element); + }, delay); + } + + cleanup() { + cancel(this.handler); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-animation-end.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-animation-end.js new file mode 100644 index 00000000000..68325d87285 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-animation-end.js @@ -0,0 +1,34 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { cancel, schedule } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatOnAnimationEnd extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [fn]) { + this.element = element; + this.fn = fn; + + this.handler = schedule("afterRender", () => { + this.element.addEventListener("animationend", this.handleAnimationEnd); + }); + } + + @bind + handleAnimationEnd() { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.fn?.(this.element); + } + + cleanup() { + cancel(this.handler); + this.element?.removeEventListener("animationend", this.handleAnimationEnd); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-long-press.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-long-press.js new file mode 100644 index 00000000000..9b69777b913 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-long-press.js @@ -0,0 +1,92 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import { cancel } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +function cancelEvent(event) { + event.stopPropagation(); +} + +export default class ChatOnLongPress extends Modifier { + @service capabilities; + @service site; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + get enabled() { + return this.capabilities.touch && this.site.mobileView; + } + + modify(element, [onLongPressStart, onLongPressEnd, onLongPressCancel]) { + if (!this.enabled) { + return; + } + + this.element = element; + this.onLongPressStart = onLongPressStart || (() => {}); + this.onLongPressEnd = onLongPressEnd || (() => {}); + this.onLongPressCancel = onLongPressCancel || (() => {}); + + this.element.addEventListener("touchstart", this.handleTouchStart, { + passive: true, + }); + } + + @bind + onCancel() { + cancel(this.timeout); + + if (this.capabilities.touch) { + this.element.removeEventListener("touchmove", this.onCancel, { + passive: true, + }); + this.element.removeEventListener("touchend", this.onCancel); + this.element.removeEventListener("touchcancel", this.onCancel); + } + + this.onLongPressCancel(this.element); + } + + @bind + handleTouchStart(event) { + if (event.touches.length > 1) { + this.onCancel(); + return; + } + this.onLongPressStart(this.element, event); + this.element.addEventListener("touchmove", this.onCancel, { + passive: true, + }); + this.element.addEventListener("touchend", this.onCancel); + this.element.addEventListener("touchcancel", this.onCancel); + this.timeout = discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.element.addEventListener("touchend", cancelEvent, { + once: true, + passive: true, + }); + + this.onLongPressEnd(this.element, event); + }, 400); + } + + cleanup() { + if (!this.enabled) { + return; + } + + this.element.removeEventListener("touchstart", this.handleTouchStart, { + passive: true, + }); + + this.onCancel(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-resize.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-resize.js new file mode 100644 index 00000000000..a836d690fde --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-resize.js @@ -0,0 +1,29 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { cancel, throttle } from "@ember/runloop"; + +export default class ChatOnResize extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [fn, options = {}]) { + this.resizeObserver = new ResizeObserver((entries) => { + this.throttleHandler = throttle( + this, + fn, + entries, + options.delay ?? 0, + options.immediate ?? false + ); + }); + + this.resizeObserver.observe(element); + } + + cleanup() { + cancel(this.throttleHandler); + this.resizeObserver?.disconnect(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-scroll.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-scroll.js new file mode 100644 index 00000000000..f8368b4513c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/on-scroll.js @@ -0,0 +1,36 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { cancel, throttle } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatOnScroll extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [callback, options]) { + this.element = element; + this.callback = callback; + this.options = options; + this.element.addEventListener("scroll", this.throttledCallback, { + passive: true, + }); + } + + @bind + throttledCallback(event) { + this.throttledHandler = throttle( + this, + this.callback, + event, + this.options.delay ?? 100, + this.options.immediate ?? false + ); + } + + cleanup() { + cancel(this.throttledHandler); + this.element.removeEventListener("scroll", this.throttledCallback); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js new file mode 100644 index 00000000000..42abed17975 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js @@ -0,0 +1,173 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { bind } from "discourse-common/utils/decorators"; +import { inject as service } from "@ember/service"; + +const MINIMUM_SIZE = 20; + +export default class ResizableNode extends Modifier { + @service capabilities; + + element = null; + resizerSelector = null; + didResizeContainer = null; + options = null; + + _originalWidth = 0; + _originalHeight = 0; + _originalX = 0; + _originalY = 0; + _originalPageX = 0; + _originalPageY = 0; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [resizerSelector, didResizeContainer, options = {}]) { + this.resizerSelector = resizerSelector; + this.element = element; + this.didResizeContainer = didResizeContainer; + this.options = Object.assign( + { vertical: true, horizontal: true, position: true, mutate: true }, + options + ); + + if (this.capabilities.touch) { + this.element + .querySelector(this.resizerSelector) + ?.addEventListener("touchstart", this._startResize); + } else { + this.element + .querySelector(this.resizerSelector) + ?.addEventListener("mousedown", this._startResize); + } + } + + cleanup() { + if (this.capabilities.touch) { + this.element + .querySelector(this.resizerSelector) + ?.addEventListener("touchstart", this._startResize); + } else { + this.element + .querySelector(this.resizerSelector) + ?.removeEventListener("mousedown", this._startResize); + } + } + + @bind + _startResize(event) { + event.preventDefault(); + + this._originalWidth = parseFloat( + getComputedStyle(this.element, null) + .getPropertyValue("width") + .replace("px", "") + ); + this._originalHeight = parseFloat( + getComputedStyle(this.element, null) + .getPropertyValue("height") + .replace("px", "") + ); + this._originalX = this.element.getBoundingClientRect().left; + this._originalY = this.element.getBoundingClientRect().top; + + this._originalPageX = this._eventValueForProperty(event, "pageX"); + this._originalPageY = this._eventValueForProperty(event, "pageY"); + + if (this.capabilities.touch) { + window.addEventListener("touchmove", this._resize); + window.addEventListener("touchend", this._stopResize); + } else { + window.addEventListener("mousemove", this._resize); + window.addEventListener("mouseup", this._stopResize); + } + } + + /* + The bulk of the logic is to calculate the new width and height of the element + based on the current position on page: width is calculated by subtracting + the difference between the current pageX and the original this._originalPageX + from the original this._originalWidth, and rounding up to the nearest integer. + height is calculated in a similar way using pageY and this._originalPageY. + + In this example (B) is the current element top/left and (A) is x/y of the mouse after dragging: + + A------ + | | + | B--| + | | | + ------- + */ + @bind + _resize(event) { + let width = this._originalWidth; + let diffWidth = + this._eventValueForProperty(event, "pageX") - this._originalPageX; + if (document.documentElement.classList.contains("rtl")) { + width = Math.ceil(width + diffWidth); + } else { + width = Math.ceil(width - diffWidth); + } + + const height = Math.ceil( + this._originalHeight - + (this._eventValueForProperty(event, "pageY") - this._originalPageY) + ); + + const newStyle = {}; + + if (this.options.horizontal && width > MINIMUM_SIZE) { + newStyle.width = width + "px"; + + if (this.options.position) { + newStyle.left = + Math.ceil( + this._originalX + + (this._eventValueForProperty(event, "pageX") - + this._originalPageX) + ) + "px"; + } + } + + if (this.options.vertical && height > MINIMUM_SIZE) { + newStyle.height = height + "px"; + + if (this.options.position) { + newStyle.top = + Math.ceil( + this._originalY + + (this._eventValueForProperty(event, "pageY") - + this._originalPageY) + ) + "px"; + } + } + + if (this.options.mutate) { + Object.assign(this.element.style, newStyle); + } + + this.didResizeContainer?.(this.element, { width, height }); + } + + @bind + _stopResize() { + if (this.capabilities.touch) { + window.removeEventListener("touchmove", this._resize); + window.removeEventListener("touchend", this._stopResize); + } else { + window.removeEventListener("mousemove", this._resize); + window.removeEventListener("mouseup", this._stopResize); + } + } + + _eventValueForProperty(event, property) { + if (this.capabilities.touch) { + return event.changedTouches[0][property]; + } else { + return event[property]; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/scrollable-list.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/scrollable-list.js new file mode 100644 index 00000000000..a356a2f2c6e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/scrollable-list.js @@ -0,0 +1,130 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { bind } from "discourse-common/utils/decorators"; +import { cancel, throttle } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +const UP = "up"; +const DOWN = "down"; + +export default class ChatScrollableList extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [options]) { + this.element = element; + this.options = options; + + this.lastScrollTop = this.computeInitialScrollTop(); + + this.element.addEventListener("scroll", this.handleScroll, { + passive: true, + }); + // listen for wheel events to detect scrolling even when at the top or bottom + this.element.addEventListener("wheel", this.handleWheel, { + passive: true, + }); + } + + @bind + handleScroll() { + this.throttleComputeScroll(); + } + + @bind + handleWheel() { + this.throttleComputeScroll(); + } + + @bind + computeScroll() { + const scrollTop = this.element.scrollTop; + this.options.onScroll?.(this.computeState()); + this.lastScrollTop = scrollTop; + } + + throttleComputeScroll() { + cancel(this.scrollTimer); + this.throttleTimer = throttle(this, this.computeScroll, 50, true); + this.scrollTimer = discourseLater(() => { + this.options.onScrollEnd?.(this.computeState()); + }, this.options.delay || 250); + } + + cleanup() { + cancel(this.scrollTimer); + cancel(this.throttleTimer); + this.element.removeEventListener("scroll", this.handleScroll); + this.element.removeEventListener("wheel", this.handleWheel); + } + + computeState() { + const direction = this.computeScrollDirection(); + const distanceToBottom = this.computeDistanceToBottom(); + const distanceToTop = this.computeDistanceToTop(); + return { + up: direction === UP, + down: direction === DOWN, + distanceToBottom, + distanceToTop, + atBottom: distanceToBottom.pixels <= 1, + atTop: distanceToTop.pixels <= 1, + }; + } + + computeInitialScrollTop() { + if (this.options.reverse) { + return this.element.scrollHeight - this.element.clientHeight; + } else { + return this.element.scrollTop; + } + } + + computeScrollTop() { + if (this.options.reverse) { + return ( + this.element.scrollHeight - + this.element.clientHeight - + this.element.scrollTop + ); + } else { + return this.element.scrollTop; + } + } + + computeDistanceToTop() { + let pixels; + const height = this.element.scrollHeight - this.element.clientHeight; + + if (this.options.reverse) { + pixels = height - Math.abs(this.element.scrollTop); + } else { + pixels = Math.abs(this.element.scrollTop); + } + + return { pixels, percentage: Math.round((pixels / height) * 100) }; + } + + computeDistanceToBottom() { + let pixels; + const height = this.element.scrollHeight - this.element.clientHeight; + + if (this.options.reverse) { + pixels = -this.element.scrollTop; + } else { + pixels = height - Math.abs(this.element.scrollTop); + } + + return { pixels, percentage: Math.round((pixels / height) * 100) }; + } + + computeScrollDirection() { + if (this.element.scrollTop === this.lastScrollTop) { + return null; + } + + return this.element.scrollTop < this.lastScrollTop ? UP : DOWN; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js new file mode 100644 index 00000000000..480d7a57f88 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js @@ -0,0 +1,39 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; + +const IS_PINNED_CLASS = "is-pinned"; + +/* + This modifier is used to track the date separator in the chat message list. + The trick is to have an element with `top: -1px` which will stop fully intersecting + as soon as it's scrolled a little bit. +*/ +export default class ChatTrackMessageSeparatorDate extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.intersectionObserver = new IntersectionObserver( + ([entry]) => { + if ( + entry.isIntersecting && + entry.intersectionRatio < 1 && + entry.boundingClientRect.y < entry.intersectionRect.y + ) { + entry.target.classList.add(IS_PINNED_CLASS); + } else { + entry.target.classList.remove(IS_PINNED_CLASS); + } + }, + { threshold: [0, 1] } + ); + + this.intersectionObserver.observe(element); + } + + cleanup() { + this.intersectionObserver?.disconnect(); + } +} 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 deleted file mode 100644 index 10474b067cf..00000000000 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js +++ /dev/null @@ -1,23 +0,0 @@ -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/modifiers/chat/track-message.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js new file mode 100644 index 00000000000..7a67cb08121 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js @@ -0,0 +1,43 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatTrackMessage extends Modifier { + didEnterViewport = null; + didLeaveViewport = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [didEnterViewport, didLeaveViewport]) { + this.didEnterViewport = didEnterViewport; + this.didLeaveViewport = didLeaveViewport; + + this.intersectionObserver = new IntersectionObserver( + this._intersectionObserverCallback, + { + root: document, + threshold: 0, + } + ); + + this.intersectionObserver.observe(element); + } + + cleanup() { + this.intersectionObserver?.disconnect(); + } + + @bind + _intersectionObserverCallback(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.didEnterViewport?.(); + } else { + this.didLeaveViewport?.(); + } + }); + } +} 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 index 3a6801ea0b7..6b977a398b8 100644 --- a/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js +++ b/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js @@ -4,6 +4,125 @@ import { resetChatMessageDecorators, } from "discourse/plugins/chat/discourse/components/chat-message"; import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import { addChatDrawerStateCallback } from "discourse/plugins/chat/discourse/services/chat-state-manager"; +import { removeChatComposerSecondaryActions } from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; + +/** + * Class exposing the javascript API available to plugins and themes. + * @class PluginApi + */ + +/** + * Callback used to decorate a chat message + * + * @callback PluginApi~decorateChatMessageCallback + * @param {ChatMessage} chatMessage - model + * @param {HTMLElement} messageContainer - DOM node + * @param {ChatChannel} chatChannel - model + */ + +/** + * Callback used to decorate a chat message + * + * @callback PluginApi~chatDrawerStateCallback + * @param {Object} state + * @param {boolean} state.isDrawerActive - is the chat drawer active + * @param {boolean} state.isDrawerExpanded - is the chat drawer expanded + */ + +/** + * Decorate a chat message + * + * @memberof PluginApi + * @instance + * @function decorateChatMessage + * @param {PluginApi~decorateChatMessageCallback} decorator + * @example + * + * api.decorateChatMessage((chatMessage, messageContainer) => { + * messageContainer.dataset.foo = chatMessage.id; + * }); + */ + +/** + * Register a button in the chat composer + * + * @memberof PluginApi + * @instance + * @function registerChatComposerButton + * @param {Object} options + * @param {number} options.id - The id of the button + * @param {function} options.action - An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }` + * @param {string} options.icon - A valid font awesome icon name, eg: "far fa-image" + * @param {string} options.label - Text displayed on the button, a translatable key, eg: "foo.bar" + * @param {string} options.translatedLabel - Text displayed on the button, a string, eg: "Add gifs" + * @param {string} [options.position] - Can be "inline" or "dropdown", defaults to "inline" + * @param {string} [options.title] - Title attribute of the button, a translatable key, eg: "foo.bar" + * @param {string} [options.translatedTitle] - Title attribute of the button, a string, eg: "Add gifs" + * @param {string} [options.ariaLabel] - aria-label attribute of the button, a translatable key, eg: "foo.bar" + * @param {string} [options.translatedAriaLabel] - aria-label attribute of the button, a string, eg: "Add gifs" + * @param {string} [options.classNames] - Additional names to add to the button’s class attribute, eg: ["foo", "bar"] + * @param {boolean} [options.displayed] - Hide or show the button + * @param {boolean} [options.disabled] - Sets the disabled attribute on the button + * @param {number} [options.priority] - An integer defining the order of the buttons, higher comes first, eg: `700` + * @param {Array.} [options.dependentKeys] - List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]` + * @example + * + * api.registerChatComposerButton({ + * id: "foo", + * displayed() { + * return this.site.mobileView && this.canAttachUploads; + * } + * }); + */ + +/** + * Callback when the sate of the chat drawer changes + * + * @memberof PluginApi + * @instance + * @function addChatDrawerStateCallback + * @param {PluginApi~chatDrawerStateCallback} callback + * @example + * + * api.addChatDrawerStateCallback(({isDrawerExpanded, isDrawerActive}) => { + * if (isDrawerActive && isDrawerExpanded) { + * // do something + * } + * }); + */ + +/** + * Send a chat message, message or uploads must be provided + * + * @memberof PluginApi + * @instance + * @function sendChatMessage + * @param {number} channelId - The id of the channel + * @param {Object} options + * @param {string} [options.message] - The content of the message to send + * @param {string} [options.uploads] - A list of uploads to send + * @param {number} [options.threadId] - The thread id where the message should be sent + * + * @example + * + * api.sendChatMessage( + * 1, + * { message: "Hello world", threadId: 2 } + * ); + */ + +/** + * Removes secondary actions from the chat composer + * + * @memberof PluginApi + * @instance + * @function removeChatComposerSecondaryActions + * @param {...string} [1] - List of secondary action ids to remove, eg: `"copyLink", "select" + * @example + * + * api.removeChatComposerSecondaryActions("copyLink", "select"); + */ export default { name: "chat-plugin-api", @@ -28,6 +147,40 @@ export default { }, }); } + + if (!apiPrototype.hasOwnProperty("addChatDrawerStateCallback")) { + Object.defineProperty(apiPrototype, "addChatDrawerStateCallback", { + value(callback) { + addChatDrawerStateCallback(callback); + }, + }); + } + + if (!apiPrototype.hasOwnProperty("sendChatMessage")) { + Object.defineProperty(apiPrototype, "sendChatMessage", { + async value(channelId, options = {}) { + return this.container + .lookup("service:chat-api") + .sendMessage(channelId, { + thread_id: options.threadId, + message: options.message, + uploads: options.uploads, + }); + }, + }); + } + + if (!apiPrototype.hasOwnProperty("removeChatComposerSecondaryActions")) { + Object.defineProperty( + apiPrototype, + "removeChatComposerSecondaryActions", + { + value(...actionIds) { + removeChatComposerSecondaryActions(actionIds); + }, + } + ); + } }); }, diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js index 367f4adadbf..7cec808e2cc 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js @@ -1,11 +1,20 @@ import DiscourseRoute from "discourse/routes/discourse"; import { inject as service } from "@ember/service"; +import { defaultHomepage } from "discourse/lib/utilities"; export default class ChatBrowseIndexRoute extends DiscourseRoute { @service chat; + @service siteSettings; + @service router; + + beforeModel() { + if (!this.siteSettings.enable_public_channels) { + return this.router.transitionTo(`discovery.${defaultHomepage()}`); + } + } activate() { - this.chat.setActiveChannel(null); + this.chat.activeChannel = null; } afterModel() { diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js new file mode 100644 index 00000000000..62f9d9d50dd --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js @@ -0,0 +1,82 @@ +import { inject as service } from "@ember/service"; + +export default function withChatChannel(extendedClass) { + return class WithChatChannel extends extendedClass { + @service chatChannelsManager; + @service chat; + @service router; + + async model(params) { + return this.chatChannelsManager.find(params.channelId); + } + + titleToken() { + if (!this.currentModel) { + return; + } + + if (this.currentModel.isDirectMessageChannel) { + return `${this.currentModel.title}`; + } else { + return `#${this.currentModel.title}`; + } + } + + afterModel(model) { + super.afterModel?.(...arguments); + + this.chat.activeChannel = model; + + if (!model) { + return this.router.replaceWith("chat"); + } + + let { messageId, channelTitle } = this.paramsFor(this.routeName); + + // messageId query param backwards-compatibility + if (messageId) { + this.router.replaceWith( + "chat.channel", + ...model.routeModels, + messageId + ); + } + + if (channelTitle && channelTitle !== model.slugifiedTitle) { + messageId = this.paramsFor("chat.channel.near-message").messageId; + const threadId = this.paramsFor("chat.channel.thread").threadId; + + if (threadId) { + const threadMessageId = this.paramsFor( + "chat.channel.thread.near-message" + ).messageId; + + if (threadMessageId) { + this.router.replaceWith( + "chat.channel.thread.near-message", + ...model.routeModels, + threadId, + threadMessageId + ); + } else { + this.router.replaceWith( + "chat.channel.thread", + ...model.routeModels, + threadId + ); + } + } else if (messageId) { + this.router.replaceWith( + "chat.channel.near-message", + ...model.routeModels, + messageId + ); + } else { + this.router.replaceWith("chat.channel", ...model.routeModels); + } + } else { + this.controllerFor("chat-channel").set("targetMessageId", null); + } + } + }; +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js index 3a167c4890f..82e0417a23b 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js @@ -1,7 +1,9 @@ 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"; +import withChatChannel from "./chat-channel-decorator"; +@withChatChannel export default class ChatChannelInfoRoute extends DiscourseRoute { @service chatChannelInfoRouteOriginManager; diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-legacy.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-legacy.js new file mode 100644 index 00000000000..29c5104d189 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-legacy.js @@ -0,0 +1,16 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelLegacyRoute extends DiscourseRoute { + @service router; + + redirect() { + const { channelTitle, channelId, messageId } = this.paramsFor( + this.routeName + ); + + this.router.replaceWith("chat.channel", channelTitle, channelId, { + queryParams: { messageId }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-near-message.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-near-message.js new file mode 100644 index 00000000000..a5d5771acb8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-near-message.js @@ -0,0 +1,23 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +// This route is only here as a convenience method for a clean `/c/:channelTitle/:channelId/:messageId` URL. +// It's not a real route, it just redirects to the real route after setting a param on the controller. +export default class ChatChannelNearMessage extends DiscourseRoute { + @service router; + + beforeModel() { + const channel = this.modelFor("chat-channel"); + const { messageId } = this.paramsFor(this.routeName); + this.controllerFor("chat-channel").set("messageId", null); + + if ( + messageId || + this.controllerFor("chat-channel").get("targetMessageId") + ) { + this.controllerFor("chat-channel").set("targetMessageId", messageId); + } + + this.router.replaceWith("chat.channel", ...channel.routeModels); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread-near-message.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread-near-message.js new file mode 100644 index 00000000000..889a40b6a4c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread-near-message.js @@ -0,0 +1,25 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +// This route is only here as a convenience method for a clean `/c/:channelTitle/:channelId/t/:threadId/:messageId` URL. +// It's not a real route, it just redirects to the real route after setting a param on the controller. +export default class ChatChannelThreadNearMessage extends DiscourseRoute { + @service router; + + beforeModel() { + const thread = this.modelFor("chat-channel-thread"); + const { messageId } = this.paramsFor(this.routeName); + + if ( + messageId || + this.controllerFor("chat-channel-thread").get("targetMessageId") + ) { + this.controllerFor("chat-channel-thread").set( + "targetMessageId", + messageId + ); + } + + this.router.replaceWith("chat.channel.thread", ...thread.routeModels); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js new file mode 100644 index 00000000000..0600e2266a5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-thread.js @@ -0,0 +1,76 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatChannelThread extends DiscourseRoute { + @service router; + @service chatStateManager; + @service chat; + @service chatStagedThreadMapping; + @service chatThreadPane; + + model(params, transition) { + const channel = this.modelFor("chat.channel"); + return channel.threadsManager + .find(channel.id, params.threadId) + .catch(() => { + transition.abort(); + this.chatStateManager.closeSidePanel(); + this.router.transitionTo("chat.channel", ...channel.routeModels); + return; + }); + } + + afterModel(model) { + this.chat.activeChannel.activeThread = model; + } + + @action + willTransition(transition) { + if ( + transition.targetName === "chat.channel.index" || + transition.targetName === "chat.channel.near-message" || + transition.targetName === "chat.index" + ) { + this.chatStateManager.closeSidePanel(); + } + } + + beforeModel(transition) { + const channel = this.modelFor("chat.channel"); + + if (!channel.threadingEnabled) { + transition.abort(); + this.router.transitionTo("chat.channel", ...channel.routeModels); + return; + } + + // This is a very special logic to attempt to reconciliate a staged thread id + // it happens after creating a new thread and having a temp ID in the URL + // if users presses reload at this moment, we would have a 404 + // replacing the ID in the URL sooner would also cause a reload + const { threadId } = this.paramsFor(this.routeName); + + if (threadId?.startsWith("staged-thread-")) { + const mapping = this.chatStagedThreadMapping.getMapping(); + + if (mapping[threadId]) { + transition.abort(); + return this.router.transitionTo( + this.routeName, + ...[...channel.routeModels, mapping[threadId]] + ); + } + } + + const { messageId } = this.paramsFor(this.routeName + ".near-message"); + if ( + !messageId && + this.controllerFor("chat-channel-thread").get("targetMessageId") + ) { + this.controllerFor("chat-channel-thread").set("targetMessageId", null); + } + + this.chatStateManager.openSidePanel(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js new file mode 100644 index 00000000000..7599442cdba --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-threads.js @@ -0,0 +1,28 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatChannelThreads extends DiscourseRoute { + @service router; + @service chatThreadListPane; + @service chatStateManager; + + beforeModel(transition) { + const channel = this.modelFor("chat.channel"); + + if (!channel.threadingEnabled) { + transition.abort(); + this.router.transitionTo("chat.channel", ...channel.routeModels); + return; + } + + this.chatStateManager.openSidePanel(); + } + + @action + willTransition(transition) { + if (transition.targetName === "chat.channel.index") { + this.chatStateManager.closeSidePanel(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js index 152348e1f99..96658d1ada4 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -1,39 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; -import { inject as service } from "@ember/service"; -import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; -import { action } from "@ember/object"; -import { schedule } from "@ember/runloop"; +import withChatChannel from "./chat-channel-decorator"; -export default class ChatChannelRoute extends DiscourseRoute { - @service chat; - @service router; - @service chatChannelsManager; - - async model(params) { - return this.chatChannelsManager.find(params.channelId); - } - - afterModel(model) { - this.chat.setActiveChannel(model); - - const { channelTitle, messageId } = this.paramsFor(this.routeName); - const slug = slugifyChannel(model); - if (channelTitle !== slug) { - this.router.replaceWith("chat.channel.index", model.id, slug, { - queryParams: { messageId }, - }); - } - } - - @action - didTransition() { - const { channelId, messageId } = this.paramsFor(this.routeName); - if (channelId && messageId) { - schedule("afterRender", () => { - this.chat.openChannelAtMessage(channelId, messageId); - this.controller.set("messageId", null); // clear the query param - }); - } - return true; - } -} +@withChatChannel +export default class ChatChannelRoute extends DiscourseRoute {} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js index a5fd5df9df3..ea6d064588b 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js @@ -3,14 +3,15 @@ import { inject as service } from "@ember/service"; export default class ChatDraftChannelRoute extends DiscourseRoute { @service chat; + @service router; beforeModel() { if (!this.chat.userCanDirectMessage) { - this.transitionTo("chat"); + this.router.transitionTo("chat"); } } activate() { - this.chat.setActiveChannel(null); + this.chat.activeChannel = null; } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js index 027f21b4672..e818e1c62ab 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-index.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js @@ -6,6 +6,10 @@ export default class ChatIndexRoute extends DiscourseRoute { @service chatChannelsManager; @service router; + activate() { + this.chat.activeChannel = null; + } + redirect() { // Always want the channel index on mobile. if (this.site.mobileView) { @@ -16,10 +20,10 @@ export default class ChatIndexRoute extends DiscourseRoute { const id = this.chat.getIdealFirstChannelId(); if (id) { return this.chatChannelsManager.find(id).then((c) => { - return this.chat.openChannel(c); + return this.router.replaceWith("chat.channel", ...c.routeModels); }); } else { - return this.router.transitionTo("chat.browse"); + return this.router.replaceWith("chat.browse"); } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-message.js b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js index c96d913c092..4b7aaee0d1a 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js @@ -5,17 +5,16 @@ import { inject as service } from "@ember/service"; export default class ChatMessageRoute extends DiscourseRoute { @service chat; + @service router; async model(params) { return ajax(`/chat/message/${params.messageId}.json`) .then((response) => { - this.transitionTo( - "chat.channel", - response.chat_channel_id, + this.router.transitionTo( + "chat.channel.near-message", response.chat_channel_title, - { - queryParams: { messageId: params.messageId }, - } + response.chat_channel_id, + params.messageId ); }) .catch(() => this.replaceWith("/404")); @@ -23,7 +22,7 @@ export default class ChatMessageRoute extends DiscourseRoute { beforeModel() { if (!this.chat.userCanChat) { - return this.transitionTo(`discovery.${defaultHomepage()}`); + return this.router.transitionTo(`discovery.${defaultHomepage()}`); } } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js index 4c49766601d..cae606d06b2 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -4,7 +4,6 @@ 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; @@ -21,11 +20,16 @@ export default class ChatRoute extends DiscourseRoute { } const INTERCEPTABLE_ROUTES = [ - "chat.channel.index", "chat.channel", + "chat.channel.thread", + "chat.channel.thread.index", + "chat.channel.thread.near-message", + "chat.channel.threads", + "chat.channel.index", + "chat.channel.near-message", + "chat.channel-legacy", "chat", "chat.index", - "chat.draft-channel", ]; if ( @@ -35,17 +39,17 @@ export default class ChatRoute extends DiscourseRoute { ) { transition.abort(); - let URL = transition.intent.url; + let url = transition.intent.url; if (transition.targetName.startsWith("chat.channel")) { - URL ??= this.router.urlFor( + url ??= this.router.urlFor( transition.targetName, ...transition.intent.contexts ); } else { - URL ??= this.router.urlFor(transition.targetName); + url ??= this.router.urlFor(transition.targetName); } - this.appEvents.trigger("chat:open-url", URL); + this.appEvents.trigger("chat:open-url", url); return; } @@ -59,26 +63,22 @@ export default class ChatRoute extends DiscourseRoute { schedule("afterRender", () => { document.body.classList.add("has-full-page-chat"); document.documentElement.classList.add("has-full-page-chat"); - }); - } - - deactivate() { - 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.channel")) { - this.chat.setActiveChannel(null); + deactivate(transition) { + if (transition) { + const url = this.router.urlFor(transition.from.name); + this.chatStateManager.storeChatURL(url); } - if (!transition?.to?.name?.startsWith("chat.")) { - this.chatStateManager.storeChatURL(); - this.chat.updatePresence(); - } + this.chat.activeChannel = null; + this.chat.updatePresence(); + + schedule("afterRender", () => { + document.body.classList.remove("has-full-page-chat"); + document.documentElement.classList.remove("has-full-page-chat"); + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js index 07da3697c57..06e2e85a4cf 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js +++ b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js @@ -4,12 +4,13 @@ import { inject as service } from "@ember/service"; export default class PreferencesChatRoute extends RestrictedUserRoute { @service chat; + @service router; showFooter = true; setupController(controller, user) { if (!user?.can_chat && !this.currentUser.admin) { - return this.transitionTo(`discovery.${defaultHomepage()}`); + return this.router.transitionTo(`discovery.${defaultHomepage()}`); } controller.set("model", user); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js new file mode 100644 index 00000000000..38025d89b26 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js @@ -0,0 +1,19 @@ +import Service from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; + +export default class ChatAdminApi extends Service { + async exportMessages() { + await this.#post(`/export/messages`); + } + + get #basePath() { + return "/chat/admin"; + } + + #post(endpoint, data = {}) { + return ajax(`${this.#basePath}${endpoint}`, { + type: "POST", + data, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index e88cb954cc9..e8677485470 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -1,123 +1,76 @@ import Service, { inject as service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership"; -import { tracked } from "@glimmer/tracking"; -import { bind } from "discourse-common/utils/decorators"; -import { Promise } from "rsvp"; - -class Collection { - @tracked items = []; - @tracked meta = {}; - @tracked loading = false; - - constructor(resourceURL, handler) { - this._resourceURL = resourceURL; - this._handler = handler; - this._fetchedAll = false; - } - - get loadMoreURL() { - return this.meta.load_more_url; - } - - get totalRows() { - return this.meta.total_rows; - } - - get length() { - return this.items.length; - } - - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols - [Symbol.iterator]() { - let index = 0; - - return { - next: () => { - if (index < this.items.length) { - return { value: this.items[index++], done: false }; - } else { - return { done: true }; - } - }, - }; - } - - @bind - load(params = {}) { - this._fetchedAll = false; - - if (this.loading) { - return; - } - - this.loading = true; - - const filteredQueryParams = Object.entries(params).filter( - ([, v]) => v !== undefined - ); - const queryString = new URLSearchParams(filteredQueryParams).toString(); - - const endpoint = this._resourceURL + (queryString ? `?${queryString}` : ""); - return this.#fetch(endpoint) - .then((result) => { - this.items = this._handler(result); - this.meta = result.meta; - }) - .finally(() => { - this.loading = false; - }); - } - - @bind - loadMore() { - let promise = Promise.resolve(); - - if (this.loading) { - return promise; - } - - if ( - this._fetchedAll || - (this.totalRows && this.items.length >= this.totalRows) - ) { - return promise; - } - - this.loading = true; - - if (this.loadMoreURL) { - promise = this.#fetch(this.loadMoreURL).then((result) => { - const newItems = this._handler(result); - - if (newItems.length) { - this.items = this.items.concat(newItems); - } else { - this._fetchedAll = true; - } - this.meta = result.meta; - }); - } - - return promise.finally(() => { - this.loading = false; - }); - } - - #fetch(url) { - return ajax(url, { type: "GET" }); - } -} +import Collection from "../lib/collection"; +/** + * Chat API service. Provides methods to interact with the chat API. + * + * @module ChatApi + * @implements {@ember/service} + */ export default class ChatApi extends Service { + @service chat; @service chatChannelsManager; - getChannel(channelId) { - return this.#getRequest(`/channels/${channelId}`).then((result) => - this.chatChannelsManager.store(result.channel) + channel(channelId) { + return this.#getRequest(`/channels/${channelId}`); + } + + channelThreadMessages(channelId, threadId, params = {}) { + return this.#getRequest( + `/channels/${channelId}/threads/${threadId}/messages?${new URLSearchParams( + params + ).toString()}` ); } + channelMessages(channelId, params = {}) { + return this.#getRequest( + `/channels/${channelId}/messages?${new URLSearchParams( + params + ).toString()}` + ); + } + + /** + * Get a thread in a channel by its ID. + * @param {number} channelId - The ID of the channel. + * @param {number} threadId - The ID of the thread. + * @returns {Promise} + * + * @example + * + * this.chatApi.thread(5, 1).then(thread => { ... }) + */ + thread(channelId, threadId) { + return this.#getRequest(`/channels/${channelId}/threads/${threadId}`); + } + + /** + * Loads all threads for a channel. + * For now we only get the 50 threads ordered + * by the last message sent by the user then the + * thread creation date, later we will paginate + * and add filters. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ + threads(channelId, handler) { + return new Collection( + `${this.#basePath}/channels/${channelId}/threads`, + handler + ); + } + + /** + * List all accessible category channels of the current user. + * @returns {Collection} + * + * @example + * + * this.chatApi.channels.then(channels => { ... }) + */ channels() { return new Collection(`${this.#basePath}/channels`, (response) => { return response.channels.map((channel) => @@ -126,26 +79,84 @@ export default class ChatApi extends Service { }); } + /** + * Moves messages from one channel to another. + * @param {number} channelId - The ID of the original channel. + * @param {object} data - Params of the move. + * @param {Array.} data.message_ids - IDs of the moved messages. + * @param {number} data.destination_channel_id - ID of the channel where the messages are moved to. + * @returns {Promise} + * + * @example + * + * this.chatApi + * .moveChannelMessages(1, { + * message_ids: [2, 3], + * destination_channel_id: 4, + * }).then(() => { ... }) + */ moveChannelMessages(channelId, data = {}) { return this.#postRequest(`/channels/${channelId}/messages/moves`, { move: data, }); } - destroyChannel(channelId, data = {}) { - return this.#deleteRequest(`/channels/${channelId}`, { channel: data }); + /** + * Destroys a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + * + * @example + * + * this.chatApi.destroyChannel(1).then(() => { ... }) + */ + destroyChannel(channelId) { + return this.#deleteRequest(`/channels/${channelId}`); } + /** + * Creates a channel. + * @param {object} data - Params of the channel. + * @param {string} data.name - The name of the channel. + * @param {string} data.chatable_id - The category of the channel. + * @param {string} data.description - The description of the channel. + * @param {boolean} [data.auto_join_users] - Should users join this channel automatically. + * @returns {Promise} + * + * @example + * + * this.chatApi + * .createChannel({ name: "foo", chatable_id: 1, description "bar" }) + * .then((channel) => { ... }) + */ createChannel(data = {}) { return this.#postRequest("/channels", { channel: data }).then((response) => this.chatChannelsManager.store(response.channel) ); } + /** + * Lists chat permissions for a category. + * @param {number} categoryId - ID of the category. + * @returns {Promise} + */ categoryPermissions(categoryId) { - return ajax(`/chat/api/category-chatables/${categoryId}/permissions`); + return this.#getRequest(`/category-chatables/${categoryId}/permissions`); } + /** + * Sends a message. + * @param {number} channelId - ID of the channel. + * @param {object} data - Params of the message. + * @param {string} data.message - The raw content of the message in markdown. + * @param {string} data.cooked - The cooked content of the message. + * @param {number} [data.in_reply_to_id] - The ID of the replied-to message. + * @param {number} [data.staged_id] - The staged ID of the message before it was persisted. + * @param {number} [data.thread_id] - The ID of the thread where this message should be posted. + * @param {number} [data.staged_thread_id] - The staged ID of the thread before it was persisted. + * @param {Array.} [data.upload_ids] - Array of upload ids linked to the message. + * @returns {Promise} + */ sendMessage(channelId, data = {}) { return ajax(`/chat/${channelId}`, { ignoreUnsent: false, @@ -154,20 +165,60 @@ export default class ChatApi extends Service { }); } + /** + * Trashes (soft deletes) a chat message. + * @param {number} channelId - ID of the channel. + * @param {number} messageId - ID of the message. + * @returns {Promise} + */ + trashMessage(channelId, messageId) { + return this.#deleteRequest(`/channels/${channelId}/messages/${messageId}`); + } + + /** + * Creates a channel archive. + * @param {number} channelId - The ID of the channel. + * @param {object} data - Params of the archive. + * @param {string} data.selection - "new_topic" or "existing_topic". + * @param {string} [data.title] - Title of the topic when creating a new topic. + * @param {string} [data.category_id] - ID of the category used when creating a new topic. + * @param {Array.} [data.tags] - tags used when creating a new topic. + * @param {string} [data.topic_id] - ID of the topic when using an existing topic. + * @returns {Promise} + */ createChannelArchive(channelId, data = {}) { return this.#postRequest(`/channels/${channelId}/archives`, { archive: data, }); } + /** + * Updates a channel. + * @param {number} channelId - The ID of the channel. + * @param {object} data - Params of the archive. + * @param {string} [data.description] - Description of the channel. + * @param {string} [data.name] - Name of the channel. + * @returns {Promise} + */ updateChannel(channelId, data = {}) { return this.#putRequest(`/channels/${channelId}`, { channel: data }); } + /** + * Updates the status of a channel. + * @param {number} channelId - The ID of the channel. + * @param {string} status - The new status, can be "open" or "closed". + * @returns {Promise} + */ updateChannelStatus(channelId, status) { return this.#putRequest(`/channels/${channelId}/status`, { status }); } + /** + * Lists members of a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Collection} + */ listChannelMemberships(channelId) { return new Collection( `${this.#basePath}/channels/${channelId}/memberships`, @@ -179,33 +230,251 @@ export default class ChatApi extends Service { ); } + /** + * Lists public and direct message channels of the current user. + * @returns {Promise} + */ listCurrentUserChannels() { - return this.#getRequest(`/channels/me`).then((result) => { - return (result?.channels || []).map((channel) => - this.chatChannelsManager.store(channel) - ); - }); + return this.#getRequest("/channels/me"); } + /** + * Makes current user follow a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ followChannel(channelId) { return this.#postRequest(`/channels/${channelId}/memberships/me`).then( (result) => UserChatChannelMembership.create(result.membership) ); } + /** + * Makes current user unfollow a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ unfollowChannel(channelId) { return this.#deleteRequest(`/channels/${channelId}/memberships/me`).then( (result) => UserChatChannelMembership.create(result.membership) ); } - updateCurrentUserChatChannelNotificationsSettings(channelId, data = {}) { + /** + * Update notifications settings of current user for a channel. + * @param {number} channelId - The ID of the channel. + * @param {object} data - The settings to modify. + * @param {boolean} [data.muted] - Mutes the channel. + * @param {string} [data.desktop_notification_level] - Notifications level on desktop: never, mention or always. + * @param {string} [data.mobile_notification_level] - Notifications level on mobile: never, mention or always. + * @returns {Promise} + */ + updateCurrentUserChannelNotificationsSettings(channelId, data = {}) { return this.#putRequest( `/channels/${channelId}/notifications-settings/me`, { notifications_settings: data } ); } + /** + * Update notifications settings of current user for a thread. + * @param {number} channelId - The ID of the channel. + * @param {number} threadId - The ID of the thread. + * @param {object} data - The settings to modify. + * @param {boolean} [data.notification_level] - The new notification level, c.f. Chat::NotificationLevels. Threads only support + * "regular" and "tracking" for now. + * @returns {Promise} + */ + updateCurrentUserThreadNotificationsSettings(channelId, threadId, data) { + return this.#putRequest( + `/channels/${channelId}/threads/${threadId}/notifications-settings/me`, + { notification_level: data.notificationLevel } + ); + } + + /** + * Saves a draft for the channel, which includes message contents and uploads. + * @param {number} channelId - The ID of the channel. + * @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details. + * @returns {Promise} + */ + saveDraft(channelId, data) { + return ajax("/chat/drafts", { + type: "POST", + data: { + chat_channel_id: channelId, + data, + }, + ignoreUnsent: false, + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .catch((error) => { + // we ignore a draft which can't be saved because it's too big + // and only deal with network error for now + if (!error.jqXHR?.responseJSON?.errors?.length) { + this.chat.markNetworkAsUnreliable(); + } + }); + } + + /** + * Adds or removes an emoji reaction for a message inside a channel. + * @param {number} channelId - The ID of the channel. + * @param {number} messageId - The ID of the message to react on. + * @param {string} emoji - The text version of the emoji without colons, e.g. tada + * @param {string} reaction - Either "add" or "remove" + * @returns {Promise} + */ + publishReaction(channelId, messageId, emoji, reactAction) { + return ajax(`/chat/${channelId}/react/${messageId}`, { + type: "PUT", + data: { + react_action: reactAction, + emoji, + }, + }); + } + + /** + * Restores a single deleted chat message in a channel. + * + * @param {number} channelId - The ID of the channel for the message being restored. + * @param {number} messageId - The ID of the message being restored. + */ + restoreMessage(channelId, messageId) { + return this.#putRequest( + `/channels/${channelId}/messages/${messageId}/restore` + ); + } + + /** + * Rebakes the cooked HTML of a single message in a channel. + * + * @param {number} channelId - The ID of the channel for the message being restored. + * @param {number} messageId - The ID of the message being restored. + */ + rebakeMessage(channelId, messageId) { + return ajax(`/chat/${channelId}/${messageId}/rebake`, { + type: "PUT", + }); + } + + /** + * Saves an edit to a message's contents in a channel. + * + * @param {number} channelId - The ID of the channel for the message being edited. + * @param {number} messageId - The ID of the message being edited. + * @param {object} data - Params of the edit. + * @param {string} data.new_message - The edited content of the message. + * @param {Array} data.upload_ids - The uploads attached to the message after editing. + */ + editMessage(channelId, messageId, data) { + return ajax(`/chat/${channelId}/edit/${messageId}`, { + type: "PUT", + data, + }); + } + + /** + * Marks messages for all of a user's chat channel memberships as read. + * + * @returns {Promise} + */ + markAllChannelsAsRead() { + return this.#putRequest(`/channels/read`); + } + + /** + * Lists all possible chatables. + * + * @param {term} string - The term to search for. # prefix will scope to channels, @ to users. + * + * @returns {Promise} + */ + chatables(args = {}) { + return this.#getRequest("/chatables", args); + } + + /** + * Marks messages for a single user chat channel membership as read. If no + * message ID is provided, then the latest message for the channel is fetched + * on the server and used for the last read message. + * + * @param {number} channelId - The ID of the channel for the message being marked as read. + * @param {number} [messageId] - The ID of the message being marked as read. + * @returns {Promise} + */ + markChannelAsRead(channelId, messageId = null) { + return this.#putRequest(`/channels/${channelId}/read/${messageId}`); + } + + /** + * Marks all messages and mentions in a thread as read. This is quite + * far-reaching for now, and is not granular since there is no membership/ + * read state per-user for threads. In future this will be expanded to + * also pass message ID in the same way as markChannelAsRead + * + * @param {number} channelId - The ID of the channel for the thread being marked as read. + * @param {number} threadId - The ID of the thread being marked as read. + * @returns {Promise} + */ + markThreadAsRead(channelId, threadId) { + return this.#putRequest(`/channels/${channelId}/threads/${threadId}/read`); + } + + /** + * Updates settings of a thread. + * + * @param {number} channelId - The ID of the channel for the thread being edited. + * @param {number} threadId - The ID of the thread being edited. + * @param {object} data - Params of the edit. + * @param {string} data.title - The new title for the thread. + */ + editThread(channelId, threadId, data) { + return this.#putRequest(`/channels/${channelId}/threads/${threadId}`, data); + } + + /** + * Generate a quote for a list of messages. + * + * @param {number} channelId - The ID of the channel containing the messages. + * @param {Array} messageIds - The IDs of the messages to quote. + */ + generateQuote(channelId, messageIds) { + return ajax(`/chat/${channelId}/quote`, { + type: "POST", + data: { message_ids: messageIds }, + }); + } + + /** + * Invite users to a channel. + * + * @param {number} channelId - The ID of the channel. + * @param {Array} userIds - The IDs of the users to invite. + * @param {object} options + * @param {number} options.chat_message_id - A message ID to display in the invite. + */ + invite(channelId, userIds, options = {}) { + return ajax(`/chat/${channelId}/invite`, { + type: "put", + data: { user_ids: userIds, chat_message_id: options.messageId }, + }); + } + + /** + * Summarize a channel. + * + * @param {number} channelId - The ID of the channel to summarize. + * @param {object} options + * @param {number} options.since - Number of hours ago the summary should start (1, 3, 6, 12, 24, 72, 168). + */ + summarize(channelId, options = {}) { + return this.#getRequest(`/channels/${channelId}/summarize`, options); + } + get #basePath() { return "/chat/api"; } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js new file mode 100644 index 00000000000..b745b153dff --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -0,0 +1,73 @@ +import Service, { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatChannelComposer extends Service { + @service chat; + @service currentUser; + @service router; + @service("chat-thread-composer") threadComposer; + + @tracked message; + @tracked textarea; + + @action + focus(options = {}) { + this.textarea?.focus(options); + } + + @action + blur() { + this.textarea.blur(); + } + + @action + reset(channel) { + this.message = ChatMessage.createDraftMessage(channel, { + user: this.currentUser, + }); + } + + @action + cancel() { + if (this.message.editing) { + this.reset(this.message.channel); + } else if (this.message.inReplyTo) { + this.message.inReplyTo = null; + } + + this.focus({ ensureAtEnd: true, refreshHeight: true }); + } + + @action + edit(message) { + this.chat.activeMessage = null; + message.editing = true; + this.message = message; + this.focus({ refreshHeight: true, ensureAtEnd: true }); + } + + @action + async replyTo(message) { + this.chat.activeMessage = null; + + if (message.channel.threadingEnabled) { + if (!message.thread?.id) { + message.thread = message.channel.createStagedThread(message); + } + + this.reset(message.channel); + + await this.router.transitionTo( + "chat.channel.thread", + ...message.thread.routeModels + ); + + this.threadComposer.focus({ ensureAtEnd: true, refreshHeight: true }); + } else { + this.message.inReplyTo = message; + this.focus({ ensureAtEnd: true, refreshHeight: true }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js new file mode 100644 index 00000000000..fb3975ea87b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js @@ -0,0 +1,3 @@ +import ChatEmojiPickerManager from "./chat-emoji-picker-manager"; + +export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js new file mode 100644 index 00000000000..e2e53ea15f6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane-subscriptions-manager.js @@ -0,0 +1,50 @@ +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager"; +import ChatThreadPreview from "../models/chat-thread-preview"; +import ChatNotice from "../models/chat-notice"; + +export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager { + @service chat; + @service currentUser; + + @tracked notices = new TrackedArray(); + + get messageBusChannel() { + return `/chat/${this.model.id}`; + } + + get messageBusLastId() { + return this.model.channelMessageBusLastId; + } + + handleSentMessage() { + return; + } + + handleNotice(data) { + this.notices.push(ChatNotice.create(data)); + } + + clearNotice(notice) { + const index = this.notices.indexOf(notice); + if (index > -1) { + this.notices.splice(index, 1); + } + } + + handleThreadOriginalMessageUpdate(data) { + const message = this.messagesManager.findMessage(data.original_message_id); + if (message?.thread) { + message.thread.preview = ChatThreadPreview.create(data.preview); + } + } + + _afterDeleteMessage(targetMsg, data) { + if (this.model.currentUserMembership.lastReadMessageId === targetMsg.id) { + this.model.currentUserMembership.lastReadMessageId = + data.latest_not_deleted_message_id; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js new file mode 100644 index 00000000000..35e903abbd1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js @@ -0,0 +1,32 @@ +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import Service, { inject as service } from "@ember/service"; + +export default class ChatChannelPane extends Service { + @service chat; + + @tracked reacting = false; + @tracked selectingMessages = false; + @tracked lastSelectedMessage = null; + @tracked sending = false; + + get channel() { + return this.chat.activeChannel; + } + + get selectedMessageIds() { + return this.channel.messagesManager.selectedMessages.mapBy("id"); + } + + @action + cancelSelecting() { + this.selectingMessages = false; + this.channel.messagesManager.clearSelectedMessages(); + } + + @action + onSelectMessage(message) { + this.lastSelectedMessage = message; + this.selectingMessages = true; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js index 1a73752a07c..f55e58fb849 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -1,4 +1,5 @@ import Service, { inject as service } from "@ember/service"; +import { debounce } from "discourse-common/utils/decorators"; import Promise from "rsvp"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import { tracked } from "@glimmer/tracking"; @@ -9,7 +10,7 @@ const DIRECT_MESSAGE_CHANNELS_LIMIT = 20; /* The ChatChannelsManager service is responsible for managing the loaded chat channels. - It provides helpers to facilitate using and managing laoded channels instead of constantly + It provides helpers to facilitate using and managing loaded channels instead of constantly fetching them from the server. */ @@ -34,14 +35,30 @@ export default class ChatChannelsManager extends Service { return Object.values(this._cached); } - store(channelObject) { - let model = this.#findStale(channelObject.id); + store(channelObject, options = {}) { + let model; + + if (!options.replace) { + model = this.#findStale(channelObject.id); + } if (!model) { - model = ChatChannel.create(channelObject); + if (channelObject instanceof ChatChannel) { + model = channelObject; + } else { + model = ChatChannel.create(channelObject); + } this.#cache(model); } + if ( + channelObject.meta?.message_bus_last_ids?.channel_message_bus_last_id !== + undefined + ) { + model.channelMessageBusLastId = + channelObject.meta.message_bus_last_ids.channel_message_bus_last_id; + } + return model; } @@ -50,17 +67,11 @@ export default class ChatChannelsManager extends Service { if (!model.currentUserMembership.following) { return this.chatApi.followChannel(model.id).then((membership) => { - model.currentUserMembership.following = membership.following; - model.currentUserMembership.muted = membership.muted; - model.currentUserMembership.desktop_notification_level = - membership.desktop_notification_level; - model.currentUserMembership.mobile_notification_level = - membership.mobile_notification_level; - + model.currentUserMembership = membership; return model; }); } else { - return Promise.resolve(model); + return model; } } @@ -74,23 +85,25 @@ export default class ChatChannelsManager extends Service { }); } - get unreadCount() { - let count = 0; - this.publicMessageChannels.forEach((channel) => { - count += channel.currentUserMembership.unread_count || 0; - }); - return count; + @debounce(300) + async markAllChannelsRead() { + // The user tracking state for each channel marked read will be propagated by MessageBus + return this.chatApi.markAllChannelsAsRead(); } - get unreadUrgentCount() { - let count = 0; - this.channels.forEach((channel) => { - if (channel.isDirectMessageChannel) { - count += channel.currentUserMembership.unread_count || 0; + remove(model) { + this.chatSubscriptionsManager.stopChannelSubscription(model); + delete this._cached[model.id]; + } + + get allChannels() { + return [...this.publicMessageChannels, ...this.directMessageChannels].sort( + (a, b) => { + return b?.currentUserMembership?.lastViewedAt?.localeCompare?.( + a?.currentUserMembership?.lastViewedAt + ); } - count += channel.currentUserMembership.unread_mentions || 0; - }); - return count; + ); } get publicMessageChannels() { @@ -99,7 +112,7 @@ export default class ChatChannelsManager extends Service { (channel) => channel.isCategoryChannel && channel.currentUserMembership.following ) - .sort((a, b) => a.title.localeCompare(b.title)); + .sort((a, b) => a?.slug?.localeCompare?.(b?.slug)); } get directMessageChannels() { @@ -117,15 +130,18 @@ export default class ChatChannelsManager extends Service { async #find(id) { return this.chatApi - .getChannel(id) + .channel(id) .catch(popupAjaxError) - .then((channel) => { - this.#cache(channel); - return channel; + .then((result) => { + return this.store(result.channel); }); } #cache(channel) { + if (!channel) { + return; + } + this._cached[channel.id] = channel; } @@ -135,15 +151,21 @@ export default class ChatChannelsManager extends Service { #sortDirectMessageChannels(channels) { return channels.sort((a, b) => { - const unreadCountA = a.currentUserMembership.unread_count || 0; - const unreadCountB = b.currentUserMembership.unread_count || 0; - if (unreadCountA === unreadCountB) { - return new Date(a.get("last_message_sent_at")) > - new Date(b.get("last_message_sent_at")) + if (!a.lastMessage) { + return 1; + } + + if (!b.lastMessage) { + return -1; + } + + if (a.tracking.unreadCount === b.tracking.unreadCount) { + return new Date(a.lastMessage.createdAt) > + new Date(b.lastMessage.createdAt) ? -1 : 1; } else { - return unreadCountA > unreadCountB ? -1 : 1; + return a.tracking.unreadCount > b.tracking.unreadCount ? -1 : 1; } }); } 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 index 57ddd682dbf..25c0490b6c4 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js @@ -2,7 +2,6 @@ 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 @@ -16,15 +15,15 @@ export default class ChatComposerPresenceManager extends Service { this.leave(); } - notifyState(chatChannelId, replying) { + notifyState(channelName, replying) { if (!replying) { this.leave(); return; } - if (this._chatChannelId !== chatChannelId) { - this._enter(chatChannelId); - this._chatChannelId = chatChannelId; + if (this._channelName !== channelName) { + this._enter(channelName); + this._channelName = channelName; } if (!isTesting()) { @@ -39,17 +38,16 @@ export default class ChatComposerPresenceManager extends Service { leave() { this._presentChannel?.leave(); this._presentChannel = null; - this._chatChannelId = null; + this._channelName = null; if (this._autoLeaveTimer) { cancel(this._autoLeaveTimer); this._autoLeaveTimer = null; } } - _enter(chatChannelId) { + _enter(channelName) { 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-composer-warnings-tracker.js b/plugins/chat/assets/javascripts/discourse/services/chat-composer-warnings-tracker.js new file mode 100644 index 00000000000..165d327729d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-composer-warnings-tracker.js @@ -0,0 +1,146 @@ +import Service, { inject as service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; +import { mentionRegex } from "pretty-text/mentions"; +import { cancel } from "@ember/runloop"; +import { tracked } from "@glimmer/tracking"; + +const MENTION_RESULT = { + invalid: -1, + unreachable: 0, + over_members_limit: 1, +}; + +const MENTION_DEBOUNCE_MS = 1000; + +export default class ChatComposerWarningsTracker extends Service { + @service siteSettings; + + // Track mention hints to display warnings + @tracked unreachableGroupMentions = []; + @tracked overMembersLimitGroupMentions = []; + @tracked tooManyMentions = false; + @tracked mentionsCount = 0; + @tracked mentionsTimer = null; + + // Complimentary structure to avoid repeating mention checks. + _mentionWarningsSeen = {}; + + willDestroy() { + cancel(this.mentionsTimer); + } + + @bind + trackMentions(message) { + this.mentionsTimer = discourseDebounce( + this, + this._trackMentions, + message, + MENTION_DEBOUNCE_MS + ); + } + + @bind + _trackMentions(message) { + if (!this.siteSettings.enable_mentions) { + return; + } + + const mentions = this._extractMentions(message); + this.mentionsCount = mentions?.length; + + if (this.mentionsCount > 0) { + this.tooManyMentions = + this.mentionsCount > this.siteSettings.max_mentions_per_chat_message; + + if (!this.tooManyMentions) { + const newMentions = mentions.filter( + (mention) => !(mention in this._mentionWarningsSeen) + ); + + if (newMentions?.length > 0) { + this._recordNewWarnings(newMentions, mentions); + } else { + this._rebuildWarnings(mentions); + } + } + } else { + this.tooManyMentions = false; + this.unreachableGroupMentions = []; + this.overMembersLimitGroupMentions = []; + } + } + + _extractMentions(message) { + const regex = mentionRegex(this.siteSettings.unicode_usernames); + const mentions = []; + let mentionsLeft = true; + + while (mentionsLeft) { + const matches = message.match(regex); + + if (matches) { + const mention = matches[1] || matches[2]; + mentions.push(mention); + message = message.replaceAll(`${mention}`, ""); + + if (mentions.length > this.siteSettings.max_mentions_per_chat_message) { + mentionsLeft = false; + } + } else { + mentionsLeft = false; + } + } + + return mentions; + } + + _recordNewWarnings(newMentions, mentions) { + ajax("/chat/api/mentions/groups.json", { + data: { mentions: newMentions }, + }) + .then((newWarnings) => { + newWarnings.unreachable.forEach((warning) => { + this._mentionWarningsSeen[warning] = MENTION_RESULT["unreachable"]; + }); + + newWarnings.over_members_limit.forEach((warning) => { + this._mentionWarningsSeen[warning] = + MENTION_RESULT["over_members_limit"]; + }); + + newWarnings.invalid.forEach((warning) => { + this._mentionWarningsSeen[warning] = MENTION_RESULT["invalid"]; + }); + + this._rebuildWarnings(mentions); + }) + .catch(this._rebuildWarnings(mentions)); + } + + _rebuildWarnings(mentions) { + const newWarnings = mentions.reduce( + (memo, mention) => { + if ( + mention in this._mentionWarningsSeen && + !(this._mentionWarningsSeen[mention] === MENTION_RESULT["invalid"]) + ) { + if ( + this._mentionWarningsSeen[mention] === MENTION_RESULT["unreachable"] + ) { + memo[0].push(mention); + } else { + memo[1].push(mention); + } + } + + return memo; + }, + [[], []] + ); + + this.unreachableGroupMentions = newWarnings[0]; + this.overMembersLimitGroupMentions = newWarnings[1]; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js new file mode 100644 index 00000000000..00662f03edc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drafts-manager.js @@ -0,0 +1,25 @@ +import Service from "@ember/service"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +export default class ChatDraftsManager extends Service { + drafts = {}; + + add(message) { + if (message instanceof ChatMessage) { + this.drafts[message.channel.id] = message; + } else { + throw new Error("message must be an instance of ChatMessage"); + } + } + + get({ channelId }) { + return this.drafts[channelId]; + } + + remove({ channelId }) { + delete this.drafts[channelId]; + } + + reset() { + this.drafts = {}; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js new file mode 100644 index 00000000000..54e19a973f9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js @@ -0,0 +1,86 @@ +import Service, { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel"; +import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread"; +import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads"; +import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index"; + +const ROUTES = { + "chat.channel": { name: ChatDrawerChannel }, + "chat.channel.thread": { + name: ChatDrawerThread, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + threadId: route.params.threadId, + }; + }, + }, + "chat.channel.thread.index": { + name: ChatDrawerThread, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + threadId: route.params.threadId, + }; + }, + }, + "chat.channel.thread.near-message": { + name: ChatDrawerThread, + extractParams: (route) => { + return { + channelId: route.parent.parent.params.channelId, + threadId: route.parent.params.threadId, + messageId: route.params.messageId, + }; + }, + }, + "chat.channel.threads": { + name: ChatDrawerThreads, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + }; + }, + }, + chat: { name: ChatDrawerIndex }, + "chat.channel.near-message": { + name: ChatDrawerChannel, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + messageId: route.params.messageId, + }; + }, + }, + "chat.channel-legacy": { + name: ChatDrawerChannel, + extractParams: (route) => { + return { + channelId: route.params.channelId, + messageId: route.queryParams.messageId, + }; + }, + }, +}; + +export default class ChatDrawerRouter extends Service { + @service router; + @service chatHistory; + + @tracked component = null; + @tracked drawerRoute = null; + @tracked params = null; + + stateFor(route) { + this.drawerRoute?.deactivate?.(this.chatHistory.currentRoute); + + this.chatHistory.visit(route); + + this.drawerRoute = ROUTES[route.name]; + this.params = this.drawerRoute?.extractParams?.(route) || route.params; + this.component = this.drawerRoute?.name || ChatDrawerIndex; + + this.drawerRoute.activate?.(route); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js new file mode 100644 index 00000000000..d19c873f32b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js @@ -0,0 +1,32 @@ +import Service from "@ember/service"; +import KeyValueStore from "discourse/lib/key-value-store"; + +export default class ChatDrawerSize extends Service { + STORE_NAMESPACE = "discourse_chat_drawer_size_"; + MIN_HEIGHT = 300; + MIN_WIDTH = 250; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + get size() { + return { + width: this.store.getObject("width") || 400, + height: this.store.getObject("height") || 530, + }; + } + + set size({ width, height }) { + this.store.setObject({ + key: "width", + value: this.#min(width, this.MIN_WIDTH), + }); + this.store.setObject({ + key: "height", + value: this.#min(height, this.MIN_HEIGHT), + }); + } + + #min(number, min) { + return Math.max(number, min); + } +} 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 index b19d53b61a5..280fe7422ff 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js @@ -1,47 +1,42 @@ -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 { later } 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"; +import { action } from "@ember/object"; +import Service, { inject as service } from "@ember/service"; 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; + @service appEvents; + @tracked closing = false; @tracked loading = false; - @tracked context = null; + @tracked picker = 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; + this.picker = null; } @bind close() { - this.callback = null; this.closing = true; later(() => { @@ -52,7 +47,7 @@ export default class ChatEmojiPickerManager extends Service { this.visibleSections = DEFAULT_VISIBLE_SECTIONS; this.lastVisibleSection = DEFAULT_LAST_SECTION; this.closing = false; - this.opened = false; + this.picker = null; }, TRANSITION_TIME); } @@ -62,74 +57,23 @@ export default class ChatEmojiPickerManager extends Service { .uniq(); } - didSelectEmoji(emoji) { - this?.callback(emoji); - this.callback = null; - this.close(); - } + open(picker) { + this.loadEmojis(); - 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() }, - }, - }, - ], - }); - }); + if (this.picker) { + if (this.picker.trigger === picker.trigger) { + this.closeExisting(); + } else { + this.closeExisting(); + this.picker = picker; + } + } else { + this.picker = picker; } } - 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() { + @action + loadEmojis() { if (this.emojis) { return Promise.resolve(); } @@ -140,7 +84,6 @@ export default class ChatEmojiPickerManager extends Service { .then((emojis) => { this.emojis = emojis; }) - .catch(popupAjaxError) .finally(() => { this.loading = false; diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-history.js b/plugins/chat/assets/javascripts/discourse/services/chat-history.js new file mode 100644 index 00000000000..466529d6cf7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-history.js @@ -0,0 +1,22 @@ +import Service from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatHistory extends Service { + @tracked history; + + get previousRoute() { + if (this.history?.length > 1) { + return this.history[this.history.length - 2]; + } + } + + get currentRoute() { + if (this.history?.length > 0) { + return this.history[this.history.length - 1]; + } + } + + visit(route) { + this.history = (this.history || []).slice(-9).concat([route]); + } +} 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 deleted file mode 100644 index a5a77a1d4b0..00000000000 --- a/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js +++ /dev/null @@ -1,63 +0,0 @@ -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; - - intersectionObserver = new IntersectionObserver( - this._intersectionObserverCallback, - { - root: document, - rootMargin: "-10px", - } - ); - - mutationObserver = new MutationObserver(this._mutationObserverCallback, { - root: document, - rootMargin: "-10px", - }); - - willDestroy() { - this.intersectionObserver.disconnect(); - this.mutationObserver.disconnect(); - } - - @bind - _intersectionObserverCallback(entries) { - entries.forEach((entry) => { - entry.target.dataset.visible = entry.isIntersecting; - - if ( - !entry.target.dataset.stagedId && - entry.isIntersecting && - !isTesting() - ) { - this.chat.updateLastReadMessage(); - } - }); - } - - @bind - _mutationObserverCallback(mutationList) { - mutationList.forEach((mutation) => { - const data = mutation.target.dataset; - if (data.id && data.visible && !data.stagedId) { - this.chat.updateLastReadMessage(); - } - }); - } - - observe(element) { - this.intersectionObserver.observe(element); - this.mutationObserver.observe(element, { - attributes: true, - attributeOldValue: true, - attributeFilter: ["data-staged-id"], - }); - } - - unobserve(element) { - this.intersectionObserver.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 index 686c552515b..4e96cb76f89 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js @@ -1,18 +1,17 @@ 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"; +import { bind } from "discourse-common/utils/decorators"; export default class ChatNotificationManager extends Service { @service presence; @service chat; + @service chatStateManager; - _inChat = false; _subscribedToCore = true; _subscribedToChat = false; _countChatInDocTitle = true; @@ -36,6 +35,17 @@ export default class ChatNotificationManager extends Service { withPluginApi("0.12.1", (api) => { api.onPageChange(this._pageChanged); }); + + this._pageChanged(); + + this._chatPresenceChannel.on( + "change", + this._subscribeToCorrectNotifications + ); + this._corePresenceChannel.on( + "change", + this._subscribeToCorrectNotifications + ); } willDestroy() { @@ -45,8 +55,17 @@ export default class ChatNotificationManager extends Service { return; } + this._chatPresenceChannel.off( + "change", + this._subscribeToCorrectNotifications + ); this._chatPresenceChannel.unsubscribe(); this._chatPresenceChannel.leave(); + + this._corePresenceChannel.off( + "change", + this._subscribeToCorrectNotifications + ); this._corePresenceChannel.unsubscribe(); this._corePresenceChannel.leave(); } @@ -56,9 +75,8 @@ export default class ChatNotificationManager extends Service { } @bind - _pageChanged(path) { - this.set("_inChat", path.startsWith("/chat/channel/")); - if (this._inChat) { + _pageChanged() { + if (this.chatStateManager.isActive) { this._chatPresenceChannel.enter({ onlyWhileActive: false }); this._corePresenceChannel.leave(); } else { @@ -67,11 +85,6 @@ export default class ChatNotificationManager extends Service { } } - @observes("_chatPresenceChannel.count", "_corePresenceChannel.count") - _channelCountsChanged() { - discourseDebounce(this, this._subscribeToCorrectNotifications, 2000); - } - _coreAlertChannel() { return alertChannel(this.currentUser); } @@ -80,12 +93,13 @@ export default class ChatNotificationManager extends Service { return `/chat${alertChannel(this.currentUser)}`; } + @bind _subscribeToCorrectNotifications() { const oneTabForEachOpen = this._chatPresenceChannel.count > 0 && this._corePresenceChannel.count > 0; if (oneTabForEachOpen) { - this._inChat + this.chatStateManager.isActive ? this._subscribeToChat({ only: true }) : this._subscribeToCore({ only: true }); } else { @@ -103,6 +117,7 @@ export default class ChatNotificationManager extends Service { if (!this._subscribedToChat) { this.messageBus.subscribe(this._chatAlertChannel(), this.onMessage); + this.set("_subscribedToChat", true); } if (opts.only && this._subscribedToCore) { @@ -117,9 +132,10 @@ export default class ChatNotificationManager extends Service { } if (!this._subscribedToCore) { this.messageBus.subscribe(this._coreAlertChannel(), this.onMessage); + this.set("_subscribedToCore", true); } - if (this.only && this._subscribedToChat) { + if (opts.only && this._subscribedToChat) { this.messageBus.unsubscribe(this._chatAlertChannel(), this.onMessage); this.set("_subscribedToChat", false); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-pane-base-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-pane-base-subscriptions-manager.js new file mode 100644 index 00000000000..327b9e2bbf8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-pane-base-subscriptions-manager.js @@ -0,0 +1,272 @@ +import Service, { inject as service } from "@ember/service"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import ChatMessageMentionWarning from "discourse/plugins/chat/discourse/models/chat-message-mention-warning"; +import { cloneJSON } from "discourse-common/lib/object"; +import { bind } from "discourse-common/utils/decorators"; + +export function handleStagedMessage(channel, messagesManager, data) { + const stagedMessage = messagesManager.findStagedMessage(data.staged_id); + + if (!stagedMessage) { + return; + } + + stagedMessage.error = null; + stagedMessage.id = data.chat_message.id; + stagedMessage.staged = false; + stagedMessage.excerpt = data.chat_message.excerpt; + stagedMessage.channel = channel; + stagedMessage.createdAt = new Date(data.chat_message.created_at); + stagedMessage.cooked = data.chat_message.cooked; + + return stagedMessage; +} + +/** + * Handles subscriptions for MessageBus messages sent from Chat::Publisher + * to the channel and thread panes. There are individual services for + * each (ChatChannelPaneSubscriptionsManager and ChatThreadPaneSubscriptionsManager) + * that implement their own logic where necessary. Functions which will + * always be different between the two raise a "not implemented" error in + * the base class, and the child class must define the associated function, + * even if it is a noop in that context. + * + * For example, in the thread context there is no need to handle the thread + * creation event, because the panel will not be open in that case. + */ +export default class ChatPaneBaseSubscriptionsManager extends Service { + @service chat; + @service currentUser; + @service chatStagedThreadMapping; + + get messageBusChannel() { + throw "not implemented"; + } + + get messageBusLastId() { + throw "not implemented"; + } + + get messagesManager() { + return this.model.messagesManager; + } + + subscribe(model) { + this.unsubscribe(); + this.model = model; + this.messageBus.subscribe( + this.messageBusChannel, + this.onMessage, + this.messageBusLastId + ); + } + + unsubscribe() { + if (!this.model) { + return; + } + + this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage); + this.model = null; + } + + handleStagedMessageInternal(channel, data) { + return handleStagedMessage(channel, this.messagesManager, data); + } + + @bind + onMessage(busData) { + switch (busData.type) { + case "sent": + this.handleSentMessage(busData); + break; + case "reaction": + this.handleReactionMessage(busData); + break; + case "processed": + this.handleProcessedMessage(busData); + break; + case "edit": + this.handleEditMessage(busData); + break; + case "refresh": + this.handleRefreshMessage(busData); + break; + case "delete": + this.handleDeleteMessage(busData); + break; + case "bulk_delete": + this.handleBulkDeleteMessage(busData); + break; + case "restore": + this.handleRestoreMessage(busData); + break; + case "mention_warning": + this.handleMentionWarning(busData); + break; + case "self_flagged": + this.handleSelfFlaggedMessage(busData); + break; + case "flag": + this.handleFlaggedMessage(busData); + break; + case "thread_created": + this.handleNewThreadCreated(busData); + break; + case "update_thread_original_message": + this.handleThreadOriginalMessageUpdate(busData); + break; + case "notice": + this.handleNotice(busData); + break; + } + } + + handleSentMessage() { + throw "not implemented"; + } + + handleProcessedMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message.id); + if (message) { + message.cooked = data.chat_message.cooked; + } + } + + handleReactionMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message_id); + if (message) { + message.react(data.emoji, data.action, data.user, this.currentUser.id); + } + } + + handleEditMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message.id); + if (message) { + message.message = data.chat_message.message; + message.cooked = data.chat_message.cooked; + message.excerpt = data.chat_message.excerpt; + message.uploads = cloneJSON(data.chat_message.uploads || []); + message.edited = true; + } + } + + handleRefreshMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message.id); + if (message) { + message.incrementVersion(); + } + } + + 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.messagesManager.findMessage(deletedId); + + if (!targetMsg) { + return; + } + + if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { + targetMsg.deletedAt = data.deleted_at; + targetMsg.deletedById = data.deleted_by_id; + targetMsg.expanded = false; + } else { + this.messagesManager.removeMessage(targetMsg); + } + + this._afterDeleteMessage(targetMsg, data); + } + + handleRestoreMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message.id); + if (message) { + message.deletedAt = null; + } else { + const newMessage = ChatMessage.create(this.model, data.chat_message); + newMessage.manager = this.messagesManager; + this.messagesManager.addMessages([newMessage]); + } + } + + handleMentionWarning(data) { + const message = this.messagesManager.findMessage(data.chat_message_id); + if (message) { + message.mentionWarning = ChatMessageMentionWarning.create(message, data); + } + } + + handleSelfFlaggedMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message_id); + if (message) { + message.userFlagStatus = data.user_flag_status; + } + } + + handleFlaggedMessage(data) { + const message = this.messagesManager.findMessage(data.chat_message_id); + if (message) { + message.reviewableId = data.reviewable_id; + } + } + + handleNewThreadCreated(data) { + this.model.threadsManager + .find(this.model.id, data.staged_thread_id, { fetchIfNotFound: false }) + .then((stagedThread) => { + if (stagedThread) { + this.chatStagedThreadMapping.setMapping( + data.thread_id, + stagedThread.id + ); + stagedThread.staged = false; + stagedThread.id = data.thread_id; + stagedThread.originalMessage.thread = stagedThread; + stagedThread.originalMessage.thread.preview.replyCount ??= 1; + + // We have to do this because the thread manager cache is keyed by + // staged_thread_id, but the thread_id is what we want to use to + // look up the thread, otherwise calls to .find() will not return + // the thread by its actual ID, and we will end up with double-ups + // in places like the thread list when .add() is called. + this.model.threadsManager.remove({ id: data.staged_thread_id }); + this.model.threadsManager.add(this.model, stagedThread, { + replace: true, + }); + } else if (data.thread_id) { + this.model.threadsManager + .find(this.model.id, data.thread_id, { fetchIfNotFound: true }) + .then((thread) => { + const channelOriginalMessage = + this.model.messagesManager.findMessage( + thread.originalMessage.id + ); + + if (channelOriginalMessage) { + channelOriginalMessage.thread = thread; + } + }); + } + }); + } + + handleThreadOriginalMessageUpdate() { + throw "not implemented"; + } + + handleNotice() { + throw "not implemented"; + } + + _afterDeleteMessage() { + throw "not implemented"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js new file mode 100644 index 00000000000..e09e354fc67 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js @@ -0,0 +1,24 @@ +import Service from "@ember/service"; +import KeyValueStore from "discourse/lib/key-value-store"; + +export default class ChatSidePanelSize extends Service { + STORE_NAMESPACE = "discourse_chat_side_panel_size_"; + MIN_WIDTH = 250; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + get width() { + return this.store.getObject("width") || this.MIN_WIDTH; + } + + set width(width) { + this.store.setObject({ + key: "width", + value: this.#min(width, this.MIN_WIDTH), + }); + } + + #min(number, min) { + return Math.max(number, min); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-staged-thread-mapping.js b/plugins/chat/assets/javascripts/discourse/services/chat-staged-thread-mapping.js new file mode 100644 index 00000000000..747e92e3df0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-staged-thread-mapping.js @@ -0,0 +1,34 @@ +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; + +export default class ChatStagedThreadMapping extends Service { + STORE_NAMESPACE = "discourse_chat_"; + KEY = "staged_thread"; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + constructor() { + super(...arguments); + + if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) { + this.storedFavorites = []; + } + } + + getMapping() { + return JSON.parse(this.store.getObject(this.KEY) || "{}"); + } + + setMapping(id, stagedId) { + const mapping = {}; + mapping[stagedId] = id; + this.store.setObject({ + key: this.KEY, + value: JSON.stringify(mapping), + }); + } + + reset() { + this.store.setObject({ key: this.KEY, value: "{}" }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js index ee79bb20bb3..88608e1a41d 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-state-manager.js @@ -3,17 +3,31 @@ 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"; +import getURL from "discourse-common/lib/get-url"; 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"; +let chatDrawerStateCallbacks = []; + +export function addChatDrawerStateCallback(callback) { + chatDrawerStateCallbacks.push(callback); +} + +export function resetChatDrawerStateCallbacks() { + chatDrawerStateCallbacks = []; +} export default class ChatStateManager extends Service { @service chat; + @service chatHistory; @service router; - isDrawerExpanded = false; - isDrawerActive = false; + + @tracked isSidePanelExpanded = false; + @tracked isDrawerExpanded = false; + @tracked isDrawerActive = false; + @tracked _chatURL = null; @tracked _appURL = null; @@ -33,37 +47,49 @@ export default class ChatStateManager extends Service { this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT }); } - didOpenDrawer(URL = null) { - this.set("isDrawerActive", true); - this.set("isDrawerExpanded", true); + openSidePanel() { + this.isSidePanelExpanded = true; + } - if (URL) { - this.storeChatURL(URL); + closeSidePanel() { + this.isSidePanelExpanded = false; + } + + didOpenDrawer(url = null) { + this.isDrawerActive = true; + this.isDrawerExpanded = true; + + if (url) { + this.storeChatURL(url); } this.chat.updatePresence(); + this.#publishStateChange(); } didCloseDrawer() { - this.set("isDrawerActive", false); - this.set("isDrawerExpanded", false); + this.isDrawerActive = false; + this.isDrawerExpanded = false; this.chat.updatePresence(); + this.#publishStateChange(); } didExpandDrawer() { - this.set("isDrawerActive", true); - this.set("isDrawerExpanded", true); + this.isDrawerActive = true; + this.isDrawerExpanded = true; this.chat.updatePresence(); } didCollapseDrawer() { - this.set("isDrawerActive", true); - this.set("isDrawerExpanded", false); + this.isDrawerActive = true; + this.isDrawerExpanded = false; + this.#publishStateChange(); } didToggleDrawer() { - this.set("isDrawerExpanded", !this.isDrawerExpanded); - this.set("isDrawerActive", true); + this.isDrawerExpanded = !this.isDrawerExpanded; + this.isDrawerActive = true; + this.#publishStateChange(); } get isFullPagePreferred() { @@ -90,12 +116,18 @@ export default class ChatStateManager extends Service { return this.isFullPageActive || this.isDrawerActive; } - storeAppURL(URL = null) { - this._appURL = URL || this.router.currentURL; + storeAppURL(url = null) { + if (url) { + this._appURL = url; + } else if (this.router.currentURL?.startsWith("/chat")) { + this._appURL = "/"; + } else { + this._appURL = this.router.currentURL; + } } - storeChatURL(URL = null) { - this._chatURL = URL || this.router.currentURL; + storeChatURL(url) { + this._chatURL = url; } get lastKnownAppURL() { @@ -104,10 +136,19 @@ export default class ChatStateManager extends Service { url = this.router.urlFor(`discovery.${defaultHomepage()}`); } - return url; + return getURL(url); } get lastKnownChatURL() { - return this._chatURL || "/chat"; + return getURL(this._chatURL || "/chat"); + } + + #publishStateChange() { + const state = { + isDrawerActive: this.isDrawerActive, + isDrawerExpanded: this.isDrawerExpanded, + }; + + chatDrawerStateCallbacks.forEach((callback) => callback(state)); } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js index 4b58c3153b9..ea3531f47b5 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js @@ -1,12 +1,18 @@ import Service, { inject as service } from "@ember/service"; +import I18n from "I18n"; import { bind } from "discourse-common/utils/decorators"; import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import ChatChannelArchive from "../models/chat-channel-archive"; export default class ChatSubscriptionsManager extends Service { @service store; @service chatChannelsManager; + @service chatTrackingStateManager; @service currentUser; @service appEvents; + @service chat; + @service dialog; + @service router; _channelSubscriptions = new Set(); @@ -22,6 +28,7 @@ export default class ChatSubscriptionsManager extends Service { if (!channel.isDirectMessageChannel) { this._startChannelMentionsSubscription(channel); + this._startKickFromChannelSubscription(channel); } this._startChannelNewMessagesSubscription(channel); @@ -37,6 +44,10 @@ export default class ChatSubscriptionsManager extends Service { `/chat/${channel.id}/new-mentions`, this._onNewMentions ); + this.messageBus.unsubscribe( + `/chat/${channel.id}/kick`, + this._onKickFromChannel + ); } this._channelSubscriptions.delete(channel.id); @@ -93,6 +104,14 @@ export default class ChatSubscriptionsManager extends Service { ); } + _startKickFromChannelSubscription(channel) { + this.messageBus.subscribe( + `/chat/${channel.id}/kick`, + this._onKickFromChannel, + channel.meta.message_bus_last_ids.kick + ); + } + @bind _onChannelArchiveStatusUpdate(busData) { // we don't want to fetch a channel we don't have locally because archive status changed @@ -103,13 +122,7 @@ export default class ChatSubscriptionsManager extends Service { return; } - 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, - }); + channel.archive = ChatChannelArchive.create(busData); }); } @@ -117,8 +130,36 @@ export default class ChatSubscriptionsManager extends Service { _onNewMentions(busData) { this.chatChannelsManager.find(busData.channel_id).then((channel) => { const membership = channel.currentUserMembership; - if (busData.message_id > membership?.last_read_message_id) { - membership.unread_mentions = (membership.unread_mentions || 0) + 1; + if (busData.message_id > membership?.lastReadMessageId) { + channel.tracking.mentionCount++; + } + }); + } + + @bind + _onKickFromChannel(busData) { + this.chatChannelsManager.find(busData.channel_id).then((channel) => { + if (this.chat.activeChannel.id === channel.id) { + this.dialog.alert({ + message: I18n.t("chat.kicked_from_channel"), + didConfirm: () => { + this.chatChannelsManager.remove(channel); + + const firstChannel = + this.chatChannelsManager.publicMessageChannels[0]; + + if (firstChannel) { + this.router.transitionTo( + "chat.channel", + ...firstChannel.routeModels + ); + } else { + this.router.transitionTo("chat.browse"); + } + }, + }); + } else { + this.chatChannelsManager.remove(channel); } }); } @@ -133,31 +174,114 @@ export default class ChatSubscriptionsManager extends Service { @bind _onNewMessages(busData) { + switch (busData.type) { + case "channel": + this._onNewChannelMessage(busData); + break; + case "thread": + this._onNewThreadMessage(busData); + break; + } + } + + _onNewChannelMessage(busData) { this.chatChannelsManager.find(busData.channel_id).then((channel) => { - if (busData.user_id === this.currentUser.id) { + channel.lastMessage = busData.message; + const user = busData.message.user; + if (user.id === this.currentUser.id) { // User sent message, update tracking state to no unread - channel.currentUserMembership.last_read_message_id = busData.message_id; + channel.currentUserMembership.lastReadMessageId = + channel.lastMessage.id; } else { // Ignored user sent message, update tracking state to no unread - if (this.currentUser.ignored_users.includes(busData.username)) { - channel.currentUserMembership.last_read_message_id = - busData.message_id; + if (this.currentUser.ignored_users.includes(user.username)) { + channel.currentUserMembership.lastReadMessageId = + channel.lastMessage.id; } else { - // Message from other user. Increment trackings state if ( - busData.message_id > - (channel.currentUserMembership.last_read_message_id || 0) + channel.lastMessage.id > + (channel.currentUserMembership.lastReadMessageId || 0) ) { - channel.currentUserMembership.unread_count = - channel.currentUserMembership.unread_count + 1; + channel.tracking.unreadCount++; + } + + // Thread should be considered unread if not already. + if (busData.thread_id && channel.threadingEnabled) { + channel.threadsManager + .find(channel.id, busData.thread_id) + .then((thread) => { + if (thread.currentUserMembership) { + channel.threadsManager.markThreadUnread( + busData.thread_id, + busData.message.created_at + ); + this._updateActiveLastViewedAt(channel); + } + }); } } } - - channel.set("last_message_sent_at", new Date()); }); } + _onNewThreadMessage(busData) { + this.chatChannelsManager.find(busData.channel_id).then((channel) => { + if (!channel.threadingEnabled) { + return; + } + + channel.threadsManager + .find(busData.channel_id, busData.thread_id) + .then((thread) => { + if (busData.message.user.id === this.currentUser.id) { + // Thread should no longer be considered unread. + if (thread.currentUserMembership) { + channel.threadsManager.unreadThreadOverview.delete( + parseInt(busData.thread_id, 10) + ); + thread.currentUserMembership.lastReadMessageId = + busData.message.id; + } + } else { + // Ignored user sent message, update tracking state to no unread + if ( + this.currentUser.ignored_users.includes( + busData.message.user.username + ) + ) { + if (thread.currentUserMembership) { + thread.currentUserMembership.lastReadMessageId = + busData.message.id; + } + } else { + // Message from other user. Increment unread for thread tracking state. + if ( + thread.currentUserMembership && + busData.message.id > + (thread.currentUserMembership.lastReadMessageId || 0) && + !thread.currentUserMembership.isQuiet + ) { + channel.threadsManager.markThreadUnread( + busData.thread_id, + busData.message.created_at + ); + thread.tracking.unreadCount++; + this._updateActiveLastViewedAt(channel); + } + } + } + }); + }); + } + + // If the user is currently looking at this channel via activeChannel, we don't want the unread + // indicator to show in the sidebar for unread threads (since that is based on the lastViewedAt). + _updateActiveLastViewedAt(channel) { + if (this.chat.activeChannel?.id === channel.id) { + channel.updateLastViewedAt(); + } + } + _startUserTrackingStateSubscription(lastId) { if (!this.currentUser) { return; @@ -168,6 +292,11 @@ export default class ChatSubscriptionsManager extends Service { this._onUserTrackingStateUpdate, lastId ); + this.messageBus.subscribe( + `/chat/bulk-user-tracking-state/${this.currentUser.id}`, + this._onBulkUserTrackingStateUpdate, + lastId + ); } _stopUserTrackingStateSubscription() { @@ -179,19 +308,64 @@ export default class ChatSubscriptionsManager extends Service { `/chat/user-tracking-state/${this.currentUser.id}`, this._onUserTrackingStateUpdate ); + + this.messageBus.unsubscribe( + `/chat/bulk-user-tracking-state/${this.currentUser.id}`, + this._onBulkUserTrackingStateUpdate + ); + } + + @bind + _onBulkUserTrackingStateUpdate(busData) { + Object.keys(busData).forEach((channelId) => { + this._updateChannelTrackingData(channelId, busData[channelId]); + }); } @bind _onUserTrackingStateUpdate(busData) { - this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { + this._updateChannelTrackingData(busData.channel_id, busData); + } + + @bind + _updateChannelTrackingData(channelId, busData) { + this.chatChannelsManager.find(channelId).then((channel) => { + if (!busData.thread_id) { + channel.currentUserMembership.lastReadMessageId = + busData.last_read_message_id; + } + + channel.tracking.unreadCount = busData.unread_count; + channel.tracking.mentionCount = busData.mention_count; + if ( - channel?.currentUserMembership?.last_read_message_id <= - busData.chat_message_id + busData.hasOwnProperty("unread_thread_overview") && + channel.threadingEnabled ) { - channel.currentUserMembership.last_read_message_id = - busData.chat_message_id; - channel.currentUserMembership.unread_count = 0; - channel.currentUserMembership.unread_mentions = 0; + channel.threadsManager.unreadThreadOverview = + busData.unread_thread_overview; + } + + if ( + busData.thread_id && + busData.hasOwnProperty("thread_tracking") && + channel.threadingEnabled + ) { + channel.threadsManager + .find(channelId, busData.thread_id) + .then((thread) => { + if ( + thread.currentUserMembership && + !thread.currentUserMembership.isQuiet + ) { + thread.currentUserMembership.lastReadMessageId = + busData.last_read_message_id; + thread.tracking.unreadCount = + busData.thread_tracking.unread_count; + thread.tracking.mentionCount = + busData.thread_tracking.mention_count; + } + }); } }); } @@ -214,14 +388,15 @@ export default class ChatSubscriptionsManager extends Service { @bind _onNewChannelSubscription(data) { this.chatChannelsManager.find(data.channel.id).then((channel) => { - // we need to refrehs here to have correct last message ids + // we need to refresh here to have correct last message ids channel.meta = data.channel.meta; + channel.currentUserMembership = data.channel.current_user_membership; if ( channel.isDirectMessageChannel && !channel.currentUserMembership.following ) { - channel.currentUserMembership.unread_count = 1; + channel.tracking.unreadCount = 1; } this.chatChannelsManager.follow(channel); @@ -269,24 +444,23 @@ export default class ChatSubscriptionsManager extends Service { @bind _onChannelMetadata(busData) { - this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { - if (channel) { - channel.setProperties({ - memberships_count: busData.memberships_count, - }); - this.appEvents.trigger("chat:refresh-channel-members"); - } - }); + this.chatChannelsManager + .find(busData.chat_channel_id, { fetchIfNotFound: false }) + .then((channel) => { + if (channel) { + channel.membershipsCount = busData.memberships_count; + this.appEvents.trigger("chat:refresh-channel-members"); + } + }); } @bind _onChannelEdits(busData) { this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { if (channel) { - channel.setProperties({ - title: busData.name, - description: busData.description, - }); + channel.title = busData.name; + channel.description = busData.description; + channel.slug = busData.slug; } }); } @@ -294,15 +468,14 @@ export default class ChatSubscriptionsManager extends Service { @bind _onChannelStatus(busData) { this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { - channel.set("status", busData.status); + channel.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) { - channel.currentUserMembership.unread_count = 0; - channel.currentUserMembership.unread_mentions = 0; + channel.tracking.reset(); } }); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js new file mode 100644 index 00000000000..318846adc96 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js @@ -0,0 +1,51 @@ +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { action } from "@ember/object"; +import Service, { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatThreadComposer extends Service { + @service chat; + + @tracked message; + @tracked textarea; + + @action + focus(options = {}) { + this.textarea?.focus(options); + } + + @action + blur() { + this.textarea?.blur(); + } + + @action + reset(thread) { + this.message = ChatMessage.createDraftMessage(thread.channel, { + user: this.currentUser, + thread, + }); + } + + @action + cancel() { + if (this.message.editing) { + this.reset(this.message.thread); + } else if (this.message.inReplyTo) { + this.message.inReplyTo = null; + } + } + + @action + edit(message) { + this.chat.activeMessage = null; + message.editing = true; + this.message = message; + this.focus({ refreshHeight: true, ensureAtEnd: true }); + } + + @action + replyTo() { + this.chat.activeMessage = null; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-list-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-list-pane.js new file mode 100644 index 00000000000..4b42bdfd67a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-list-pane.js @@ -0,0 +1,24 @@ +import Service, { inject as service } from "@ember/service"; + +export default class ChatThreadListPane extends Service { + @service chat; + @service router; + + get isOpened() { + return this.router.currentRoute.name === "chat.channel.threads"; + } + + async close() { + await this.router.transitionTo( + "chat.channel", + ...this.chat.activeChannel.routeModels + ); + } + + async open() { + await this.router.transitionTo( + "chat.channel.threads", + ...this.chat.activeChannel.routeModels + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane-subscriptions-manager.js new file mode 100644 index 00000000000..1b1845eafae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane-subscriptions-manager.js @@ -0,0 +1,47 @@ +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager"; + +export default class ChatThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager { + get messageBusChannel() { + return `/chat/${this.model.channel.id}/thread/${this.model.id}`; + } + + get messageBusLastId() { + return this.model.threadMessageBusLastId; + } + + handleSentMessage(data) { + if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { + const stagedMessage = this.handleStagedMessageInternal( + this.model.channel, + data + ); + if (stagedMessage) { + return; + } + } + + const message = ChatMessage.create(this.model.channel, data.chat_message); + message.thread = this.model; + message.manager = this.messagesManager; + this.messagesManager.addMessages([message]); + } + + // NOTE: noop, there is nothing to do when a thread original message + // is updated inside the thread panel (for now). + handleThreadOriginalMessageUpdate() { + return; + } + + // NOTE: We don't yet handle notices inside of threads so do nothing. + handleNotice() { + return; + } + + _afterDeleteMessage(targetMsg, data) { + if (this.model.currentUserMembership?.lastReadMessageId === targetMsg.id) { + this.model.currentUserMembership.lastReadMessageId = + data.latest_not_deleted_message_id; + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane.js new file mode 100644 index 00000000000..526d3422cb3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-pane.js @@ -0,0 +1,30 @@ +import ChatChannelPane from "./chat-channel-pane"; +import { inject as service } from "@ember/service"; + +export default class ChatThreadPane extends ChatChannelPane { + @service chat; + @service router; + + get thread() { + return this.channel?.activeThread; + } + + get isOpened() { + return this.router.currentRoute.name === "chat.channel.thread"; + } + + get selectedMessageIds() { + return this.thread.messagesManager.selectedMessages.mapBy("id"); + } + + async close() { + await this.router.transitionTo("chat.channel", ...this.channel.routeModels); + } + + async open(thread) { + await this.router.transitionTo( + "chat.channel.thread", + ...thread.routeModels + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-tracking-state-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-tracking-state-manager.js new file mode 100644 index 00000000000..208eac437b6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-tracking-state-manager.js @@ -0,0 +1,106 @@ +import Service, { inject as service } from "@ember/service"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { cancel } from "@ember/runloop"; +import ChatTrackingState from "discourse/plugins/chat/discourse/models/chat-tracking-state"; +import { getOwner } from "discourse-common/lib/get-owner"; + +/** + * This service is used to provide a global interface to tracking individual + * channels and threads. In many places in the app, we need to know the global + * unread count for channels, threads, etc. + * + * The individual tracking state of each channel and thread is stored in + * a ChatTrackingState class instance and changed via the getters/setters + * provided there. + * + * This service is also used to preload bulk tracking state for channels + * and threads, which is used when the user first loads the app, and in + * certain cases where we need to set the state for many items at once. + */ +export default class ChatTrackingStateManager extends Service { + @service chatChannelsManager; + @service appEvents; + + // NOTE: In future, we may want to preload some thread tracking state + // as well, but for now we do that on demand when the user opens a channel, + // to avoid having to load all the threads across all channels into memory at once. + setupWithPreloadedState({ channel_tracking = {} }) { + this.chatChannelsManager.channels.forEach((channel) => { + if (channel_tracking[channel.id.toString()]) { + this.#setState(channel, channel_tracking[channel.id.toString()]); + } + }); + } + + setupChannelThreadState(channel, threadTracking) { + channel.threadsManager.threads.forEach((thread) => { + const tracking = threadTracking[thread.id.toString()]; + if (tracking) { + this.#setState(thread, tracking); + } + }); + } + + get publicChannelUnreadCount() { + return this.#publicChannels().reduce((unreadCount, channel) => { + return unreadCount + channel.tracking.unreadCount; + }, 0); + } + + get allChannelUrgentCount() { + let publicChannelMentionCount = this.#publicChannels().reduce( + (mentionCount, channel) => { + return mentionCount + channel.tracking.mentionCount; + }, + 0 + ); + + let dmChannelUnreadCount = this.#directMessageChannels().reduce( + (unreadCount, channel) => { + return unreadCount + channel.tracking.unreadCount; + }, + 0 + ); + + return publicChannelMentionCount + dmChannelUnreadCount; + } + + willDestroy() { + super.willDestroy(...arguments); + cancel(this._onTriggerNotificationDebounceHandler); + } + + /** + * Some reactivity in the app such as the document title + * updates are only done via appEvents -- rather than + * sprinkle this appEvent call everywhere we just define + * it here so it can be changed as required. + */ + triggerNotificationsChanged() { + this._onTriggerNotificationDebounceHandler = discourseDebounce( + this, + this.#triggerNotificationsChanged, + 100 + ); + } + + #triggerNotificationsChanged() { + this.appEvents.trigger("notifications:changed"); + } + + #setState(model, state) { + if (!model.tracking) { + model.tracking = new ChatTrackingState(getOwner(this)); + } + model.tracking.unreadCount = state.unread_count; + model.tracking.mentionCount = state.mention_count; + } + + #publicChannels() { + return this.chatChannelsManager.publicMessageChannels; + } + + #directMessageChannels() { + return this.chatChannelsManager.directMessageChannels; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 931f480e438..c1ed707f8c4 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -1,42 +1,38 @@ -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 { tracked } from "@glimmer/tracking"; 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 { generateCookFunction } from "discourse/lib/text"; import { cancel, next } from "@ember/runloop"; import { and } from "@ember/object/computed"; import { computed } from "@ember/object"; -import { Promise } from "rsvp"; -import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; -import discourseDebounce from "discourse-common/lib/debounce"; 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"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { + onPresenceChange, + removeOnPresenceChange, +} from "discourse/lib/user-presence"; +import { bind } from "discourse-common/utils/decorators"; 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 chatApi; @service appEvents; + @service currentUser; @service chatNotificationManager; @service chatSubscriptionsManager; @service chatStateManager; + @service chatDraftsManager; @service presence; @service router; @service site; @service chatChannelsManager; + @service chatTrackingStateManager; - activeChannel = null; cook = null; presenceChannel = null; sidebarActive = false; @@ -44,6 +40,25 @@ export default class Chat extends Service { @and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat; + @tracked _activeMessage = null; + @tracked _activeChannel = null; + + get activeChannel() { + return this._activeChannel; + } + + set activeChannel(channel) { + if (!channel) { + this._activeMessage = null; + } + + if (this._activeChannel) { + this._activeChannel.activeThread = null; + } + + this._activeChannel = channel; + } + @computed("currentUser.staff", "currentUser.groups.[]") get userCanDirectMessage() { if (!this.currentUser) { @@ -60,18 +75,81 @@ export default class Chat extends Service { ); } + @computed("activeChannel.userSilenced") + get userCanInteractWithChat() { + return !this.activeChannel?.userSilenced; + } + + get activeMessage() { + return this._activeMessage; + } + + set activeMessage(hash) { + if (hash) { + this._activeMessage = hash; + } else { + this._activeMessage = null; + } + } + init() { super.init(...arguments); if (this.userCanChat) { 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); + onPresenceChange({ + callback: this.onPresenceChangeCallback, + browserHiddenTime: 150000, + userUnseenTime: 150000, + }); + } + } + + @bind + onPresenceChangeCallback(present) { + if (present) { + // NOTE: channels is more than a simple array, it also contains + // tracking and membership data, see Chat::StructuredChannelSerializer + this.chatApi.listCurrentUserChannels().then((channelsView) => { + this.chatSubscriptionsManager.stopChannelsSubscriptions(); + this.chatSubscriptionsManager.startChannelsSubscriptions( + channelsView.meta.message_bus_last_ids + ); + + [ + ...channelsView.public_channels, + ...channelsView.direct_message_channels, + ].forEach((channelObject) => { + this.chatChannelsManager + .find(channelObject.id, { fetchIfNotFound: false }) + .then((channel) => { + if (!channel) { + return; + } + // TODO (martin) We need to do something here for thread tracking + // state as well on presence change, otherwise we will be back in + // the same place as the channels were. + // + // At some point it would likely be better to just fetch an + // endpoint that gives you all channel tracking state and the + // thread tracking state for the current channel. + + // ensures we have the latest message bus ids + channel.meta.message_bus_last_ids = + channelObject.meta.message_bus_last_ids; + + const state = channelsView.tracking.channel_tracking[channel.id]; + channel.tracking.unreadCount = state.unread_count; + channel.tracking.mentionCount = state.mention_count; + + channel.currentUserMembership = + channelObject.current_user_membership; + + this.chatSubscriptionsManager.startChannelSubscription(channel); + }); }); - } + }); } } @@ -95,17 +173,43 @@ export default class Chat extends Service { this.set("isNetworkUnreliable", false); } - setupWithPreloadedChannels(channels) { + setupWithPreloadedChannels(channelsView) { this.chatSubscriptionsManager.startChannelsSubscriptions( - channels.meta.message_bus_last_ids + channelsView.meta.message_bus_last_ids ); - this.presenceChannel.subscribe(channels.global_presence_channel_state); + this.presenceChannel.subscribe(channelsView.global_presence_channel_state); - [...channels.public_channels, ...channels.direct_message_channels].forEach( - (channelObject) => { - const channel = this.chatChannelsManager.store(channelObject); - return this.chatChannelsManager.follow(channel); + [ + ...channelsView.public_channels, + ...channelsView.direct_message_channels, + ].forEach((channelObject) => { + const storedChannel = this.chatChannelsManager.store(channelObject); + const storedDraft = (this.currentUser?.chat_drafts || []).find( + (draft) => draft.channel_id === storedChannel.id + ); + + if (storedDraft) { + this.chatDraftsManager.add( + ChatMessage.createDraftMessage( + storedChannel, + Object.assign( + { user: this.currentUser }, + JSON.parse(storedDraft.data) + ) + ) + ); } + + if (channelsView.unread_thread_overview?.[storedChannel.id]) { + storedChannel.threadsManager.unreadThreadOverview = + channelsView.unread_thread_overview[storedChannel.id]; + } + + return this.chatChannelsManager.follow(storedChannel); + }); + + this.chatTrackingStateManager.setupWithPreloadedState( + channelsView.tracking ); } @@ -114,46 +218,20 @@ export default class Chat extends Service { if (this.userCanChat) { this.chatSubscriptionsManager.stopChannelsSubscriptions(); + removeOnPresenceChange(this.onPresenceChangeCallback); } } - setActiveChannel(channel) { - this.set("activeChannel", channel); - } - - loadCookFunction(categories) { - if (this.cook) { - return Promise.resolve(this.cook); - } - - const markdownOptions = { - featuresOverride: Site.currentProp( - "markdown_additional_options.chat.limited_pretty_text_features" - ), - markdownItRules: Site.currentProp( - "markdown_additional_options.chat.limited_pretty_text_markdown_rules" - ), - hashtagTypesInPriorityOrder: - this.site.hashtag_configurations["chat-composer"], - hashtagIcons: this.site.hashtag_icons, - }; - - return generateCookFunction(markdownOptions).then((cookFunction) => { - return this.set("cook", (raw) => { - return simpleCategoryHashMentionTransform( - cookFunction(raw), - categories - ); - }); - }); - } - updatePresence() { next(() => { if (this.isDestroyed || this.isDestroying) { return; } + if (this.currentUser.user_option?.hide_profile_and_presence) { + return; + } + if (this.chatStateManager.isActive) { this.presenceChannel.enter({ activeOptions: CHAT_ONLINE_OPTIONS }); } else { @@ -164,7 +242,7 @@ export default class Chat extends Service { getDocumentTitleCount() { return this.chatNotificationManager.shouldCountChatInDocTitle() - ? this.chatChannelsManager.unreadUrgentCount + ? this.chatTrackingStateManager.allChannelUrgentCount : 0; } @@ -192,7 +270,10 @@ export default class Chat extends Service { currentList[currentChannelIndex + (directionUp ? -1 : 1)]; if (nextChannelInSameList) { // You're navigating in the same list of channels, just use index +- 1 - return this.openChannel(nextChannelInSameList); + return this.router.transitionTo( + "chat.channel", + ...nextChannelInSameList.routeModels + ); } // You need to go to the next list of channels, if it exists. @@ -202,15 +283,13 @@ export default class Chat extends Service { : nextList[0]; if (nextChannel.id !== activeChannel.id) { - return this.openChannel(nextChannel); + return this.router.transitionTo( + "chat.channel", + ...nextChannel.routeModels + ); } } - 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. // @@ -232,8 +311,12 @@ export default class Chat extends Service { this.chatChannelsManager.channels.forEach((channel) => { const membership = channel.currentUserMembership; + if (!membership.following) { + return; + } + if (channel.isDirectMessageChannel) { - if (!dmChannelWithUnread && membership.unread_count > 0) { + if (!dmChannelWithUnread && channel.tracking.unreadCount > 0) { dmChannelWithUnread = channel.id; } else if (!dmChannel) { dmChannel = channel.id; @@ -242,7 +325,10 @@ export default class Chat extends Service { if (membership.unread_mentions > 0) { publicChannelWithMention = channel.id; return; // <- We have a public channel with a mention. Break and return this. - } else if (!publicChannelWithUnread && membership.unread_count > 0) { + } else if ( + !publicChannelWithUnread && + channel.tracking.unreadCount > 0 + ) { publicChannelWithUnread = channel.id; } else if ( !defaultChannel && @@ -266,59 +352,14 @@ export default class Chat extends Service { ); } - async openChannelAtMessage(channelId, messageId = null) { - return this.chatChannelsManager.find(channelId).then((channel) => { - return this._openFoundChannelAtMessage(channel, messageId); - }); - } - - 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.isFullPageActive || - 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, + ? this.router.transitionTo( + "chat.channel.near-message", + ...channel.routeModels, messageId ) - : this.appEvents.trigger("chat:open-channel", channel); - } - - _fireOpenMessageAppEvent(messageId) { - this.appEvents.trigger("chat-live-pane:highlight-message", messageId); + : this.router.transitionTo("chat.channel", ...channel.routeModels); } async followChannel(channel) { @@ -346,9 +387,9 @@ export default class Chat extends Service { // 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", { + return ajax("/chat/api/direct-message-channels.json", { method: "POST", - data: { usernames: usernames.uniq() }, + data: { target_usernames: usernames.uniq() }, }) .then((response) => { const channel = this.chatChannelsManager.store(response.channel); @@ -367,84 +408,6 @@ export default class Chat extends Service { }); } - _saveDraft(channelId, draft) { - const data = { chat_channel_id: channelId }; - if (draft) { - data.data = JSON.stringify(draft); - } - - ajax("/chat/drafts.json", { type: "POST", data, ignoreUnsent: false }) - .then(() => { - this.markNetworkAsReliable(); - }) - .catch((error) => { - // we ignore a draft which can't be saved because it's too big - // and only deal with network error for now - 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 membership = channel.currentUserMembership; - const hasUnreadMessages = - latestUnreadMsgId > membership.last_read_message_id; - if ( - hasUnreadMessages || - membership.unread_count > 0 || - membership.unread_mentions > 0 - ) { - channel.updateLastReadMessage(latestUnreadMsgId); - } - } - addToolbarButton() { deprecated( "Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`" diff --git a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs index bbab48d73b3..521d3b889a6 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs @@ -1,3 +1,5 @@ + + {{#if this.selectedWebhook}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs index e7c3b8d6a47..83de81608a8 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs @@ -2,4 +2,4 @@ @channel={{this.model}} @onEditChatChannelName={{action "onEditChatChannelName"}} @onEditChatChannelDescription={{action "onEditChatChannelDescription"}} -/> +/> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info.hbs index 83e6b2e8a85..e390f0a194c 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info.hbs @@ -13,7 +13,7 @@ {{else}} @@ -38,7 +38,7 @@ > {{i18n (concat "chat.channel_info.tabs." tab)}} @@ -62,4 +62,4 @@ {{outlet}}
    -
    +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs new file mode 100644 index 00000000000..df4708f87a0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-thread.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs new file mode 100644 index 00000000000..efc4083ac97 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-threads.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs index e2147cab02d..6c3a15b47e7 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel.hbs @@ -1 +1,8 @@ -{{outlet}} \ No newline at end of file + + + + {{outlet}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-draft-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-draft-channel.hbs index 858044e3763..8d692797dc0 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-draft-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-draft-channel.hbs @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-index.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-index.hbs index 0d18ba932b5..ec6c915d310 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-index.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-index.hbs @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs index d7a30317d22..3fe25fd695d 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs @@ -17,7 +17,10 @@ {{/if}} -
    +
    {{outlet}}
    diff --git a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs b/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs deleted file mode 100644 index f49c1cb84c9..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#if - (and this.chatEmojiPickerManager.opened this.chatEmojiPickerManager.element) -}} - {{#in-element this.chatEmojiPickerManager.element}} - - {{/in-element}} -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js b/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js deleted file mode 100644 index 8fa5cf2f402..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { getOwner } from "discourse-common/lib/get-owner"; - -export default { - setupComponent(args, component) { - const container = getOwner(this); - const chatEmojiPickerManager = container.lookup( - "service:chat-emoji-picker-manager" - ); - - component.set("chatEmojiPickerManager", chatEmojiPickerManager); - }, -}; diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-archive-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-archive-modal.hbs deleted file mode 100644 index bb925b1952b..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-archive-modal.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-delete-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-delete-modal.hbs deleted file mode 100644 index 90820b131a3..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-delete-modal.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-description.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-description.hbs deleted file mode 100644 index b988acd671e..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-description.hbs +++ /dev/null @@ -1,23 +0,0 @@ - - - - {{i18n "chat.channel_edit_description_modal.description"}} - - - - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs deleted file mode 100644 index 00492a25217..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs +++ /dev/null @@ -1,22 +0,0 @@ - - - - {{i18n "chat.channel_edit_name_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 deleted file mode 100644 index 498861076c6..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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 deleted file mode 100644 index 121e2c8f424..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file 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 deleted file mode 100644 index 0dcc0c1114d..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs deleted file mode 100644 index 13b2ff45e29..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs +++ /dev/null @@ -1,52 +0,0 @@ - - - - - {{#if this.categoryPermissionsHint}} -
    - {{this.categoryPermissionsHint}} -
    - {{/if}} - - {{#if this.autoJoinAvailable}} - - {{/if}} - - - - - - -
    - - \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs index a2e2d00cc37..15eb67ad181 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs @@ -1,6 +1,6 @@ -
    +
    -
    +
    -
    +
    -
    +
    -
    +
    @@ -69,6 +81,24 @@ {{/if}}
    +
    + + +
    + ` + hbs`` ), ]; }, 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 index f9367adec4a..b9498312ef9 100644 --- a/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js @@ -38,7 +38,7 @@ createWidgetFrom(DefaultNotificationItem, "chat-invitation-notification-item", { title: data.chat_channel_title, slug: data.chat_channel_slug, }); - return `/chat/channel/${data.chat_channel_id}/${slug || "-"}?messageId=${ + return `/chat/c/${slug || "-"}/${data.chat_channel_id}/${ 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 index 7d194879dc2..6b64fe8ed9e 100644 --- a/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js @@ -33,7 +33,7 @@ const chatNotificationItem = { const title = this.notificationTitle(notificationName, data); const text = this.text(notificationName, data); const html = new RawHtml({ html: `
    ${text}
    ` }); - const contents = [iconNode("comment"), html]; + const contents = [iconNode("d-chat"), html]; const href = this.url(data); return h( @@ -48,9 +48,15 @@ const chatNotificationItem = { title: data.chat_channel_title, slug: data.chat_channel_slug, }); - return `/chat/channel/${data.chat_channel_id}/${slug || "-"}?messageId=${ - data.chat_message_id - }`; + + let notificationRoute = `/chat/c/${slug || "-"}/${data.chat_channel_id}`; + if (data.chat_thread_id) { + notificationRoute += `/t/${data.chat_thread_id}`; + } else { + notificationRoute += `/${data.chat_message_id}`; + } + + return notificationRoute; }, }; diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js index 236277a62fb..515865247e4 100644 --- a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js @@ -21,7 +21,7 @@ const chatTranscriptRule = { const channelName = tagInfo.attrs.channel; const channelId = tagInfo.attrs.channelId; const channelLink = channelId - ? options.getURL(`/chat/channel/${channelId}/-`) + ? options.getURL(`/chat/c/-/${channelId}`) : null; if (!username || !messageIdStart || !messageTimeStart) { @@ -122,7 +122,7 @@ const chatTranscriptRule = { } else { let linkToken = state.push("link_open", "a", 1); linkToken.attrs = [ - ["href", `${channelLink}?messageId=${messageIdStart}`], + ["href", `${channelLink}/${messageIdStart}`], ["title", messageTimeStart], ]; @@ -232,7 +232,7 @@ export function setup(helper) { }); helper.buildCookFunction((opts, generateCookFunction) => { - if (!opts.discourse.additionalOptions) { + if (!opts.discourse.additionalOptions?.chat) { return; } diff --git a/plugins/chat/assets/javascripts/select-kit/addons/components/thread-notifications-button.js b/plugins/chat/assets/javascripts/select-kit/addons/components/thread-notifications-button.js new file mode 100644 index 00000000000..8480d2b07bc --- /dev/null +++ b/plugins/chat/assets/javascripts/select-kit/addons/components/thread-notifications-button.js @@ -0,0 +1,14 @@ +import NotificationsButtonComponent from "select-kit/components/notifications-button"; +import { threadNotificationButtonLevels } from "discourse/plugins/chat/discourse/lib/chat-notification-levels"; + +export default NotificationsButtonComponent.extend({ + pluginApiIdentifiers: ["thread-notifications-button"], + classNames: ["thread-notifications-button"], + content: threadNotificationButtonLevels, + + selectKitOptions: { + i18nPrefix: "chat.thread.notifications", + showFullTitle: false, + btnCustomClasses: "btn-flat", + }, +}); diff --git a/plugins/chat/assets/stylesheets/common/base-common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss new file mode 100644 index 00000000000..794330db303 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/base-common.scss @@ -0,0 +1,388 @@ +:root { + --message-left-width: 42px; + --full-page-border-radius: 12px; + --full-page-sidebar-width: 275px; + --channel-list-avatar-size: 30px; + --chat-header-offset: 50px; +} + +// Very specific hack to ensure the contextual menu (copy/paste/...) is +// not completely over the textarea, the rules to position this menu are quite obscure +// and under DiscourseHUB the space between the textarea and the keyboard is so small that +// it sometimes prefer to appear on top of the textarea, making any gesture very complicated +html.ios-device.keyboard-visible body #main-outlet .full-page-chat { + padding-bottom: 0.2rem; +} + +.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); + } + } + } +} + +.header-dropdown-toggle.chat-header-icon { + .icon { + .chat-channel-unread-indicator { + @include chat-unread-indicator; + border: 2px solid var(--header_background); + position: absolute; + top: 0; + right: 2px; + + &.-urgent { + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: 1em; + min-width: 0.6em; + padding: 0.21em 0.42em; + top: -1px; + right: 0; + } + } + } + + span.icon { + cursor: auto; + + &:hover { + .d-icon { + color: var(--header_primary-low-mid); + } + + background: none; + } + } + + a.icon { + &.active { + .d-icon-comment { + color: var(--primary-medium); + } + } + + &:hover { + .chat-channel-unread-indicator { + border-color: var(--primary-low); + } + } + } +} + +.chat-messages-container { + word-wrap: break-word; + white-space: normal; + position: relative; + + .chat-message-container { + display: grid; + + &.-selectable { + 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; + } +} + +.avatar { + border: 1px solid transparent; + padding: 0; + box-sizing: border-box; + + .is-online & { + border: 1px solid var(--secondary); + box-shadow: 0px 0px 0px 1px var(--success); + } +} + +.topic-title-chat-icon { + display: inline-block; + * { + display: inline-block; + } +} + +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 { + display: grid; + grid-template-columns: var(--full-page-sidebar-width) 1fr; + + .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, + .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-messages-scroll { + 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; + gap: 0.5rem; + 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; + max-width: 100%; + + .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 { + 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; + } +} + +.user-preferences .chat-setting .controls { + margin-bottom: 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 { + background: var(--secondary); + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--primary-low); +} + +html.has-full-page-chat { + height: 100%; + width: 100%; + + body { + height: 100%; + width: 100%; + + #main-outlet { + display: flex; + flex-direction: column; + + .full-page-chat { + height: 100%; + min-height: 0; + } + + .main-chat-outlet { + min-height: 0; + overflow: hidden; + } + } + } + + &.mobile-view { + #main-outlet-wrapper { + padding: 0; + } + } + + // these need to apply to desktop too, because iPads + &.discourse-touch { + .full-page-chat, + .chat-channel, + #main-outlet { + // allows containers to shrink to fit + min-height: 0; + } + } + [data-popper-reference-hidden] { + visibility: hidden; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-browse.scss b/plugins/chat/assets/stylesheets/common/chat-browse.scss index 2d6dda91607..b7934c3af8c 100644 --- a/plugins/chat/assets/stylesheets/common/chat-browse.scss +++ b/plugins/chat/assets/stylesheets/common/chat-browse.scss @@ -1,15 +1,7 @@ .chat-browse-view { position: relative; - height: calc(100vh - var(--header-offset) - var(--chat-header-offset)); - padding-top: 1em; - padding-bottom: 41px; box-sizing: border-box; - 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 - } + padding: 1rem 1rem env(safe-area-inset-bottom) 1rem; &__header { display: flex; @@ -27,18 +19,14 @@ } &__content_wrapper { - margin: 2rem 0 0 1rem; box-sizing: border-box; - - @include breakpoint(tablet) { - margin-top: 1rem; - } + margin-top: 1rem; } &__cards { display: grid; grid-template-columns: repeat(2, 1fr); - grid-gap: 2.5rem; + grid-gap: 2rem; @include breakpoint(tablet) { grid-template-columns: repeat(1, 1fr); @@ -50,7 +38,6 @@ display: flex; justify-content: space-between; align-items: end; - margin: 0 0 0 1rem; @include breakpoint(tablet) { flex-direction: column; diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss index d8b47c37606..fa514170381 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-card.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss @@ -4,7 +4,7 @@ position: relative; padding: 1.25rem; background-color: var(--primary-very-low); - border-radius: 5px; + border-radius: var(--d-border-radius); min-height: 0; min-width: 0; border-left: 5px solid transparent; diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss index 091ea9366d8..be5dee75b81 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss @@ -32,14 +32,22 @@ color: var(--primary-medium); } -.channel-settings-view__desktop-notification-level-selector, -.channel-settings-view__mobile-notification-level-selector, -.channel-settings-view__muted-selector, -.channel-settings-view__auto-join-selector, -.channel-settings-view__channel-wide-mentions-selector { +.channel-info-about-view__slug { + color: var(--primary-medium); + font-size: var(--font-down-2); +} + +.channel-settings-view__selector { width: 220px; } +.channel-settings-view__channel-threading-tooltip { + padding-left: 0.25rem; + color: var(--tertiary); + cursor: pointer; +} + +.channel-settings-view__muted-selector, .chat-form__btn.delete-btn { .d-icon { color: var(--danger); @@ -96,10 +104,6 @@ input.channel-members-view__search-input { 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 { @@ -107,9 +111,8 @@ input.channel-members-view__search-input { align-items: center; padding: 0.5rem 0 0.5rem 1px; - &:hover { - background-color: var(--tertiary-very-low); - border-radius: 0.25rem; + &:not(:last-child) { + border-bottom: 1px solid var(--primary-low); } .chat-user-avatar { @@ -117,28 +120,22 @@ input.channel-members-view__search-input { } } -// Channel info edit name modal -.chat-channel-edit-name-modal__name-input { - display: flex; - margin: 0; - width: 100%; +// Channel info edit name and slug modal +.chat-channel-edit-name-slug-modal { + .modal-inner-container { + width: 300px; + } + + &__name-input, + &__slug-input { + display: flex; + margin: 0; + width: 100%; + } } -.chat-channel-edit-name-modal__description { +.chat-channel-edit-name-slug-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 index 34035311c73..c91c7f39917 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss @@ -5,6 +5,7 @@ display: flex; flex-direction: column; align-items: center; + z-index: 3; &.-no-description { .chat-channel-title { @@ -12,9 +13,17 @@ } } + &.-no-button { + .chat-channel-preview-card__browse-all { + margin-top: 0; + } + } + &__description { color: var(--primary-600); text-align: center; + overflow-wrap: break-word; + max-width: 100%; } .chat-channel-title__name { @@ -24,7 +33,7 @@ &__join-channel-btn { font-size: var(--font-up-2); border: 1px solid transparent; - border-radius: 0.25rem; + border-radius: var(--d-button-border-radius); line-height: normal; box-sizing: border-box; padding: 0.5em 0.65em; diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss deleted file mode 100644 index c5935c249aa..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss +++ /dev/null @@ -1,63 +0,0 @@ -: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 index 38968f36c11..a1c21cf4a70 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-title.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-title.scss @@ -1,6 +1,8 @@ +//appears in: header of chat pane, channel info, preview card .chat-channel-title-wrapper { display: flex; align-items: center; + overflow: hidden; } .chat-channel-title { @@ -8,10 +10,23 @@ align-items: center; @include ellipsis; + .chat-channel-preview-card & { + max-width: 100%; + } + + &__user-info { + overflow: hidden; + } + .user-status-message { display: none; // we only show when in channels list } + &__user-status-message { + flex-shrink: 3; + overflow: hidden; + } + .chat-name, .category-chat-name, &__usernames, diff --git a/plugins/chat/assets/stylesheets/common/chat-channel.scss b/plugins/chat/assets/stylesheets/common/chat-channel.scss new file mode 100644 index 00000000000..7a80405824b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel.scss @@ -0,0 +1,39 @@ +.chat-channel { + display: flex; + flex-direction: column; + width: 100%; + min-height: 1px; + position: relative; + overflow: hidden; + grid-area: main; + min-width: 250px; + @include chat-height; + + .chat-messages-scroll { + flex-grow: 1; + overflow-y: scroll; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; + z-index: 1; + margin: 0 1px 0 0; + will-change: transform; + @include chat-scrollbar(); + min-height: 1px; + + .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; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-button.scss b/plugins/chat/assets/stylesheets/common/chat-composer-button.scss new file mode 100644 index 00000000000..512283ada9b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-button.scss @@ -0,0 +1,81 @@ +.chat-composer-button { + box-sizing: border-box; + width: 50px; + border: 0; + height: 50px; + background: none; + + .is-disabled & { + cursor: not-allowed; + } + + &__wrapper { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + align-self: flex-end; + } + + .d-icon { + color: var(--primary-low-mid); + + &:hover { + color: var(--primary-low-mid); + } + + .is-focused & { + color: var(--primary-high); + } + + .is-disabled & { + cursor: not-allowed; + } + } + + &.-send { + will-change: scale; + + @keyframes sendingScales { + 0% { + transform: scale(0.9); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(0.9); + } + } + + .is-send-disabled & { + cursor: default; + } + + .chat-composer.is-sending & { + animation: sendingScales 1s infinite linear; + } + + .d-icon { + .is-send-enabled.is-focused & { + color: var(--tertiary); + } + } + + &:hover { + .d-icon { + .is-send-enabled & { + transform: scale(1.2); + } + } + } + } + + &.-emoji { + .d-icon { + .is-focused & { + color: var(--tertiary); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss index 870202a1e2f..a3c08c7839d 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss @@ -1,4 +1,4 @@ -.chat-composer-dropdown { +[data-theme="chat-composer-dropdown"] { margin-left: 0.2rem; .tippy-content { @@ -6,44 +6,38 @@ } } +.chat-composer.is-disabled { + .no-touch & { + .chat-composer-dropdown__trigger-btn:hover { + cursor: default; + .d-icon { + color: var(--primary-low-mid); + } + } + } +} + .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); + padding: 5px; + transition: transform 0.1s ease-in-out; + background: var(--secondary-very-high); + border-radius: 100%; } - &:focus { - border-color: var(--tertiary); - } - - .discourse-no-touch &:hover { - background: var(--primary-high); + &.has-active-panel { .d-icon { - color: var(--primary-low); + transform: rotate(45deg); } } } .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%; diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss b/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss deleted file mode 100644 index e1b019a3154..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss +++ /dev/null @@ -1,9 +0,0 @@ -.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-separator.scss b/plugins/chat/assets/stylesheets/common/chat-composer-separator.scss new file mode 100644 index 00000000000..657e01aef32 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-separator.scss @@ -0,0 +1,8 @@ +.chat-composer-separator { + width: 1px; + margin: 10px 0.25rem; + box-sizing: border-box; + background: var(--primary-low-mid); + height: 30px; + display: flex; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss index 5d4b36303d2..ed2f9beca2a 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss @@ -1,30 +1,62 @@ .chat-composer-upload { display: inline-flex; - height: 50px; + height: 64px; padding: 0.5rem; border: 1px solid var(--primary-low-mid); margin-right: 0.5em; + position: relative; + border-radius: var(--d-border-radius); + box-sizing: border-box; + + &--image:not(.chat-composer-upload--in-progress) { + padding: 0; + + .preview-img { + height: 62px; + width: 62px; + box-sizing: border-box; + } + } &:last-child { margin-right: 0; } + &:hover { + .chat-composer-upload__remove-btn { + visibility: visible; + background: rgba(var(--always-black-rgb), 0.9); + padding: 5px; + border-radius: 100%; + font-size: var(--font-down-2); + } + } + + &__remove-btn { + border: 1px solid var(--primary-medium); + position: absolute; + top: -8px; + right: -8px; + visibility: hidden; + } + .preview { - width: 50px; + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; - margin: 0 1em 0 0; - border-radius: 8px; + margin: 0; .d-icon { font-size: var(--font-up-6); + margin-right: 0.5rem; } .preview-img { - max-width: 100%; - max-height: 100%; + object-position: center; + object-fit: cover; + border-radius: var(--d-border-radius); } } @@ -63,7 +95,7 @@ .extension-pill { background: var(--primary-low); - border-radius: 5px; + border-radius: var(--d-border-radius); 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 index 8050774913f..81693b18010 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss @@ -1,6 +1,8 @@ .chat-composer-uploads { + max-width: 100%; + .chat-composer-uploads-container { - padding: 0.5rem 10px; + padding: 0.5rem 0.25rem; 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 index 9ae341f2473..ecc6032ea04 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -1,99 +1,113 @@ -.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; + &__wrapper { + display: flex; + flex-direction: column; + z-index: 3; + background-color: var(--primary-very-low); + padding: 0.5rem 0 env(safe-area-inset-bottom) 0; - &.is-disabled { - background-color: var(--primary-low); - border: 1px solid var(--primary-low-mid); + .keyboard-visible & { + padding-bottom: 0; + } + + #chat-full-page-uploader, + #chat-widget-uploader { + display: none; + } + + .drop-a-file { + display: none; + } + + .chat-replying-indicator { + padding-inline: 1rem; + } } - .send-btn { - padding: 0.4rem 0.5rem; - border: 1px solid transparent; - border-radius: 5px; + .chat-composer-button, + .chat-composer-separator { + align-self: flex-end; + } + + &__outer-container { 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); - } - } - } + box-sizing: border-box; + width: 100%; + padding-inline: 1rem; } - &__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; + &__inner-container { display: flex; + align-items: center; + box-sizing: border-box; + width: 100%; + flex-direction: row; + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius-large); + background-color: var(--secondary); + min-height: 50px; + overflow: hidden; - .d-icon { - color: var(--secondary-very-high); + .chat-composer.is-focused & { + border-color: var(--primary-low-mid); + box-shadow: 0px 0px 4px 1px rgba(0, 0, 0, 0.1); } - &:focus { - border-color: var(--tertiary); - } + .chat-composer.is-disabled & { + background: var(--primary-low); - .discourse-no-touch &:hover { - background: var(--primary-high); - .d-icon { - color: var(--primary-low); + &:hover { + cursor: not-allowed; } } } - .chat-composer-input { + &__input-container { + display: flex; + align-items: center; + box-sizing: border-box; + width: 100%; + align-self: stretch; + } + + --100dvh: 100vh; + @supports (height: 100dvh) { + --100dvh: 100dvh; + } + + &__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; + max-height: calc( + ( + var(--100dvh) - var(--header-offset, 0px) - + var(--chat-header-offset, 0px) + ) / 100 * 25 + ); background: none; - margin: 0; - padding: 0.25rem 0.5rem; + padding: 0; + margin: 5px 0; text-overflow: ellipsis; + cursor: inherit; + @include chat-scrollbar(); + + &[disabled] { + background: none; + + .d-icon { + opacity: 0.4; + } + } + + &:focus, + &:active { + outline: none; + } &:placeholder-shown, &::placeholder { @@ -101,21 +115,6 @@ 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 { @@ -125,14 +124,9 @@ } .chat-composer-message-details { - padding: 0.5rem 0.75rem; - border-top: 1px solid var(--primary-low); + margin: 0 1rem 0.5rem 1rem; 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 deleted file mode 100644 index 6f295468c6e..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss +++ /dev/null @@ -1,43 +0,0 @@ -.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 index 918dcd6b8c5..f9c13429a80 100644 --- a/plugins/chat/assets/stylesheets/common/chat-drawer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss @@ -2,14 +2,40 @@ body.composer-open .chat-drawer-outlet-container { bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator } +.chat-drawer-resizer { + position: absolute; + top: -5px; + width: 15px; + height: 15px; +} + +html:not(.rtl) { + .chat-drawer-resizer { + cursor: nwse-resize; + left: -5px; + } +} + +html.rtl { + .chat-drawer-resizer { + cursor: nesw-resize; + right: -5px; + } +} + .chat-drawer-outlet-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; + max-height: calc(100% - var(--header-offset) - 15px); + + .rtl & { + left: var(--composer-right, 20px); + right: 0; + } + margin: 0; padding: 0; display: flex; @@ -31,7 +57,6 @@ body.composer-open .chat-drawer-outlet-container { } box-sizing: border-box; - max-height: 90vh; padding-bottom: var(--composer-height, 0); transition: all 100ms ease-in; transition-property: bottom, padding-bottom; @@ -39,44 +64,55 @@ body.composer-open .chat-drawer-outlet-container { .chat-drawer { align-self: flex-end; + width: 400px; + min-width: 250px !important; // important to override inline styles + max-width: calc(100% - var(--composer-right)); + min-height: 300px !important; // important to override inline styles .chat-drawer-container { background: var(--secondary); border: 1px solid var(--primary-low); border-bottom: 0; - border-top-left-radius: 8px; - border-top-right-radius: 8px; + border-top-left-radius: var(--d-border-radius-large); + border-top-right-radius: var(--d-border-radius-large); box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.125); box-sizing: border-box; display: flex; flex-direction: column; + position: relative; + overflow: hidden; } &.is-expanded { .chat-drawer-container { - max-height: $float-height; - height: calc(85vh - var(--composer-height, 0px)); + height: 100%; } } - .chat-live-pane { + &:not(.is-expanded) { + min-height: 0 !important; + height: auto !important; + } + + .chat-channel, + .chat-thread, + .chat-thread-list { height: 100%; } } .chat-drawer-header__left-actions { display: flex; - height: 100%; + height: 2rem; } .chat-drawer-header__right-actions { display: flex; - height: 100%; + height: 2rem; margin-left: auto; } .chat-drawer-header__top-line { - height: 2.5rem; display: flex; align-items: center; } @@ -90,25 +126,38 @@ body.composer-open .chat-drawer-outlet-container { .chat-drawer-header__title { @include ellipsis; display: flex; - flex-direction: column; - width: 100%; + width: auto; font-weight: 700; - padding: 0 0.5rem 0 1rem; + padding: 0 0.5rem 0 0; cursor: pointer; + height: 2rem; + align-items: center; - .chat-channel-title { - padding: 0; + .chat-drawer-header__top-line { + padding: 0.25rem; + width: 100%; + } +} + +a.chat-drawer-header__title { + &:hover { + .chat-drawer-header__top-line { + background: var(--primary-low); + border-radius: var(--d-border-radius); + } } } .chat-drawer-header { box-sizing: border-box; border-bottom: solid 1px var(--primary-low); - border-radius: 8px 8px 0 0; + border-radius: var(--d-border-radius-large) var(--d-border-radius-large) 0 0; background: var(--primary-very-low); width: 100%; display: flex; align-items: flex-start; + cursor: pointer; + padding: 0.25rem; .btn { height: 100%; @@ -118,6 +167,10 @@ body.composer-open .chat-drawer-outlet-container { font-weight: 700; width: 100%; + &__user-info { + overflow: hidden; + } + .chat-name, .chat-drawer-name, .category-chat-name, @@ -144,7 +197,7 @@ body.composer-open .chat-drawer-outlet-container { text-overflow: ellipsis; } - .d-icon:not(.d-icon-hashtag) { + .d-icon:not(.d-icon-d-chat) { color: var(--primary-high); } .category-hashtag { @@ -153,19 +206,24 @@ body.composer-open .chat-drawer-outlet-container { } &__close-btn, - &__return-to-channels-btn, + &__back-btn, &__full-screen-btn, + &__thread-list-btn, &__expand-btn { - max-height: 2.5rem; - height: 100%; - min-width: 40px; - width: 40px; + height: 30px; + width: 30px; display: flex; justify-content: center; align-items: center; + border-radius: 100%; + + &:hover:active { + background: var(--primary-low); + } .d-icon { color: var(--primary-low-mid); + margin-right: 0; } &:visited { @@ -180,16 +238,20 @@ body.composer-open .chat-drawer-outlet-container { .d-icon { background: none; - color: var(--primary-low-mid); + color: var(--primary); } } &:hover { .d-icon { - color: var(--primary-high); + color: var(--primary); } } } + + &__thread-list-btn.has-unreads { + margin-right: 0.5rem; + } } .chat-drawer-content { @@ -197,4 +259,5 @@ body.composer-open .chat-drawer-outlet-container { height: 100%; min-height: 1px; padding-bottom: 0.25em; + position: relative; } diff --git a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss index bc61941f3a4..9c51a474b45 100644 --- a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss +++ b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss @@ -17,7 +17,7 @@ &:hover, &:focus { background: var(--primary-very-low); - border-radius: 5px; + border-radius: var(--d-border-radius); transform: scale(1.25); } } @@ -54,9 +54,11 @@ height: 100%; overflow-y: scroll; text-transform: capitalize; + @include chat-scrollbar(); + margin: 1px; } - &__no-reults { + &__no-results { padding: 1em; } @@ -98,7 +100,8 @@ } } - &__section-emojis { + &__section-emojis, + &__section.filtered { padding: 0.5rem; } @@ -136,7 +139,7 @@ background: none; margin-right: 0.5rem; border: 0; - border-radius: 5px; + border-radius: var(--d-border-radius); .d-icon { visibility: hidden; @@ -164,18 +167,23 @@ &.t1 { background: #ffcc4d; } + &.t2 { background: #f7dece; } + &.t3 { background: #f3d2a2; } + &.t4 { background: #d5ab88; } + &.t5 { background: #af7e57; } + &.t6 { background: #7c533e; } @@ -191,12 +199,13 @@ } } -.chat-message-emoji-picker-anchor { - z-index: z("header") + 1; +.chat-channel-message-emoji-picker-connector { + position: relative; .chat-emoji-picker { border: 1px solid var(--primary-low); width: 320px; + z-index: z("header") + 1; .emoji { width: 22px; @@ -204,31 +213,3 @@ } } } - -.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-height-mixin.scss b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss new file mode 100644 index 00000000000..fc0ddb5574f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss @@ -0,0 +1,20 @@ +@mixin chat-height($inset: 0px) { + // desktop and mobile + height: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - + var(--composer-height, 0px) + ); + + // mobile with keyboard opened + .keyboard-visible & { + height: calc(var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px)); + } + + // ipad + .footer-nav-ipad & { + height: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - + var(--composer-height, 0px) + ); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-index.scss b/plugins/chat/assets/stylesheets/common/chat-index.scss index 1bde956b15f..fbc7977bfa0 100644 --- a/plugins/chat/assets/stylesheets/common/chat-index.scss +++ b/plugins/chat/assets/stylesheets/common/chat-index.scss @@ -1,9 +1,38 @@ +.btn-floating.open-new-message-btn { + position: fixed; + background: var(--tertiary); + bottom: 2rem; + right: 2rem; + 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); + } +} + .channels-list { overflow-y: auto; + overscroll-behavior: contain; height: 100%; padding-bottom: env(safe-area-inset-bottom); position: relative; - @include chat-scrollbar(var(--secondary)); + @include chat-scrollbar(); @include breakpoint(mobile-large) { @include chat-scrollbar(); @@ -68,10 +97,24 @@ position: relative; cursor: pointer; color: var(--primary-high); - transition: opacity 50ms ease-in; - opacity: 1; + + @media (hover: none) { + &:hover, + &:focus { + background: transparent; + } + + &:active { + background: var(--primary-low); + } + } @media (hover: hover) { + &:hover, + &.active { + background: var(--primary-very-low); + } + &.can-leave:hover { .toggle-channel-membership-button.-leave { display: block; @@ -87,17 +130,8 @@ } } - .discourse-no-touch &:hover, - &.active { - background: var(--primary-low); - } - &:hover, &.active { - &.active { - font-weight: 600; - } - .chat-channel-title { &, .category-chat-name, @@ -168,22 +202,18 @@ } .chat-channel-unread-indicator { + @include chat-unread-indicator; display: flex; align-items: center; justify-content: center; - width: auto; - min-width: 14px; - padding: 2px; - font-size: var(--font-down-3); - border-radius: 1em; - background: var(--tertiary-med-or-tertiary); + width: 8px; + height: 8px; - &.urgent { - background: var(--success); - } - - .number { - line-height: 1rem; + &.-urgent { + width: auto; + height: auto; + min-width: 0.6em; + padding: 0.3em 0.5em; } } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss index 356cf39dfa6..e44650921d2 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -6,20 +6,15 @@ .chat-message-actions { .chat-message-reaction { @include chat-reaction; - - &:not(.show) { - display: none; - } } } .chat-message-actions-container { @include unselectable; - position: relative; + z-index: z("dropdown") - 1; } .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); @@ -44,12 +39,17 @@ .react-btn, .reply-btn, + .chat-message-thread-btn, .bookmark-btn { margin-right: -1px; padding: 0.5em 0; width: 2.5em; transition: background 0.2s, border-color 0.2s; + > * { + pointer-events: none; + } + &:focus { .d-icon { color: var(--primary); @@ -62,7 +62,7 @@ } &:first-child:not(:hover) { - border-color: var(--primary-low); + border-color: var(--primary-300); border-right-color: transparent; } @@ -75,18 +75,25 @@ } } + &.has-no-secondary-actions { + .reply-btn { + border-right: 1px solid var(--primary-300); + border-top: 1px solid var(--primary-300); + border-bottom: 1px solid var(--primary-300); + } + } + .more-buttons.dropdown-select-box { .select-kit-header { background: none; - border: 1px solid var(--primary-low); + border: 1px solid var(--primary-300); 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-color: var(--primary-300); border-left-color: transparent; .select-kit-header-wrapper .d-icon { @@ -117,8 +124,8 @@ .select-kit-body { padding: 0.5rem; - box-shadow: shadow("card"); - border: 1px solid var(--primary-low); + box-shadow: var(--shadow-card); + border: 1px solid var(--primary-300); } .select-kit-row { @@ -153,9 +160,7 @@ } &:first-child { - border-bottom-left-radius: 0.25em; - border-left-color: var(--primary-low); - border-top-left-radius: 0.25em; + border-left-color: var(--primary-300); } &.reacted { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss index c18d6aa83a9..5189621ced8 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss @@ -19,8 +19,9 @@ $max_video_height: 150px; } .chat-video-upload { - height: $max_video_height; + max-height: $max_video_height; width: calc(#{$max_video_height} / 9 * 16); + max-width: 100%; } .chat-message-collapser-link-small { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-creator.scss b/plugins/chat/assets/stylesheets/common/chat-message-creator.scss new file mode 100644 index 00000000000..c23ab6cd760 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-creator.scss @@ -0,0 +1,310 @@ +.chat-message-creator { + display: flex; + align-items: center; + width: 100%; + flex-direction: column; + + --row-height: 36px; + + &__search-icon { + color: var(--primary-medium); + + &-container { + display: flex; + align-items: center; + height: var(--row-height); + padding-inline: 0.25rem; + box-sizing: border-box; + } + } + + &__container { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + + > * { + box-sizing: border-box; + } + } + + &__row { + display: flex; + padding-inline: 0.25rem; + align-items: center; + border-radius: 5px; + height: var(--row-height); + + .unread-indicator { + background: var(--tertiary); + width: 8px; + height: 8px; + display: flex; + border-radius: 50%; + margin-left: 0.5rem; + + &.-urgent { + background: var(--success); + } + } + + .selection-indicator { + visibility: hidden; + + font-size: var(--font-down-2); + margin-left: auto; + + &.-add { + color: var(--success); + } + + &.-remove { + color: var(--danger); + } + } + + .action-indicator { + display: none; + margin-left: auto; + white-space: nowrap; + font-size: var(--font-down-1); + color: var(--secondary-medium); + align-items: center; + padding-right: 0.25rem; + + kbd { + margin-left: 0.25rem; + } + } + + &.-active { + .action-indicator { + display: flex; + } + } + + .chat-channel-title__name { + margin-left: 0; + } + + .chat-channel-title__avatar, + .chat-channel-title__category-badge, + .chat-user-avatar { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + } + + .chat-channel-title__name, + .chat-user-display-name { + @include ellipsis; + padding-left: 0.5rem; + } + + &.-selected { + .selection-indicator { + visibility: visible; + } + } + + &.-disabled { + opacity: 0.25; + } + + &.-active { + cursor: pointer; + + .chat-user-display-name { + color: var(--primary); + } + } + + &.-user { + &.-disabled { + .chat-user-display-name__username.-first { + font-weight: normal; + } + } + .disabled-text { + margin-left: auto; + white-space: nowrap; + padding-left: 0.25rem; + } + } + } + + &__content { + box-sizing: border-box; + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + + &-container { + display: flex; + flex: 1; + width: 100%; + box-sizing: border-box; + padding: 0.25rem 1rem 1rem 1rem; + } + } + + &__close-btn { + margin-bottom: auto; + margin-left: 0.25rem; + height: 44px; + width: 44px; + min-width: 44px; + border-radius: 5px; + } + + &__selection { + flex: 1 1 auto; + flex-direction: row; + flex-wrap: wrap; + display: flex; + background: var(--secondary-very-high); + border-radius: 5px; + padding: 3px; + position: relative; + + &-container { + display: flex; + box-sizing: border-box; + width: 100%; + align-items: center; + padding: 1rem; + box-sizing: border-box; + } + } + + &__input[type="text"], + &__input[type="text"]:focus { + background: none; + appearance: none; + outline: none; + border: 0; + resize: none; + box-sizing: border-box; + min-width: 150px; + height: var(--row-height); + flex: 1; + width: auto; + padding: 0 5px; + margin: 0; + box-sizing: border-box; + display: inline-flex; + } + + &__loader { + &-container { + display: flex; + align-items: center; + padding-inline: 0.5rem; + height: var(--row-height); + } + } + + &__selection-item { + align-items: center; + box-sizing: border-box; + cursor: pointer; + display: inline-flex; + background: var(--primary-very-low); + border-radius: 5px; + border: 1px solid var(--primary-low); + height: calc(var(--row-height) - 6); + padding-inline: 0.25rem; + margin: 3px; + + .d-icon-times { + margin-top: 4px; + } + + .chat-channel-title__name { + padding-inline: 0.25rem; + } + + &__username { + padding-inline: 0.25rem; + } + + &.-active { + border-color: var(--secondary-high); + } + + &-remove-btn { + padding-inline: 0.25rem; + font-size: var(--font-down-2); + display: flex; + align-items: center; + } + + &:hover { + border-color: var(--primary-medium); + + .chat-message-creator__selection__remove-btn { + color: var(--danger); + } + } + } + + &__no-items { + &-container { + display: flex; + align-items: center; + height: var(--row-height); + margin-left: 0.5rem; + } + } + + &__footer { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-direction: row; + width: 100%; + + &-container { + margin-top: auto; + display: flex; + width: 100%; + padding: 1rem; + box-sizing: border-box; + border-top: 1px solid var(--primary-low); + } + } + + &__open-dm-btn { + display: flex; + margin-left: auto; + @include ellipsis; + padding: 0.5rem; + max-width: 40%; + + .d-button-label { + @include ellipsis; + } + } + + &__shortcut { + display: flex; + align-items: center; + font-size: var(--font-down-2); + color: var(--secondary-medium); + flex: 3; + + span { + margin-left: 0.25rem; + display: inline-flex; + line-height: 17px; + } + + kbd { + margin-inline: 0.25rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-error.scss b/plugins/chat/assets/stylesheets/common/chat-message-error.scss new file mode 100644 index 00000000000..71a40dc4deb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-error.scss @@ -0,0 +1,40 @@ +.chat-message-error { + color: var(--danger-medium); + + &__retry-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-message-images.scss b/plugins/chat/assets/stylesheets/common/chat-message-images.scss index 2805ed5cacc..09797aa28a5 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-images.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-images.scss @@ -3,12 +3,13 @@ $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, + .onebox img:not(.ytp-thumbnail-image, .onebox-avatar-inline), + img.onebox, + .chat-uploads img, + p img, aside.onebox .onebox-body .aspect-image-full-size, - aside.onebox .onebox-body .aspect-image-full-size img { + aside.onebox .onebox-body .aspect-image-full-size img, + .chat-message-text p img:not(.emoji) { object-fit: contain; max-height: $max_image_height; max-width: 100%; @@ -16,12 +17,67 @@ $max_image_height: 150px; overflow: hidden; } + .onebox { + container-type: inline-size; + + .thumbnail { + max-width: 40% !important; + &.onebox-avatar { + max-height: 100px; + width: 20%; + max-width: 60px; + margin-right: 0.5rem; + } + } + + @container (width < 400px) { + .onebox-body { + &:not(:has(.thumbnail.onebox-avatar)) { + display: flex; + flex-direction: column; + } + + h3 { + margin-block: 0.75rem 0; + } + p { + margin-top: 0.5rem; + } + + .thumbnail { + max-width: 100% !important; + margin: 0; + + &.onebox-avatar { + max-width: 20%; + margin-right: 0.5rem; + } + } + } + } + } + .chat-message-collapser .chat-message-collapser-header + div - .chat-message-collapser-youtube { + .chat-message-collapser-lazy-video { object-fit: contain; - height: $max_image_height; - width: calc(#{$max_image_height} / 9 * 16); + max-height: $max_image_height; + max-width: calc(#{$max_image_height} / 9 * 16); + } + + // Prevent overflow of old lazy-yt images + // TODO: remove in December 2023 + .lazyYT.lazyYT-container { + border: none; + a { + display: flex; + } + .ytp-thumbnail-image { + object-fit: contain; + height: $max_image_height; + width: calc(#{$max_image_height} / 9 * 16); + pointer-events: none; + } } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message-info.scss b/plugins/chat/assets/stylesheets/common/chat-message-info.scss index fb82db5baac..76968afb8dd 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-info.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-info.scss @@ -25,7 +25,7 @@ text-transform: uppercase; padding: 0.25em; background: var(--primary-low); - border-radius: 3px; + border-radius: var(--d-border-radius); font-size: var(--font-down-2); & + .chat-message-info__date { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss index 955082fe5fb..2a453f5b79e 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss @@ -6,16 +6,15 @@ width: var(--message-left-width); } +.chat-message-container:hover .chat-message-left-gutter { + .chat-time { + color: var(--secondary-medium); + } +} + .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 { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-mention-warning.scss b/plugins/chat/assets/stylesheets/common/chat-message-mention-warning.scss new file mode 100644 index 00000000000..4d69f3c9fd1 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-mention-warning.scss @@ -0,0 +1,19 @@ +.chat-message-mention-warning { + position: relative; + margin-top: 0.25rem; + font-size: var(--font-down-1); + + &__dismiss-btn { + position: absolute; + top: 7px; + right: 5px; + } + + &__text { + margin: 0.25rem 0; + } + + &__invite-sent { + color: var(--tertiary); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss index e918d0c850a..d9a6e2051b5 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss @@ -1,42 +1,137 @@ .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); + &-new { + display: flex; + justify-content: center; + padding: 20px 0 20px var(--scrollbarWidth); + position: relative; - .divider { - background-color: var(--danger-medium); + .chat-message-separator__text-container { + text-align: center; + position: absolute; + height: 40px; + box-sizing: border-box; + z-index: 1; + top: 0; + display: flex; + align-items: center; + justify-content: center; + + .chat-message-separator__text { + color: var(--danger-medium); + background-color: var(--secondary); + padding: 0.25rem 0.5rem; + font-size: var(--font-down-1); + } + } + + .chat-message-separator__line-container { + width: 100%; + } + + .chat-message-separator__line { + border-top: 1px solid 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 { + &-date { + box-sizing: border-box; position: absolute; width: 100%; - height: 1px; - top: 50%; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: center; + padding-left: var(--scrollbarWidth); + pointer-events: none; + + &.with-last-visit { + & + .chat-message-separator__line-container { + .chat-message-separator__line { + border-color: var(--danger-medium); + } + } + } + + .chat-message-separator__text-container { + align-items: center; + display: flex; + height: 40px; + position: sticky; + top: -1px; + + &.is-pinned, + &.is-force-pinned { + .chat-message-separator__text { + border: 1px solid var(--primary-200); + border-radius: var(--d-border-radius); + color: var(--primary-800); + background: var(--primary-50); + + &:hover { + border: 1px solid var(--secondary-high); + } + } + + .chat-message-separator__last-visit { + display: none; + } + } + } + + .chat-message-separator__last-visit { + display: flex; + } + + .chat-message-separator__last-visit-separator { + margin: 0 0.25rem; + } + + .chat-message-separator__text { + @include unselectable; + background-color: var(--secondary); + border: 1px solid transparent; + color: var(--secondary-low); + font-size: var(--font-down-1); + padding: 0.25rem 0.5rem; + box-sizing: border-box; + display: flex; + cursor: pointer; + pointer-events: all; + + .no-touch & { + &:hover { + border: 1px solid var(--secondary-high); + border-radius: var(--d-border-radius); + color: var(--primary-800); + background: var(--primary-50); + } + } + + .touch & { + &:active { + border: 1px solid var(--secondary-high); + border-radius: var(--d-border-radius); + color: var(--primary-800); + background: var(--primary-50); + } + } + + &:active { + transform: scale(0.98); + } + } + + & + .chat-message-separator__line-container { + padding: 20px 0 20px var(--scrollbarWidth); + box-sizing: border-box; + + .chat-message-separator__line { + border-top: 1px solid var(--secondary-high); + margin: 0 0 -1px; + } + } } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss new file mode 100644 index 00000000000..1513a32f801 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-thread-indicator.scss @@ -0,0 +1,91 @@ +.chat-message { + container-type: inline-size; +} + +.chat-message-thread-indicator { + cursor: pointer; + grid-area: threadindicator; + box-sizing: border-box; + display: grid; + grid-template-columns: auto 1fr auto auto; + background-color: var(--primary-very-low); + margin: 4px 0 -2px calc(var(--message-left-width) - 0.25rem); + padding: 0.5rem; + border-radius: var(--d-border-radius-large); + color: var(--primary); + + > * { + pointer-events: none; + } + + &:visited, + &:hover { + color: var(--primary); + } + + &:hover { + .chat-message-thread-indicator__replies-count { + text-decoration: underline; + } + } + + .touch & { + &.-active { + box-shadow: var(--shadow-dropdown); + } + } + + .no-touch & { + &:hover { + box-shadow: var(--shadow-dropdown); + } + } + + &__last-reply-avatar { + grid-area: avatar; + margin-right: 0.5rem; + + .chat-user-avatar { + width: auto !important; + } + } + + &__last-reply-username { + @include ellipsis; + font-weight: bold; + color: var(--primary-very-high); + } + + &__last-reply-info { + grid-area: info; + display: flex; + align-items: center; + gap: 0.25rem; + } + + &__last-reply-timestamp { + color: var(--primary-high); + font-size: var(--font-down-3); + } + + &__last-reply-excerpt { + @include ellipsis; + grid-area: excerpt; + } + + .chat-thread-participants { + grid-area: participants; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + } + + &__replies-count { + @include ellipsis; + color: var(--tertiary); + font-size: var(--font-down-1); + text-align: right; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 6eb92b80ff5..27195653a32 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -1,5 +1,5 @@ -.chat-message-deleted, -.chat-message-hidden { +.chat-message-text.-deleted, +.chat-message-text.-hidden { margin-left: calc(var(--message-left-width) + 0.75em); padding: 0; @@ -7,6 +7,10 @@ color: var(--primary-low-mid); padding: 0.25em; + .d-button-label { + text-align: left; + } + &:hover { background: inherit; color: inherit; @@ -14,103 +18,24 @@ } } -@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-reaction { + > * { + pointer-events: none; } } .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; + will-change: scale; - &: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; + &:active { + transform: scale(0.93); } } @@ -136,15 +61,12 @@ .mention.highlighted { background: var(--tertiary-low); color: var(--primary); - } - - img.ytp-thumbnail-image { - height: 100%; - max-height: unset; - - &:hover { - border-radius: 0; - } + display: inline-block; + font-size: 0.93em; + font-weight: normal; + padding: 0 0.3em 0.07em; + border-radius: 0.6em; + text-decoration: none; } // Automatic aspect-ratio mapping https://developer.mozilla.org/en-US/docs/Web/Media/images/aspect_ratio_mapping @@ -172,23 +94,20 @@ 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; + will-change: scale; + + &:active { + transform: scale(0.93); + } + + > * { + pointer-events: none; + } .d-icon { color: var(--primary-high); @@ -202,177 +121,116 @@ } } - .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: 15px; - right: 5px; - cursor: pointer; - } - - .warning-item { - margin: 0.25em 0; - } - - .invite-link { - color: var(--tertiary); - cursor: pointer; - } - } - - .chat-message-avatar .chat-user-avatar .chat-user-avatar-container .avatar, + .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; +.touch .chat-message-container { + &.-active { + background: var(--d-hover); + border-radius: var(--d-border-radius); + + &.-bookmarked { + background: var(--highlight-low); + } + } } -.chat-messages-container { - .not-mobile-device & .chat-message:hover, - .chat-message.chat-message-selected { - background: var(--primary-very-low); +.no-touch .chat-message-container { + &:hover, + &.-active { + background: var(--d-hover); } - .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 { + &: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; + &.-active, + &:hover { + &.-bookmarked { + background: var(--highlight-medium); + } - .flag-count, - .d-icon { - color: var(--danger); - } -} + &.-deleted { + background-color: var(--danger-medium); + } -.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), -.chat-drawer-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; - } -} - -.chat-drawer-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; + &.-highlighted { + background-color: var(--tertiary-medium); } } } + +.chat-message-container { + background-color: var(--secondary); + + &.-errored { + color: var(--primary-medium); + } + + &.-deleted { + background-color: var(--danger-low); + padding-block: 0.25rem; + } + + &.-bookmarked { + background: var(--highlight-bg); + } + + &.-highlighted { + background-color: var(--tertiary-low); + } + + &.has-reply { + .chat-message { + 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; + } + } + } + + &.has-thread-indicator { + .chat-message { + display: grid; + grid-template-columns: var(--message-left-width) 1fr; + grid-template-rows: auto; + grid-template-areas: + "avatar message" + "threadindicator threadindicator"; + padding-bottom: 0.65rem !important; + + .chat-user-avatar { + grid-area: avatar; + } + + .chat-message-content { + grid-area: message; + } + } + } + + &:has(.tippy-box) { + position: relative; + z-index: 1; + } + .chat-message-reaction-list .chat-message-react-btn { + display: none; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-archive-channel.scss b/plugins/chat/assets/stylesheets/common/chat-modal-archive-channel.scss new file mode 100644 index 00000000000..aff98188d14 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-archive-channel.scss @@ -0,0 +1,25 @@ +.chat-modal-archive-channel { + .chat-to-topic-selector { + width: auto; + 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%; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-channel-summary.scss b/plugins/chat/assets/stylesheets/common/chat-modal-channel-summary.scss new file mode 100644 index 00000000000..3f795455cff --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-channel-summary.scss @@ -0,0 +1,10 @@ +.chat-modal-channel-summary { + .summarization-since, + .summary-area { + margin: 10px 0 10px 0; + } + + .summary-area { + min-height: 50px; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-create-channel.scss b/plugins/chat/assets/stylesheets/common/chat-modal-create-channel.scss new file mode 100644 index 00000000000..e1dfc3956f9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-create-channel.scss @@ -0,0 +1,42 @@ +.chat-modal-create-channel { + .modal-inner-container { + width: 500px; + } + + .choose-topic-results-list { + max-height: 200px; + overflow-y: scroll; + } + + .select-kit.combo-box, + &__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 + } + } + + &__hint { + font-size: var(--font-down-1); + padding-top: 0.25rem; + color: var(--secondary-low); + } + + &__control, + .edit-channel-control { + margin-bottom: 1rem; + } + + &__label-description { + margin: 0; + padding-top: 0.25rem; + color: var(--secondary-low); + font-size: var(--font-down-1) !important; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-edit-channel-description.scss b/plugins/chat/assets/stylesheets/common/chat-modal-edit-channel-description.scss new file mode 100644 index 00000000000..359f6e8144b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-edit-channel-description.scss @@ -0,0 +1,20 @@ +.chat-modal-edit-channel-description { + .exceeded-word-count { + .chat-modal-edit-channel-description__description-input { + outline: 1px solid var(--danger); + border: 1px solid var(--danger); + } + } + + &__description-input { + display: flex; + margin: 0; + min-height: 200px; + } + + &__description { + display: flex; + padding-bottom: 0.75rem; + color: var(--primary-medium); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-move-message-to-channel.scss b/plugins/chat/assets/stylesheets/common/chat-modal-move-message-to-channel.scss new file mode 100644 index 00000000000..2f9abebc49e --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-move-message-to-channel.scss @@ -0,0 +1,10 @@ +.chat-modal-move-message-to-channel { + &__channel-chooser { + width: 100%; + .category-chat-badge { + .d-icon { + color: inherit; + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-modal-new-message.scss b/plugins/chat/assets/stylesheets/common/chat-modal-new-message.scss new file mode 100644 index 00000000000..da33bf6813e --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-modal-new-message.scss @@ -0,0 +1,34 @@ +.chat-modal-new-message { + & + .modal-backdrop { + opacity: 1; + background: transparent; + } + + .modal-body { + padding: 0; + } + + .modal-header { + display: none; + } + + .modal-inner-container { + width: var(--modal-max-width); + box-shadow: var(--shadow-dropdown); + overflow: hidden; + } + + .mobile-device & { + .modal-inner-container { + border-radius: 0; + margin: 0 auto auto auto; + box-shadow: var(--shadow-modal); + } + } + + .not-mobile-device & { + .modal-inner-container { + margin: 10px auto auto auto; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-notices.scss b/plugins/chat/assets/stylesheets/common/chat-notices.scss new file mode 100644 index 00000000000..fdd17aa5004 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-notices.scss @@ -0,0 +1,49 @@ +.chat-notices { + display: flex; + flex-direction: column; + gap: 0.5em; + position: absolute; + top: 0; + z-index: 10; + width: 100%; + + .full-page-chat & { + padding-inline: 1rem; + box-sizing: border-box; + } + + &__notice, + .chat-retention-reminder { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--tertiary-low); + padding: 0.5em 0 0.5em 1em; + color: var(--primary); + padding: 0.5em 0 0.5em 1em; + min-width: 280px; + margin-left: auto; + margin-right: auto; + } + + .dismiss-btn { + margin: 0 0.25em; + color: var(--primary-medium); + align-self: flex-start; + + &:hover, + &:focus { + background-color: transparent; + .d-icon { + color: var(--primary); + } + } + .d-icon { + color: var(--primary-medium); + } + } +} + +.full-page-chat .chat-notices { + top: 4rem; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-onebox.scss b/plugins/chat/assets/stylesheets/common/chat-onebox.scss index 39250f62b23..f57d41a56ff 100644 --- a/plugins/chat/assets/stylesheets/common/chat-onebox.scss +++ b/plugins/chat/assets/stylesheets/common/chat-onebox.scss @@ -18,6 +18,7 @@ align-items: center; color: var(--primary-medium); display: flex; + flex-wrap: wrap; .avatar { aspect-ratio: 30 / 30; @@ -27,8 +28,50 @@ } } -.chat-transcript { - .chat-transcript-user-avatar .avatar { - aspect-ratio: 20 / 20; +.chat-drawer-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; + } +} + +.has-full-page-chat .chat-message .onebox:not(img), +.chat-drawer-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; } } diff --git a/plugins/chat/assets/stylesheets/common/chat-reply.scss b/plugins/chat/assets/stylesheets/common/chat-reply.scss index 765a04d957f..5b2259ae07d 100644 --- a/plugins/chat/assets/stylesheets/common/chat-reply.scss +++ b/plugins/chat/assets/stylesheets/common/chat-reply.scss @@ -1,12 +1,8 @@ .chat-reply { - display: contents; align-items: center; - box-sizing: border-box; + display: grid; font-size: var(--font-down-1); - padding-left: 0.5em; - height: 100%; - width: 100%; - white-space: nowrap; + grid: 1fr / auto-flow; .d-icon { color: var(--primary-low-mid); diff --git a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss index 3d93fc44576..5f3c2cce24d 100644 --- a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss @@ -1,12 +1,12 @@ -.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))"); + + &-container { + display: flex; + height: 16px; + } &:before { // unicode zero width space character diff --git a/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss b/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss deleted file mode 100644 index d1aeaaf3a91..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss +++ /dev/null @@ -1,35 +0,0 @@ -.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-scroll-to-bottom.scss b/plugins/chat/assets/stylesheets/common/chat-scroll-to-bottom.scss new file mode 100644 index 00000000000..eb8cb539643 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-scroll-to-bottom.scss @@ -0,0 +1,72 @@ +.chat-scroll-to-bottom { + display: flex; + justify-content: center; + margin: 0 1rem; + position: relative; + + &__arrow { + display: flex; + background: var(--primary-medium); + border-radius: 100%; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + position: relative; + } + + &__button { + align-items: center; + justify-content: center; + position: absolute; + flex-direction: column; + bottom: -25px; + background: none; + opacity: 0; + transition: opacity 0.25s ease, transform 0.5s ease; + transform: scale(0.1); + padding: 0; + z-index: z("dropdown"); + + .d-icon { + color: var(--secondary); + margin-left: 1px; // "fixes" the 1px svg shift + } + + > * { + pointer-events: none; + } + + &:hover, + &:active, + &:focus { + background: none !important; + .d-icon { + color: var(--secondary) !important; + } + } + + .no-touch & { + &:hover { + opacity: 1; + + .d-icon { + color: var(--primary-very-high) !important; + } + } + } + + &.visible { + transform: translateY(-32px) scale(1); + opacity: 0.8; + + &:hover { + transform: translateY(-32px) scale(1); + + &:active { + transform: translateY(-32px) scale(0.8); + } + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-section.scss b/plugins/chat/assets/stylesheets/common/chat-section.scss new file mode 100644 index 00000000000..bd4eae7ffc8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-section.scss @@ -0,0 +1,15 @@ +.chat-section { + border-bottom: 1px solid var(--primary-low); + padding: 1rem; + align-items: center; + display: flex; + flex-shrink: 0; + box-sizing: border-box; + + &__text { + align-items: baseline; + display: flex; + flex: 1 1 0; + min-width: 0; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss index 959fad11334..2d3e4bab323 100644 --- a/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss +++ b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss @@ -8,7 +8,7 @@ flex-direction: column; } - .chat-selection-management-buttons { + &__buttons { display: flex; gap: 0.5rem; @@ -18,7 +18,7 @@ } } - .chat-selection-message { + &__copy-success { animation: chat-quote-message-background-fade-highlight 2s ease-out 3s; animation-fill-mode: forwards; background-color: var(--success-low); diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss new file mode 100644 index 00000000000..61186aea264 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss @@ -0,0 +1,30 @@ +.chat-side-panel-resizer { + top: 0; + bottom: 0; + + position: absolute; + z-index: calc(z("header") - 1); + transition: background-color 0.15s 0.15s; + background-color: transparent; + + .touch & { + left: -6px; + width: 10px; + + &:active { + cursor: col-resize; + background: var(--tertiary); + } + } + + .no-touch & { + left: -3px; + width: 5px; + + &:hover, + &:active { + cursor: col-resize; + background: var(--tertiary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss new file mode 100644 index 00000000000..e65661a4ad8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss @@ -0,0 +1,20 @@ +#main-chat-outlet.chat-view { + min-height: 0; + display: grid; + grid-template-rows: 100%; + grid-template-areas: "main threads"; + grid-template-columns: 1fr auto; +} + +.chat-side-panel { + grid-area: threads; + box-sizing: border-box; + border-left: 1px solid var(--primary-low); + position: relative; + min-width: 250px; + + &__list { + flex-grow: 1; + padding: 0 1.5em 1em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss index 36beaa46676..c487474def3 100644 --- a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss +++ b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss @@ -1,34 +1,8 @@ -$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; + padding: 0.5em 1em; } &__message { @@ -52,10 +26,10 @@ $radius: 10px; border-radius: 50%; margin-right: 0.5rem; - .chat-skeleton__message:nth-of-type(odd) & { + .chat-skeleton__body:nth-of-type(odd) & { background-color: var(--primary-100); } - .chat-skeleton__message:nth-of-type(even) & { + .chat-skeleton__body:nth-of-type(even) & { background-color: var(--primary-200); } } @@ -66,12 +40,12 @@ $radius: 10px; margin-bottom: 0.25rem; width: 70px; height: 20px; - border-radius: $radius; + border-radius: var(--d-border-radius); - .chat-skeleton__message:nth-of-type(odd) & { + .chat-skeleton__body:nth-of-type(odd) & { background-color: var(--primary-100); } - .chat-skeleton__message:nth-of-type(even) & { + .chat-skeleton__body:nth-of-type(even) & { background-color: var(--primary-200); } } @@ -79,41 +53,66 @@ $radius: 10px; &__message-content { grid-area: content; width: 100%; + padding: 10px 0; } - &__message-msg { - height: 13px; - border-radius: $radius; - .chat-skeleton__message:nth-of-type(odd) & { + &__message-reactions { + display: flex; + padding: 5px 0 0 0; + } + + &__message-reaction { + background-color: var(--primary-100); + width: 32px; + height: 18px; + border-radius: var(--d-border-radius); + + & + & { + margin-left: 0.5rem; + } + } + + &__message-text { + display: flex; + padding: 0; + flex-direction: column; + } + + &__message-msg { + height: 10px; + border-radius: var(--d-border-radius); + margin: 2px 0; + + .chat-skeleton__body:nth-of-type(odd) & { background-color: var(--primary-100); } - .chat-skeleton__message:nth-of-type(even) & { + .chat-skeleton__body:nth-of-type(even) & { background-color: var(--primary-200); } + } - &.-line1 { - margin-top: 0.5rem; - margin-bottom: 0.5em; - } + &__message-img { + height: 80px; + border-radius: var(--d-border-radius); + margin: 2px 0; + width: 200px; + background-color: var(--primary-100); + } - &.-small { - width: 35%; - } - - &.-medium { - width: 60%; - } - - &.-large { - width: 85%; - } + *[class^="chat-skeleton__message-"] { + position: relative; + overflow: hidden; } &.-animation { position: relative; overflow: hidden; - &::after { + *[class^="chat-skeleton__message-"]:not( + .chat-skeleton__message-content + ):not(.chat-skeleton__message-text):not( + .chat-skeleton__message-reactions + ):after { position: absolute; top: 0; right: 0; @@ -123,11 +122,10 @@ $radius: 10px; 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) + rgba(var(--chat-skeleton-animation-rgb), 0.3) 50%, + rgba(var(--chat-skeleton-animation-rgb), 0.5) 100% ); - animation: shimmer 1.5s infinite; + animation: shimmer 1.25s infinite; content: ""; } diff --git a/plugins/chat/assets/stylesheets/common/chat-tabs.scss b/plugins/chat/assets/stylesheets/common/chat-tabs.scss index fe49815231b..70262b2a580 100644 --- a/plugins/chat/assets/stylesheets/common/chat-tabs.scss +++ b/plugins/chat/assets/stylesheets/common/chat-tabs.scss @@ -8,6 +8,7 @@ .chat-tabs__tabpanel { height: 100%; min-height: 1px; + padding-bottom: env(safe-area-inset-bottom); } .chat-tabs-list { diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-header-buttons.scss b/plugins/chat/assets/stylesheets/common/chat-thread-header-buttons.scss new file mode 100644 index 00000000000..777637121fa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-header-buttons.scss @@ -0,0 +1,66 @@ +@mixin chat-channel-header-button { + color: var(--primary-low-mid); + padding: 0.25em 0.4em; + + .d-icon { + color: inherit; + } + + &:visited { + color: var(--primary-low-mid); + } + + &:hover { + color: var(--primary-medium); + background: var(--primary-very-low); + border-radius: var(--d-border-radius); + + &:hover { + .d-icon { + color: inherit; + } + } + } + + > * { + pointer-events: none; + } +} + +.chat-channel { + .chat-threads-list-button { + @include chat-channel-header-button; + position: relative; + display: flex; + align-items: center; + + &.has-unreads { + color: var(--tertiary-med-or-tertiary); + gap: 0.25rem; + + &:hover { + color: var(--tertiary-hover); + } + } + + .d-icon { + margin-right: 0; + } + + &:hover { + .discourse-touch & { + background: none !important; + } + } + + &:active { + .discourse-touch & { + background: var(--secondary-very-high) !important; + } + } + } + + .open-drawer-btn { + @include chat-channel-header-button; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-header.scss b/plugins/chat/assets/stylesheets/common/chat-thread-header.scss new file mode 100644 index 00000000000..74547688cfd --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-header.scss @@ -0,0 +1,26 @@ +.chat-thread-header { + height: var(--chat-header-offset); + min-height: var(--chat-header-offset); + border-bottom: 1px solid var(--primary-low); + border-top: 1px solid var(--primary-low); + box-sizing: border-box; + display: flex; + align-items: center; + padding-inline: 0.5rem; + + .chat-thread__back-to-previous-route { + padding: 0.5rem 0; + margin-right: 0.5rem; + } + + &__buttons { + display: flex; + margin-left: auto; + } + + &__left-buttons { + display: flex; + flex-direction: row; + align-items: center; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-list-header.scss b/plugins/chat/assets/stylesheets/common/chat-thread-list-header.scss new file mode 100644 index 00000000000..698afa7c559 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-list-header.scss @@ -0,0 +1,15 @@ +.chat-thread-list-header { + height: var(--chat-header-offset); + min-height: var(--chat-header-offset); + border-bottom: 1px solid var(--primary-low); + border-top: 1px solid var(--primary-low); + box-sizing: border-box; + display: flex; + align-items: center; + padding-inline: 0.5rem; + + &__buttons { + display: flex; + margin-left: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss b/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss new file mode 100644 index 00000000000..6dc08b08820 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-list-item.scss @@ -0,0 +1,92 @@ +@mixin thread-list-item { + display: flex; + flex-direction: row; + padding: 0.5rem; + border-radius: var(--d-border-radius); + background-color: var(--primary-very-low); + border: 1px solid transparent; +} + +.chat-thread-list-item { + @include thread-list-item; + cursor: pointer; + margin: 0.25rem; + + & + .chat-thread-list-item { + margin-top: 0; + } + + .touch & { + &:active { + background-color: var(--d-hover); + border: 1px solid var(--primary-300); + } + } + + .no-touch & { + &:hover, + &:active { + background-color: var(--d-hover); + border: 1px solid var(--primary-300); + } + } + + &__main { + flex: 1 1 100%; + width: 100%; + } + + &__body { + padding-bottom: 0.25rem; + word-break: break-word; + margin: 0.5rem 0rem; + + > * { + pointer-events: none; + } + } + + &__metadata { + display: flex; + justify-content: flex-end; + } + + &__last-reply { + color: var(--secondary-low); + font-size: var(--font-down-1); + } + + &__header { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 0.25rem; + } + + &__title { + flex: 1 1 auto; + font-weight: bold; + } + + &__unread-indicator { + flex: 0 0 auto; + } + + &__open-button { + display: flex; + flex-direction: column; + box-sizing: border-box; + justify-content: center; + color: var(--primary); + + &:hover, + &:visited { + color: var(--primary); + } + } + + &__om-user-avatar { + margin-right: 0.5rem; + flex: 0 0 auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-participants.scss b/plugins/chat/assets/stylesheets/common/chat-thread-participants.scss new file mode 100644 index 00000000000..3fb9cf66f89 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-participants.scss @@ -0,0 +1,48 @@ +.chat-thread-participants { + margin-left: 0.5rem; + &__other-count { + font-size: var(--font-down-2); + color: var(--primary-high); + white-space: nowrap; + } + + &__avatar-group { + display: flex; + align-items: center; + justify-content: flex-end; + + .chat-user-avatar__container { + padding: 0; + } + + .chat-user-avatar { + width: auto !important; + + .avatar { + width: 24px; + height: 24px; + padding: 0; + } + } + } +} + +@container (max-width: 400px) { + .chat-thread-participants { + &__avatar-group { + flex-direction: row; + justify-content: flex-start; + + .chat-user-avatar { + &:not(:last-child) { + margin-right: -10px; + } + .avatar { + width: 22px; + height: 22px; + border: 1px solid var(--primary-very-low); + } + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread-unread-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-thread-unread-indicator.scss new file mode 100644 index 00000000000..eaf893aa528 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread-unread-indicator.scss @@ -0,0 +1,12 @@ +.chat-thread-header-unread-indicator, +.chat-thread-list-item-unread-indicator { + @include chat-unread-indicator; + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + padding: 0.21em 0.42em; + border-radius: 1em; + min-width: 0.6em; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-thread.scss b/plugins/chat/assets/stylesheets/common/chat-thread.scss new file mode 100644 index 00000000000..9a2a48b260f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-thread.scss @@ -0,0 +1,16 @@ +.chat-thread { + display: flex; + flex-direction: column; + position: relative; + @include chat-height; + + &__body { + overflow-y: scroll; + @include chat-scrollbar(); + box-sizing: border-box; + flex-grow: 1; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-threads-list.scss b/plugins/chat/assets/stylesheets/common/chat-threads-list.scss new file mode 100644 index 00000000000..ea97183b909 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-threads-list.scss @@ -0,0 +1,21 @@ +.chat-thread-list { + display: flex; + flex-direction: column; + position: relative; + @include chat-height(env(safe-area-inset-bottom)); + + &__items { + overflow-y: scroll; + @include chat-scrollbar(); + box-sizing: border-box; + flex-grow: 1; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + } + + &__no-threads { + @include thread-list-item; + margin: 0.5rem 0rem 0.5rem 0.5rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-transcript.scss b/plugins/chat/assets/stylesheets/common/chat-transcript.scss index b66d72fcd68..f5b686efebb 100644 --- a/plugins/chat/assets/stylesheets/common/chat-transcript.scss +++ b/plugins/chat/assets/stylesheets/common/chat-transcript.scss @@ -53,6 +53,10 @@ } } + .chat-transcript-user-avatar .avatar { + aspect-ratio: 20 / 20; + } + .chat-transcript-user { display: flex; flex-wrap: wrap-reverse; diff --git a/plugins/chat/assets/stylesheets/common/chat-unread-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-unread-indicator.scss new file mode 100644 index 00000000000..9759542783b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-unread-indicator.scss @@ -0,0 +1,23 @@ +@mixin chat-unread-indicator { + @include unselectable; + width: 14px; + height: 14px; + border-radius: 1em; + box-sizing: content-box; + -webkit-touch-callout: none; + background: var(--tertiary-med-or-tertiary); + color: var(--secondary); + font-size: var(--font-down-2); + text-align: center; + transition: border-color linear 0.15s; + + &.-urgent { + background: var(--success); + color: var(--secondary); + } + + &__number { + color: var(--secondary); + line-height: var(--line-height-small); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss new file mode 100644 index 00000000000..b313056f3aa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss @@ -0,0 +1,77 @@ +.chat-upload-drop-zone { + position: absolute; + visibility: hidden; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: z("max"); + align-items: center; + justify-content: center; + display: flex; + background: rgba(var(--always-black-rgb), 0.85); + + .uppy-is-drag-over & { + visibility: visible; + } + + &__content { + position: relative; + width: 50%; + height: 50%; + } + + &__background { + svg { + transform: scale(0.1); + transition: transform 200ms ease-in-out; + height: 80px; + + .uppy-is-drag-over & { + transform: scale(1); + } + } + + position: absolute; + top: 0; + left: calc(50% - 100px / 2); + z-index: 1; + } + + &__illustration { + svg { + transform: scale(0.1); + transition: transform 200ms ease-in-out; + height: 80px; + + .uppy-is-drag-over & { + transform: scale(1); + } + } + + position: absolute; + top: 0; + left: calc(50% - 100px / 2); + z-index: 1; + } + + &__text { + position: absolute; + top: 100px; + left: 0; + right: 0; + width: 100%; + z-index: 1; + display: flex; + justify-content: center; + + &__title { + width: 100%; + font-weight: 600; + text-align: center; + font-size: var(--font-up-2); + padding-inline: 1rem; + color: var(--secondary-or-primary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-user-avatar.scss b/plugins/chat/assets/stylesheets/common/chat-user-avatar.scss new file mode 100644 index 00000000000..76bd0f0a4cb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-user-avatar.scss @@ -0,0 +1,53 @@ +.chat-user-avatar { + @include unselectable; + display: flex; + align-items: center; + + .chat-message-container:not(.has-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; + } + } + + &__container { + position: relative; + padding: 1px; // for is-online box-shadow effect, preventing cutoff + + .avatar { + padding: 1px; // for is-online box-shadow effect, preventing shift + } + + .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; + } +} diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss deleted file mode 100644 index f36cc2920f8..00000000000 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ /dev/null @@ -1,683 +0,0 @@ -$float-height: 530px; - -:root { - --message-left-width: 42px; - --full-page-border-radius: 12px; - --full-page-sidebar-width: 275px; - --channel-list-avatar-size: 30px; - --chat-header-offset: 65px; -} - -.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; - -webkit-touch-callout: none; - background: var(--tertiary-med-or-tertiary); - color: var(--secondary); - font-size: var(--font-down-2); - text-align: center; - - &.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.chat-header-icon { - .icon { - .chat-channel-unread-indicator { - border: 2px solid var(--header_background); - position: absolute; - right: 2px; - bottom: 2px; - transition: border-color linear 0.15s; - } - } - - span.icon { - cursor: auto; - - &:hover { - .d-icon { - color: var(--header_primary-low-mid); - } - - background: none; - } - } - - a.icon { - &.active { - .d-icon-comment { - color: var(--primary-medium); - } - } - - &:hover { - .chat-channel-unread-indicator { - border-color: var(--primary-low); - } - } - } -} - -.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; - } -} - -.avatar { - border: 1px solid transparent; - padding: 0; - box-sizing: border-box; - - .is-online & { - border: 1px solid var(--secondary); - box-shadow: 0px 0px 0px 1px var(--success); - } -} - -.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; - padding: 1px; //for is-online boxshadow effect, preventing cutoff - - .avatar { - padding: 1px; ////for is-online boxshadow effect, preventing shift - } - - .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; - } -} - -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; - - .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, - .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 { - 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; - } -} - -.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; - } -} - -.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 { - background: var(--secondary); - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--primary-low); -} - -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 index 71c68a54dc4..e9f16af695c 100644 --- a/plugins/chat/assets/stylesheets/common/core-extensions.scss +++ b/plugins/chat/assets/stylesheets/common/core-extensions.scss @@ -1,5 +1,5 @@ .has-full-page-chat { - .create-topics-notice, + .global-notice, .bootstrap-mode-notice { display: none; } diff --git a/plugins/chat/assets/stylesheets/common/d-progress-bar.scss b/plugins/chat/assets/stylesheets/common/d-progress-bar.scss deleted file mode 100644 index 37ade41970b..00000000000 --- a/plugins/chat/assets/stylesheets/common/d-progress-bar.scss +++ /dev/null @@ -1,51 +0,0 @@ -// 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/direct-message-creator.scss b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss index 02444815adf..c857ae4d8d3 100644 --- a/plugins/chat/assets/stylesheets/common/direct-message-creator.scss +++ b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss @@ -40,7 +40,7 @@ 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-radius: var(--d-border-radius-large); border: 1px solid var(--primary-300); align-items: center; display: flex; @@ -114,7 +114,7 @@ margin: 0; flex-wrap: wrap; border-bottom: 1px solid var(--primary-low); - box-shadow: shadow("card"); + box-shadow: var(--shadow-card); position: absolute; width: 100%; z-index: z("dropdown"); @@ -129,7 +129,7 @@ padding: 0.25em 0.5em; margin: 0.25rem; align-items: center; - border-radius: 4px; + border-radius: var(--d-border-radius); .user-info { margin: 0; @@ -158,7 +158,8 @@ margin-left: 0.3em; .emoji { - margin-bottom: 0.2em; + width: 15px; + height: 15px; } } } @@ -179,7 +180,7 @@ text-align: center; padding: 1rem; width: 100%; - box-shadow: shadow("card"); + box-shadow: var(--shadow-card); background: var(--secondary); margin: 0; box-sizing: border-box; diff --git a/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss index 4b75238fcf7..4421a8fa185 100644 --- a/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss +++ b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss @@ -6,7 +6,7 @@ justify-content: space-between; background-color: var(--primary-very-low); padding: 1em; - border-radius: 6px; + border-radius: var(--d-border-radius-large); margin-bottom: 1em; &--details { diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss new file mode 100644 index 00000000000..b94060c5963 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -0,0 +1,66 @@ +@import "chat-unread-indicator"; +@import "chat-height-mixin"; +@import "chat-thread-header-buttons"; +@import "base-common"; +@import "sidebar-extensions"; +@import "chat-browse"; +@import "chat-channel"; +@import "chat-channel-card"; +@import "chat-channel-info"; +@import "chat-channel-preview-card"; +@import "chat-channel-settings-saved-indicator"; +@import "chat-channel-title"; +@import "chat-composer-dropdown"; +@import "chat-composer-upload"; +@import "chat-composer-uploads"; +@import "chat-composer"; +@import "chat-composer-button"; +@import "chat-drawer"; +@import "chat-emoji-picker"; +@import "chat-form"; +@import "chat-index"; +@import "chat-mention-warnings"; +@import "chat-message-actions"; +@import "chat-message-collapser"; +@import "chat-message-images"; +@import "chat-message-info"; +@import "chat-message-left-gutter"; +@import "chat-message-separator"; +@import "chat-message-thread-indicator"; +@import "chat-message"; +@import "chat-notices"; +@import "chat-onebox"; +@import "chat-reply"; +@import "chat-replying-indicator"; +@import "chat-selection-manager"; +@import "chat-side-panel"; +@import "chat-skeleton"; +@import "chat-tabs"; +@import "chat-thread"; +@import "chat-side-panel-resizer"; +@import "chat-upload-drop-zone"; +@import "chat-transcript"; +@import "core-extensions"; +@import "dc-filter-input"; +@import "full-page-chat-header"; +@import "incoming-chat-webhooks"; +@import "reviewable-chat-message"; +@import "chat-thread-list-item"; +@import "chat-threads-list"; +@import "chat-composer-separator"; +@import "chat-thread-header"; +@import "chat-thread-list-header"; +@import "chat-thread-unread-indicator"; +@import "chat-thread-participants"; +@import "chat-message-mention-warning"; +@import "chat-message-error"; +@import "chat-message-creator"; +@import "chat-user-avatar"; +@import "chat-modal-new-message"; +@import "chat-modal-archive-channel"; +@import "chat-modal-edit-channel-description"; +@import "chat-modal-create-channel"; +@import "chat-modal-create-channel"; +@import "chat-modal-channel-summary"; +@import "chat-modal-move-message-to-channel"; +@import "chat-scroll-to-bottom"; diff --git a/plugins/chat/assets/stylesheets/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss similarity index 94% rename from plugins/chat/assets/stylesheets/sidebar-extensions.scss rename to plugins/chat/assets/stylesheets/common/sidebar-extensions.scss index bc817db6dd0..6d2c572e8f6 100644 --- a/plugins/chat/assets/stylesheets/sidebar-extensions.scss +++ b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss @@ -77,7 +77,7 @@ } } - .open-draft-channel-page-btn, + .open-new-message-btn, .open-browse-page-btn, .edit-channels-dropdown .select-kit-header, .chat-channel-leave-btn { @@ -136,16 +136,6 @@ } .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; diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss similarity index 60% rename from plugins/chat/assets/stylesheets/desktop/desktop.scss rename to plugins/chat/assets/stylesheets/desktop/base-desktop.scss index 087736187b1..7323f6a67cf 100644 --- a/plugins/chat/assets/stylesheets/desktop/desktop.scss +++ b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss @@ -1,8 +1,3 @@ -.chat-drawer { - width: 400px; - max-width: 100vw; -} - .user-card, .group-card { z-index: z("usercard") + 1; // bump up user card @@ -12,7 +7,7 @@ &.teams-sidebar-on { grid-template-columns: 1fr; - .chat-live-pane { + .chat-channel { border-radius: var(--full-page-border-radius); } } @@ -24,40 +19,61 @@ flex-shrink: 0; } - .chat-live-pane { + .chat-channel { .chat-messages-container { - .chat-message { - &.is-reply { - grid-template-columns: var(--message-left-width) 1fr; - } + &.has-reply { + grid-template-columns: var(--message-left-width) 1fr; + } - .chat-user { - width: var(--message-left-width); - } + .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) { + img:not(.emoji):not(.avatar, .onebox-avatar-inline) { transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); &:hover { cursor: pointer; - border-radius: 5px; + border-radius: var(--d-border-radius); 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; +.chat-message-container:not(.-user-info-hidden) { + .chat-message { + padding: 0.65rem 1rem 0.15rem; + } +} + +.chat-message-container.-user-info-hidden { + .chat-message { + padding: 0.15rem 1rem; + } + + .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); + } + + &:hover { + .chat-message-left-gutter__bookmark { + display: none; + } + + .chat-time { + display: block; + } + } } // Full Page Styling in Core @@ -72,20 +88,6 @@ .full-page-chat { border-right: 1px solid var(--primary-low); border-left: 1px solid var(--primary-low); - - .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)) { @@ -108,22 +110,12 @@ } .full-page-chat.teams-sidebar-on { - .chat-live-pane { + .chat-channel { 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 { diff --git a/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss index 019738fe4d9..c781d127d11 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss @@ -3,6 +3,12 @@ &:hover { background: var(--primary-very-low); - border-radius: 5px; + border-radius: var(--d-border-radius); + } + + .chat-channel-title { + &__user-info { + overflow: hidden; + } } } diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss deleted file mode 100644 index 9ab578cbf85..00000000000 --- a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss +++ /dev/null @@ -1,5 +0,0 @@ -.chat-composer-container { - .chat-composer { - margin: 0.25rem 10px 0 10px; - } -} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-index-drawer.scss b/plugins/chat/assets/stylesheets/desktop/chat-index-drawer.scss index 4fa41f81053..809821f2e31 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-index-drawer.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-index-drawer.scss @@ -4,10 +4,16 @@ font-size: var(--font-0); } + &.has-scrollbar { + .chat-channel-row { + margin-right: 0; + } + } + .chat-channel-row { height: 3.6em; padding: 0 0.5rem; - margin: 0 0 0 0.5rem; + margin: 0 0.5rem 0 0.5rem; &:not(:last-of-type) { border-bottom: 1px solid var(--primary-low); diff --git a/plugins/chat/assets/stylesheets/desktop/chat-index-full-page.scss b/plugins/chat/assets/stylesheets/desktop/chat-index-full-page.scss index a175d8779dd..3c48c67a52e 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-index-full-page.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-index-full-page.scss @@ -1,7 +1,7 @@ .has-full-page-chat:not(.discourse-sidebar) { .full-page-chat { .channels-list { - height: 100%; + height: calc(100vh - var(--header-offset)); border-right: 1px solid var(--primary-low); background: var(--primary-very-low); @@ -21,7 +21,6 @@ height: 2.5em; padding: 0 0.5rem; margin: 0 0.5rem 0.125rem 0.5rem; - border-radius: 0.25em; &:hover, &.active { background-color: var(--primary-low); diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss b/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss index 3d015632e7c..64b2a7382de 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-message-actions.scss @@ -1,5 +1,5 @@ -.chat-message-actions[data-popper-reference-hidden], -.chat-message-actions[data-popper-escaped] { +.chat-message-actions-container[data-popper-reference-hidden], +.chat-message-actions-container[data-popper-escaped] { visibility: hidden; pointer-events: none; } diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss b/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss new file mode 100644 index 00000000000..c69516aaab6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss @@ -0,0 +1,7 @@ +.chat-message-creator { + &__row { + &.-active { + background: var(--tertiary-very-low); + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message-thread-indicator.scss b/plugins/chat/assets/stylesheets/desktop/chat-message-thread-indicator.scss new file mode 100644 index 00000000000..57a6fadc01d --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message-thread-indicator.scss @@ -0,0 +1,32 @@ +.chat-message-thread-indicator { + max-width: 600px; + grid-template-areas: + "avatar info replies participants" + "avatar excerpt excerpt excerpt"; + + &__replies-count { + display: flex; + align-self: center; + } +} + +@container (max-width: 400px) { + .chat-message-thread-indicator { + grid-template-areas: + "avatar info info participants" + "excerpt excerpt excerpt replies"; + &__replies-count { + align-self: flex-start; + grid-area: replies; + justify-content: flex-end; + } + &__last-reply-excerpt { + white-space: wrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + margin-top: 0.5rem; + margin-right: 0.25rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message.scss b/plugins/chat/assets/stylesheets/desktop/chat-message.scss index 8ea9c38357c..ddfea4214c9 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-message.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-message.scss @@ -1,11 +1,12 @@ .chat-message-actions { .react-btn, .reply-btn, + .chat-message-thread-btn, .bookmark-btn { border: 1px solid transparent; - border-bottom-color: var(--primary-low); + border-bottom-color: var(--primary-300); border-radius: 0; - border-top-color: var(--primary-low); + border-top-color: var(--primary-300); &:hover { background: var(--primary-low); diff --git a/plugins/chat/assets/stylesheets/desktop/index.scss b/plugins/chat/assets/stylesheets/desktop/index.scss new file mode 100644 index 00000000000..859b736a081 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/index.scss @@ -0,0 +1,10 @@ +@import "base-desktop"; +@import "chat-channel-title"; +@import "chat-composer-uploads"; +@import "chat-index-drawer"; +@import "chat-index-full-page"; +@import "chat-message-actions"; +@import "chat-message"; +@import "chat-message-creator"; +@import "chat-message-thread-indicator"; +@import "sidebar-extensions"; diff --git a/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss new file mode 100644 index 00000000000..4d8a75543c8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss @@ -0,0 +1,40 @@ +@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-300); + 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); + } + + &:focus { + background: none; + } + } + + .emoji { + height: 15px; + margin-right: 4px; + width: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss index 0fa99b9fb9f..a0a5d588021 100644 --- a/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss +++ b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss @@ -1,28 +1,43 @@ -@mixin chat-scrollbar($border: var(--primary-very-low)) { +@mixin chat-scrollbar() { --scrollbarBg: transparent; --scrollbarThumbBg: var(--primary-low); - --scrollbarWidth: 1.2rem; + --scrollbarWidth: 10px; scrollbar-color: transparent var(--scrollbarBg); transition: scrollbar-color 0.25s ease-in-out; - transition-delay: 0.5s; + // chrome, safari &::-webkit-scrollbar-thumb { - background-color: transparent; + background-color: var(--scrollbarThumbBg); border-radius: calc(var(--scrollbarWidth) / 2); - border: calc(var(--scrollbarWidth) / 4) solid transparent; + border: calc(var(--scrollbarWidth) / 4) solid var(--secondary); } - &: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-track { + background-color: transparent; } + &::-webkit-scrollbar { width: var(--scrollbarWidth); } + + // firefox + & { + scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); + scrollbar-width: thin; + } + + &::-moz-scrollbar-thumb { + background-color: var(--scrollbarThumbBg); + border-radius: calc(var(--scrollbarWidth) / 2); + border: calc(var(--scrollbarWidth) / 4) solid var(--secondary); + } + + &::-moz-scrollbar-track { + background-color: transparent; + } + + &::-moz-scrollbar { + width: var(--scrollbarWidth); + } } diff --git a/plugins/chat/assets/stylesheets/mixins/index.scss b/plugins/chat/assets/stylesheets/mixins/index.scss new file mode 100644 index 00000000000..82e3451dc32 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/index.scss @@ -0,0 +1,2 @@ +@import "chat-scrollbar"; +@import "chat-reaction"; diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss similarity index 71% rename from plugins/chat/assets/stylesheets/mobile/mobile.scss rename to plugins/chat/assets/stylesheets/mobile/base-mobile.scss index ff9c1b60b23..463462f983d 100644 --- a/plugins/chat/assets/stylesheets/mobile/mobile.scss +++ b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss @@ -2,17 +2,34 @@ --channel-list-avatar-size: 38px; } -.chat-message:not(.user-info-hidden) { - padding-top: 0.75em; +.chat-message-container:not(.-user-info-hidden) { + .chat-message { + padding-top: 0.75em; + } } -body.has-full-page-chat { +html.has-full-page-chat { .footer-nav { display: none !important; } - #main-outlet { + body #main-outlet { padding: 0; + + .main-chat-outlet { + .chat-channel { + min-width: 0; + } + + &.has-side-panel-expanded { + grid-template-columns: 1fr; + grid-template-areas: "threads"; + + .chat-channel { + display: none; + } + } + } } } @@ -30,7 +47,7 @@ body.has-full-page-chat { } } - .chat-live-pane { + .chat-channel { border-radius: 0; padding: 0; } @@ -45,10 +62,6 @@ body.has-full-page-chat { height: 50px; min-height: 50px; } - - .chat-messages-scroll { - padding: 0 10px; - } } .sidebar-container .channels-list .chat-channel-divider { @@ -77,13 +90,6 @@ body.has-full-page-chat { } } -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; } @@ -98,5 +104,8 @@ html.has-full-page-chat body { color: var(--primary-medium); } } + .chat-channel-unread-indicator { + border-color: var(--primary-very-low); + } } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-channel.scss b/plugins/chat/assets/stylesheets/mobile/chat-channel.scss new file mode 100644 index 00000000000..5eabe9eec06 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-channel.scss @@ -0,0 +1,5 @@ +.chat-channel { + .chat-messages-scroll { + padding-bottom: 5px; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss new file mode 100644 index 00000000000..b99e9877373 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss @@ -0,0 +1,11 @@ +.chat-composer-upload { + &__remove-btn { + visibility: visible; + background: rgba(var(--always-black-rgb), 0.9); + border-radius: 100%; + + // overwrite ios style + font-size: var(--font-down-2) !important; + padding: 5px !important; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss index edbc974c9dd..d6334192c33 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss @@ -1,7 +1,7 @@ -.chat-composer-container { - padding: 0; - - .chat-composer { - margin: 0.5rem 10px 0 10px; +.chat-composer { + &__input { + .ios-device & { + background-color: transparent; + } } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss new file mode 100644 index 00000000000..c169432dcce --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss @@ -0,0 +1,26 @@ +.chat-channel-message-emoji-picker-connector { + position: relative; + + .chat-emoji-picker { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 50vh; + width: 100%; + box-shadow: var(--shadow-card); + z-index: z("header") + 2; + max-width: 100vw; + box-sizing: border-box; + + &__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--always-black-rgb), 0.75); + z-index: z("header") + 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-index.scss b/plugins/chat/assets/stylesheets/mobile/chat-index.scss index 7659920f80a..c4f7f696131 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-index.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-index.scss @@ -1,4 +1,5 @@ @import "common/foundation/mixins"; + .full-page-chat { overflow: hidden; //prevents double scroll @@ -7,24 +8,14 @@ padding-bottom: 6rem; box-sizing: border-box; - @media (hover: none) { - .chat-channel-row:hover { - background: transparent; - } - - .chat-channel-row:active { - background: var(--primary-low); - } - } - .channels-list-container { background: var(--secondary); } .chat-channel-row { height: 4em; - margin: 0 1.5rem; - padding: 0; + margin: 0; + padding: 0 1.5rem; border-radius: 0; border-bottom: 1px solid var(--primary-low); @@ -77,34 +68,12 @@ &__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); + &__user-status-message { + flex-shrink: 3; + overflow: hidden; + text-overflow: ellipsis; + } } } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss index eac83541f83..cf313273d12 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss @@ -5,9 +5,11 @@ right: 0; display: flex; flex-direction: column; - border-radius: 8px 8px 0 0; - margin: 0 2px; - transition: bottom 0.2s ease; + margin: 0 5px; + transition: bottom 0.2s cubic-bezier(0.4, 0, 0.2, 1), + visibility cubic-bezier(0.4, 0, 0.2, 1); + visibility: hidden; + box-sizing: border-box; .selected-message-container { padding: 0.5em 0.5em 1em 0.5em; @@ -19,9 +21,10 @@ 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 { + margin-left: 5px; + &:not(.is-expanded) { @include ellipsis; } @@ -75,10 +78,15 @@ .chat-message-reaction, .reply-btn, + .chat-message-thread-btn, .react-btn, .bookmark-btn { flex-grow: 1; height: 42px; + + &:active { + background: var(--primary-low); + } } .bookmark-btn, @@ -90,7 +98,6 @@ } .reply-btn { - border-radius: 3px; .d-icon { font-size: var(--font-up-4); } @@ -109,14 +116,15 @@ border-bottom: 1px solid var(--primary-low); width: 100%; list-style: none; - padding-bottom: 0.25em; - margin-bottom: 0.25em; + padding: 0.25em 0; display: flex; + &:active { + background: var(--primary-low); + } + &:last-child { - border: 0; - margin: 0; - padding: 0; + border-bottom: 0; } .chat-message-action { @@ -143,7 +151,7 @@ height: 100%; width: 100%; z-index: z("header") + 1; - transition: background-color 0.2s ease; + transition: background-color 0.4s ease-in; .collapse-area { width: 100%; @@ -151,10 +159,11 @@ } &.fade-in { - background-color: rgba(0, 0, 0, 0.35); + background: rgba(var(--always-black-rgb), 0.6); .chat-message-actions { - bottom: 0; + bottom: 0px; + visibility: visible; } } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss new file mode 100644 index 00000000000..5f9b7ad18f8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss @@ -0,0 +1,40 @@ +.chat-message-creator { + &__open-dm-btn { + width: 100%; + max-width: 100%; + } + + &__row { + padding-block: 0.5rem; + } + + .chat-channel-title__name, + .chat-user-display-name { + font-size: var(--font-up-1); + } + + .chat-channel-title__category-badge { + display: flex; + align-items: center; + justify-content: center; + padding-inline: 2px; + width: 24px; + height: 24px; + + svg:not(.chat-channel-title__restricted-category-icon) { + height: inherit; + width: inherit; + } + } + + .chat-user-avatar { + &__container { + padding: 0; + } + img { + width: 28px; + height: 28px; + box-sizing: border-box; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-thread-indicator.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-thread-indicator.scss new file mode 100644 index 00000000000..ff8614ed93c --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-thread-indicator.scss @@ -0,0 +1,24 @@ +.chat-message-thread-indicator { + grid-template-areas: + "avatar info info participants" + "excerpt excerpt excerpt replies"; + + .chat-thread-participants { + .avatar { + width: 22px; + height: 22px; + } + } + + &__last-reply-excerpt { + white-space: wrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + margin-top: 0.5rem; + margin-right: 0.25rem; + } + &__replies-count { + grid-area: replies; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss index c3517ab9858..3e66d09af4a 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -1,10 +1,44 @@ -.chat-message *, -.chat-composer-row, -.chat-reply, -.replying-text { - @include unselectable; -} +.mobile-view.has-full-page-chat { + &.disable-message-actions-touch { + .chat-message-actions { + > * { + pointer-events: none; + } + } + } -.chat-message-container { - transform: translateZ(0); + #skip-link { + @include user-select(none); + } + + #skip-link, + .d-header, + .chat-message-actions-mobile-outlet, + .chat-channel, + .chat-thread { + > * { + @include user-select(none); + } + } + + .chat-message-container { + transition: transform 400ms; + transform: scale(1); + + &.-active { + animation: scale-animation 400ms; + } + } + + @keyframes scale-animation { + 0% { + transform: scale(1); + } + 80% { + transform: scale(0.95); + } + 100% { + transform: scale(1); + } + } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-modal-thread-settings.scss b/plugins/chat/assets/stylesheets/mobile/chat-modal-thread-settings.scss new file mode 100644 index 00000000000..db511f4e7d0 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-modal-thread-settings.scss @@ -0,0 +1,9 @@ +.modal-chat-thread-settings { + .modal-inner-container { + width: 98%; + } + + &__title-input { + width: 100%; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss index 97f0643313d..82d69177b03 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss @@ -1,8 +1,9 @@ .chat-selection-management { - .chat-selection-management-buttons { + &__buttons { display: flex; flex-direction: column; width: 100%; + padding-bottom: env(safe-area-inset-bottom); .cancel-btn { margin-left: initial; @@ -10,6 +11,10 @@ .btn { margin-bottom: 0.25em; + + &:last-child { + margin-bottom: 0; + } } } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-side-panel.scss b/plugins/chat/assets/stylesheets/mobile/chat-side-panel.scss new file mode 100644 index 00000000000..916cd6bbef6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-side-panel.scss @@ -0,0 +1,3 @@ +.chat-side-panel { + border-left: 0; +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-thread.scss b/plugins/chat/assets/stylesheets/mobile/chat-thread.scss new file mode 100644 index 00000000000..7659acf76b8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-thread.scss @@ -0,0 +1,9 @@ +.chat-thread { + &__body { + margin: 0 1px 0 0; + } + + .chat-messages-scroll { + padding: 10px 10px 0 10px; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss b/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss new file mode 100644 index 00000000000..ddc9ec0e52a --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-threads-list.scss @@ -0,0 +1,11 @@ +.chat-thread-list { + &__items { + .chat-thread-list-item { + margin: 0.5rem; + } + + &__no-threads { + margin: 0.5rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss new file mode 100644 index 00000000000..f9fdace3e91 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -0,0 +1,16 @@ +@import "base-mobile"; +@import "chat-channel-info"; +@import "chat-channel"; +@import "chat-composer"; +@import "chat-index"; +@import "chat-message-actions"; +@import "chat-message"; +@import "chat-selection-manager"; +@import "chat-emoji-picker"; +@import "chat-composer-upload"; +@import "chat-side-panel"; +@import "chat-thread"; +@import "chat-threads-list"; +@import "chat-modal-thread-settings"; +@import "chat-message-thread-indicator"; +@import "chat-message-creator"; diff --git a/plugins/chat/config/locales/client.ar.yml b/plugins/chat/config/locales/client.ar.yml index c91cb1329ac..64608803d76 100644 --- a/plugins/chat/config/locales/client.ar.yml +++ b/plugins/chat/config/locales/client.ar.yml @@ -38,13 +38,6 @@ ar: 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: "إعدادات القناة" @@ -75,7 +68,6 @@ ar: instructions: "يمنع إغلاق القناة المستخدمين من غير فريق العمل من إرسال رسائل جديدة أو تعديل الرسائل الحالية. هل تريد بالتأكيد إغلاق هذه القناة؟" channel_delete: title: "حذف القناة" - instructions: "

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

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

    " confirm: "أتفهم العواقب، احذف القناة" confirm_channel_name: "أدخل اسم القناة" process_started: "لقد بدأت عملية حذف القناة. سيتم إغلاق هذا النموذج بعد قليل، ولن يصبح بإمكانك رؤية القناة المحذوفة في أي مكان." @@ -86,7 +78,6 @@ ar: close: "إغلاق" collapse: "طي ساحب الدردشة" confirm_flag: "هل تريد بالتأكيد الإبلاغ عن رسالة %{username}؟" - deleted: "تم حذف رسالة. [view]" hidden: "تم حذف إحدى الرسائل. [view]" delete: "حذف" edited: "تم التعديل" @@ -101,6 +92,8 @@ ar: never: "أبدًا" title: "إشعارات البريد الإلكتروني" when_away: "عندما أكون غائبًا فقط" + header_indicator_preference: + never: "أبدًا" enable: "تفعيل الدردشة" flag: "الإبلاغ" emoji: "إدراج رمز تعبيري" @@ -110,16 +103,11 @@ ar: in_reply_to: "ردًا على" heading: "الدردشة" join: "الانضمام" - new_messages: "رسائل جديدة" + last_visit: "آخر زيارة" + summarization: + summarize: "تلخيص" mention_warning: dismiss: "تجاهل" - cannot_see: - zero: "لا يمكن للمستخدم %{username} و%{others} مستخدم آخر الوصول إلى هذه القناة ولن يتلقوا إشعارات." - one: "لا يمكن للمستخدم %{username} ومستخدم (%{others}) آخر الوصول إلى هذه القناة ولن يتلقيا إشعارات." - two: "لا يمكن للمستخدم %{username} ومستخدمَين (%{others}) آخرين الوصول إلى هذه القناة ولن يتلقوا إشعارات." - few: "لا يمكن للمستخدم %{username} و%{others} مستخدمين آخر الوصول إلى هذه القناة ولن يتلقوا إشعارات." - many: "لا يمكن للمستخدم %{username} و%{others} مستخدمًا آخر الوصول إلى هذه القناة ولن يتلقوا إشعارات." - other: "لا يمكن للمستخدم %{username} و%{others} مستخدم آخر الوصول إلى هذه القناة ولن يتلقوا إشعارات." invitations_sent: zero: "تم إرسال دعوات" one: "تم إرسال دعوة" @@ -128,70 +116,10 @@ ar: many: "تم إرسال دعوات" other: "تم إرسال دعوات" invite: "دعوة إلى القناة" - without_membership: - zero: "لم ينضم %{username} إلى هذه القناة." - one: "لم ينضم %{username} و%{others} مستخدم آخر إلى هذه القناة." - two: "لم ينضم %{username} ومستخدمان (%{others}) آخران إلى هذه القناة." - few: "لم ينضم %{username} و%{others} مستخدمين آخرين إلى هذه القناة." - many: "لم ينضم %{username} و%{others} مستخدمًا آخر إلى هذه القناة." - other: "لم ينضم %{username} و%{others} مستخدم آخر إلى هذه القناة." - group_mentions_disabled: - zero: "لا تسمح المجموعة %{group_name} و%{others} مجموعة أخرى بالإشارات." - one: "لا تسمح المجموعة %{group_name} بالإشارات." - two: "لا تسمح المجموعة %{group_name} ومجموعتان (%{others}) أخرتان بالإشارات." - few: "لا تسمح المجموعة %{group_name} و%{others} مجموعات أخرى بالإشارات." - many: "لا تسمح المجموعة %{group_name} و%{others} مجموعةً أخرى بالإشارات." - other: "لا تسمح المجموعة %{group_name} و%{others} مجموعة أخرى بالإشارات." - too_many_members: - zero: "تحتوي المجموعة %{group_name} و%{others} مجموعة أخرى على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - one: "تحتوي المجموعة %{group_name} ومجموعة (%{others}) أخرى على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - two: "تحتوي المجموعة %{group_name} ومجموعتان (%{others}) أخرتان على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - few: "تحتوي المجموعة %{group_name} و%{others} مجموعات أخرى على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - many: "تحتوي المجموعة %{group_name} و%{others} مجموعةً أخرى على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - other: "تحتوي المجموعة %{group_name} و%{others} مجموعة أخرى على أعضاء كثيرين. لم يتلقَّ أحد الإشعارات" - warning_multiple: - zero: "%{count} آخر" - one: "%{count} آخر" - two: "%{count} آخران" - few: "%{count} آخرين" - many: "%{count} آخرين" - other: "%{count} آخرين" groups: header: some: "لن يتلقى بعض المستخدمين الإشعارات" all: "لن يتلقى أحد الإشعارات" - unreachable: - zero: "لا تسمح المجموعة @%{group} و@%{group_2} بالإشارات" - one: "لا تسمح المجموعة @%{group} بالإشارات" - two: "لا تسمح المجموعة @%{group} و@%{group_2} بالإشارات" - few: "لا تسمح المجموعة @%{group} و@%{group_2} بالإشارات" - many: "لا تسمح المجموعة @%{group} و@%{group_2} بالإشارات" - other: "لا تسمح المجموعة @%{group} و@%{group_2} بالإشارات" - unreachable_multiple: "لا تسمح المجموعة @%{group} و%{count} من المجموعات الأخرى بالإشارات" - too_many_members: - zero: "تتجاوز الإشارة إلى @%{group} أو @%{group_2} حد الإشعارات البالغ %{notification_limit} من %{limit}" - one: "تتجاوز الإشارة إلى @%{group} حد الإشعارات البالغ %{notification_limit} من %{limit}" - two: "تتجاوز الإشارة إلى @%{group} أو @%{group_2} حد الإشعارات البالغ %{notification_limit} من %{limit}" - few: "تتجاوز الإشارة إلى @%{group} أو @%{group_2} حد الإشعارات البالغ %{notification_limit} من %{limit}" - many: "تتجاوز الإشارة إلى @%{group} أو @%{group_2} حد الإشعارات البالغ %{notification_limit} من %{limit}" - other: "تتجاوز الإشارة إلى @%{group} أو @%{group_2} حد الإشعارات البالغ %{notification_limit} من %{limit}" - too_many_members_multiple: "تتجاوز هذه المجموعات البالغ عددها %{count} حد الإشعارات البالغ %{notification_limit} من %{limit}" - users_limit: - zero: "%{count} مستخدم" - one: "مستخدم واحد (%{count})" - two: "مستخدمان (%{count})" - few: "%{count} مستخدمين" - many: "%{count} مستخدمًا" - other: "%{count} مستخدم" - notification_limit: "حد الإشعارات" - too_many_mentions: "تتجاوز هذه الرسالة حد الإشعارات البالغ %{notification_limit} من %{limit}" - mentions_limit: - zero: "%{count} إشارة" - one: "إشارة واحدة (%{count})" - two: "إشارتان (%{count})" - few: "%{count} إشارات" - many: "%{count} إشارةً" - other: "%{count} إشارة" aria_roles: header: "رأس الدردشة" composer: "أداة إنشاء الدردشة" @@ -208,10 +136,7 @@ ar: 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: "رد" @@ -227,7 +152,6 @@ ar: restore: "استعادة الرسالة المحذوفة" save: "حفظ" select: "تحديد" - silence: "كتم المستخدم" return_to_list: "العودة إلى قائمة القنوات" scroll_to_bottom: "التمرير إلى الأسفل" scroll_to_new_messages: "عرض الرسائل الجديدة" @@ -287,10 +211,8 @@ ar: about: نبذة members: الأعضاء settings: الإعدادات - channel_edit_name_modal: - title: تعديل الاسم - input_placeholder: أضِف اسمًا - description: أدخل اسمًا وصفيًا قصيرًا لقناتك + new_message_modal: + no_items: "لا توجد عناصر" channel_edit_description_modal: title: تعديل الوصف input_placeholder: أضِف وصفًا @@ -300,9 +222,6 @@ ar: prefix: "إلى:" no_results: لا توجد نتائج selected_user_title: "إلغاء تحديد %{username}" - channel_selector: - title: "الانتقال إلى القناة" - no_channels: "لا توجد قنوات تطابق بحثك" channel: no_memberships: لا يوجد أعضاء في هذه القناة no_memberships_found: لم يتم العثور على أعضاء @@ -316,26 +235,10 @@ ar: 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: "اسم القناة" @@ -348,15 +251,12 @@ ar: 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: "نص رمز برمجي" + send: "إرسال" quote: original_channel: 'تم إرساله في الأساس في %{channel}' copy_success: "تم نسخ اقتباس الدردشة إلى الحافظة" @@ -368,14 +268,13 @@ ar: channel_wide_mentions_label: "السماح بإشارات @الكل و@هنا" channel_wide_mentions_description: "السماح للمستخدمين بإرسال إشعارات إلى جميع أعضاء #%{channel} باستخدام @الكل، أو المستخدمين النشطين في الوقت الحالي فقط باستخدام @هنا" auto_join_users_label: "إضافة المستخدمين تلقائيًا" - auto_join_users_info: "التحقق كل ساعة من المستخدمين الذين كانوا نشطين في الأشهر الثلاثة الماضية، وإضافتهم إلى هذه القناة إذا كان لديهم إذن الوصول إلى الفئة %{category}." - enable_auto_join_users: "إضافة كل المستخدمين النشطين مؤخرًا بشكلٍ تلقائي" auto_join_users_warning: "سينضم كل المستخدمين الذين ليسوا أعضاءً في هذه القناة ولديهم وصول إلى فئة %{category}. هل أنت متأكد؟" desktop_notification_level: "إشعارات سطح المكتب" follow: "الانضمام" followed: "انضم" mobile_notification_level: "الإشعارات الفورية للجوَّال" mute: "كتم القناة" + threading_disabled: "متوقفة" muted_on: "تشغيل" muted_off: "إيقاف" notifications: "الإشعارات" @@ -384,7 +283,6 @@ ar: saved: "تم الحفظ" unfollow: "مغادرة" admin_title: "مسؤول" - retention_info: "سيتم الاحتفاظ بسجل الدردشة لمدة %{days} من الأيام." admin: title: "الدردشة" direct_messages: @@ -465,9 +363,6 @@ ar: few: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" many: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" other: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" - retention_reminders: - public: "يتم الاحتفاظ بسجل القناة لمدة %{days} من الأيام." - dm: "يتم الاحتفاظ بسجل الدردشة الشخصية لمدة %{days} من الأيام." flags: off_topic: "هذه الرسالة ليست ذات صلة بالمناقشة الحالية كما هو محدَّد في عنوان القناة، وربما ينبغي نقلها إلى مكانٍ آخر." inappropriate: "تحتوي هذه الرسالة على محتوى قد يعتبره الشخص العاقل مسيئًا أو مهينًا أو ينتهك إرشادات المجتمع لدينا." @@ -489,6 +384,29 @@ ar: symbols: "الرموز" search_placeholder: "البحث باسم الرمز التعبيري والاسم المستعار..." no_results: "لا توجد نتائج" + thread: + title: "العنوان" + replies: + zero: "%{count} رد" + one: "رد واحد (%{count})" + two: "ردَّان (%{count})" + few: "%{count} ردود" + many: "%{count} ردًا" + other: "%{count} رد" + settings: "الإعدادات" + last_reply: "آخر رد" + notifications: + regular: + title: "طبيعية" + tracking: + title: "التتبُّع" + participants_other_count: + zero: "+%{count}" + one: "+%{count}" + two: "+%{count}" + few: "+%{count}" + many: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "رسالة جديدة" cancel: "إلغاء" @@ -532,10 +450,6 @@ ar: review: transcript: view: "عرض نص الرسائل السابقة" - types: - reviewable_chat_message: - title: "رسالة دردشة تم الإبلاغ عنها" - flagged_by: "تم الإبلاغ بواسطة" keyboard_shortcuts_help: chat: title: "الدردشة" @@ -570,3 +484,7 @@ ar: few: "إشعارات الدردشة - %{count} إشعارات غير مقروءة" many: "إشعارات الدردشة - %{count} إشعارًا غير مقروء" other: "إشعارات الدردشة - %{count} إشعارًا غير مقروء" + styleguide: + sections: + chat: + title: الدردشة diff --git a/plugins/chat/config/locales/client.be.yml b/plugins/chat/config/locales/client.be.yml index 5403bb14a78..10d8e26b3f5 100644 --- a/plugins/chat/config/locales/client.be.yml +++ b/plugins/chat/config/locales/client.be.yml @@ -18,7 +18,10 @@ be: joined: "рэгістрацыя" email_frequency: never: "ніколі" + header_indicator_preference: + never: "ніколі" flag: "Пазначыць" + last_visit: "апошні візыт" reply: "Адказаць" edit: "рэдагаваць" bookmark_message: "закладка" @@ -54,10 +57,12 @@ be: composer: italic_text: "выдзялення тэксту" bold_text: "Моцнае вылучэнне тэксту" + send: "адправіць" notification_levels: never: "ніколі" settings: followed: "рэгістрацыя" + threading_disabled: "Адключана" notifications: "Натыфікацыі" preview: "папярэдні прагляд" save: "захаваць" @@ -83,6 +88,13 @@ be: title: "Перанос наяўнай тэмы" emoji_picker: flags: "сцягі" + thread: + title: "Загаловак" + settings: "Налады" + last_reply: "апошні адказ" + notifications: + tracking: + title: "Сачыць" draft_channel_screen: header: "новае паведамленне" cancel: "адмяніць" diff --git a/plugins/chat/config/locales/client.bg.yml b/plugins/chat/config/locales/client.bg.yml index 06a660a6f0f..e22058f1c2e 100644 --- a/plugins/chat/config/locales/client.bg.yml +++ b/plugins/chat/config/locales/client.bg.yml @@ -15,6 +15,7 @@ bg: actions: chat_channel_status_change: "Статусът на чат канала е променен" chat_channel_delete: "Чат каналът е изтрит" + chat_auto_remove_membership: "Членството е автоматично премахнато от каналите" api: scopes: descriptions: @@ -39,28 +40,42 @@ bg: move_to_channel: title: "Преместване на съобщенията в канал" instructions: - one: "Вие премествате %{count} съобщение. Изберете целеви канал. Заместващо съобщение ще бъде създадено в канала %{channelTitle} , за да покаже, че това съобщение е преместено." - other: "Вие премествате %{count} съобщения. Изберете целеви канал. Заместващо съобщение ще бъде създадено в канала %{channelTitle} , за да покаже, че тези съобщения са били преместени." + one: "Вие премествате %{count} съобщение. Изберете целеви канал. Заместващо съобщение ще бъде създадено в канала %{channelTitle} , за да покаже, че това съобщение е преместено. Обърнете внимание, че веригите за отговори няма да бъдат запазени в новия канал и съобщенията в стария канал вече няма да се показват като отговарящи на преместени съобщения." + other: "Вие премествате %{count} съобщения. Изберете целеви канал. Заместващо съобщение ще бъде създадено в канала %{channelTitle} , за да покаже, че тези съобщения са били преместени. Обърнете внимание, че веригите за отговори няма да бъдат запазени в новия канал и съобщенията в стария канал вече няма да се показват като отговарящи на преместени съобщения." confirm_move: "Преместване на съобщения" channel_settings: edit: "Редактирай" add: "Добави " join: "Влизане" leave: "Напусни" + channel_delete: + instructions: "

    Изтрива канала %{name} и историята на чата. Всички съобщения и свързани с тях данни, като реакции и качвания, ще бъдат изтрити за постоянно. Ако искате да запазите историята на канала и да го деактивирате, може вместо това да архивирате канала.

    Сигурни ли сте, че искате да изтриете окончателно канала? За да потвърдите, въведете името на канала в полето по-долу.

    " close: "Затвори" + expand: "Разгънете чекмеджето за чат" + deleted: + one: "Едно съобщение беше изтрито. [view]" + other: "%{count} съобщения бяха изтрити. [виж всички]" delete: "Изтрий" muted: "заглуши" joined: "присъединен" email_frequency: never: "Никога" + header_indicator_preference: + title: "Показване на индикатора за активност в хедъра" + all_new: "Всички нови съобщения" + dm_and_mentions: "Директни съобщения и споменавания" + never: "Никога" flag: "Сигнализиране" join: "Влизане" + last_visit: "последно посещение" + summarization: + summarize: "Обобщаване" mention_warning: dismiss: "отмени" - groups: - users_limit: - one: "%{count} потребител" - other: "%{count} потребители" + cannot_see: "%{username} няма достъп до този канал и не е уведомен." + cannot_see_multiple: + one: "%{username} и %{count} друг потребител нямат достъп до този канал и не са били уведомени." + other: "%{username} и %{count} други потребители нямат достъп до този канал и не са били уведомени." reply: "Отговорете" edit: "Редактирай" rebake_message: "Прегенерирай HTML " @@ -106,6 +121,8 @@ bg: settings: follow: "Влизане" followed: "Присъединен" + threading_enabled: "Да е включен" + threading_disabled: "Деактивирани" notifications: "Известия" save: "Запази " saved: "Запазено" @@ -134,6 +151,21 @@ bg: activities: "Дейности" flags: "Сигнали" symbols: "Символи" + thread: + title: "Заглавие" + replies: + one: "%{count} отговор" + other: "%{count} отговора" + settings: "Настройки" + last_reply: "последен отговор" + notifications: + regular: + title: "Нормален" + tracking: + title: "Следене" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Ново Съобщение" cancel: "Прекрати" @@ -143,7 +175,7 @@ bg: fields: message: label: Съобщение - review: - types: - reviewable_chat_message: - flagged_by: "Означено от" + styleguide: + sections: + chat: + title: Чат diff --git a/plugins/chat/config/locales/client.bs_BA.yml b/plugins/chat/config/locales/client.bs_BA.yml index 414d00793bc..00e4ddf4546 100644 --- a/plugins/chat/config/locales/client.bs_BA.yml +++ b/plugins/chat/config/locales/client.bs_BA.yml @@ -21,15 +21,13 @@ bs_BA: joined: "pridružio se" email_frequency: never: "Nikad" + header_indicator_preference: + never: "Nikad" flag: "Prijavi" join: "Učlani se" + last_visit: "zadnja posjeta" mention_warning: dismiss: "odbaci" - groups: - users_limit: - one: "%{count} korisnik" - few: "%{count} korisnika" - other: "%{count} korisnika" reply: "Odgovori" edit: "Edit" rebake_message: "Popravi HTML" @@ -58,6 +56,8 @@ bs_BA: about: O nama members: Članovi settings: Postavke + new_message_modal: + no_items: "Nema stavki" direct_message_creator: title: Nova poruka prefix: "Za:" @@ -69,11 +69,14 @@ bs_BA: composer: italic_text: "ukošen tekst" bold_text: "bold tekst" + send: "Pošalji" notification_levels: never: "Nikad" settings: follow: "Učlani se" followed: "Pridružio se" + threading_enabled: "Omogućen" + threading_disabled: "Neomogućen" notifications: "Obavijest" preview: "Pregled" save: "Save" @@ -105,6 +108,19 @@ bs_BA: activities: "Aktivnosti" flags: "Flags" symbols: "Simboli" + thread: + title: "Naslov" + replies: + one: "%{count} odgovor" + few: "%{count} odgovora" + other: "%{count} odgovora" + settings: "Postavke" + last_reply: "zadnji odgovor" + notifications: + regular: + title: "Normalno" + tracking: + title: "Praćenje" draft_channel_screen: header: "Nova poruka" cancel: "Odustani" @@ -116,7 +132,3 @@ bs_BA: 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 index 79adffade5a..b796b8b83f3 100644 --- a/plugins/chat/config/locales/client.ca.yml +++ b/plugins/chat/config/locales/client.ca.yml @@ -21,14 +21,13 @@ ca: joined: "registrat" email_frequency: never: "Mai" + header_indicator_preference: + never: "Mai" flag: "Bandera" join: "Registre" + last_visit: "darrera visita" mention_warning: dismiss: "descarta-ho" - groups: - users_limit: - one: "%{count} usuari" - other: "%{count} usuaris" reply: "Respon" edit: "Edita" rebake_message: "Refés HTML" @@ -58,6 +57,8 @@ ca: about: Quant a members: Membres settings: Configuració + new_message_modal: + no_items: "Sense elements" direct_message_creator: title: Missatge nou prefix: "A:" @@ -69,11 +70,14 @@ ca: composer: italic_text: "text en cursiva" bold_text: "text en negreta" + send: "Envia" notification_levels: never: "Mai" settings: follow: "Registre" followed: "Registrat" + threading_enabled: "Activat" + threading_disabled: "Desactivat" notifications: "Notificacions" preview: "Previsualitza" save: "Desa" @@ -105,6 +109,18 @@ ca: activities: "Activitats" flags: "Banderes" symbols: "Símbols" + thread: + title: "Títol" + replies: + one: "%{count} resposta" + other: "%{count} respostes" + settings: "Configuració" + last_reply: "darrera resposta" + notifications: + regular: + title: "Normal" + tracking: + title: "Seguint" draft_channel_screen: header: "Missatge nou" cancel: "Cancel·la" @@ -116,7 +132,3 @@ ca: 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 index f9b87ae1aa6..bcd85d1d683 100644 --- a/plugins/chat/config/locales/client.cs.yml +++ b/plugins/chat/config/locales/client.cs.yml @@ -20,16 +20,13 @@ cs: joined: "účet vytvořen" email_frequency: never: "Nikdy" + header_indicator_preference: + never: "Nikdy" flag: "Nahlášení" join: "Přidat se ke skupině" + last_visit: "poslední návštěva" mention_warning: dismiss: "označit jako přečtené" - groups: - users_limit: - one: "%{count} uživatel" - few: "%{count} uživatelů" - many: "%{count} uživatelů" - other: "%{count} uživatelů" reply: "Odpověď" edit: "Upravit" rebake_message: "Obnovit HTML" @@ -70,11 +67,14 @@ cs: composer: italic_text: "text kurzívou" bold_text: "tučný text" + send: "Poslat" notification_levels: never: "Nikdy" settings: follow: "Přidat se ke skupině" followed: "Účet vytvořen" + threading_enabled: "Zapnutý" + threading_disabled: "Vypnuto" notifications: "Upozornění" preview: "Náhled" save: "Uložit" @@ -102,6 +102,20 @@ cs: emoji_picker: objects: "Objekty" flags: "Nahlášení" + thread: + title: "Nadpis" + replies: + one: "%{count} odpověď" + few: "%{count} odpovědí" + many: "%{count} odpovědí" + other: "%{count} odpovědí" + settings: "Nastavení" + last_reply: "poslední odpověď" + notifications: + regular: + title: "Normální" + tracking: + title: "Sledované" draft_channel_screen: header: "Nová zpráva" cancel: "Zrušit" @@ -113,7 +127,3 @@ cs: 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 index e5f94db2f33..986da6cf5e3 100644 --- a/plugins/chat/config/locales/client.da.yml +++ b/plugins/chat/config/locales/client.da.yml @@ -38,9 +38,6 @@ da: browse_all_channels: "Gennemse alle kanaler" move_to_channel: title: "Flyt beskeder til kanal" - instructions: - one: "Du flytter %{count} besked. Vælg en destinationskanal. En pladsholdermeddelelse vil blive oprettet i %{channelTitle}-kanalen for at angive, at denne besked er blevet flyttet." - other: "Du flytter %{count} beskeder. Vælg en destinationskanal. En pladsholdermeddelelse vil blive oprettet i %{channelTitle}-kanalen for at angive, at disse beskeder er blevet flyttet." confirm_move: "Flyt beskeder" channel_settings: title: "Kanal indstillinger" @@ -62,7 +59,6 @@ da: title: "Luk Kanal" channel_delete: title: "Slet Kanal" - instructions: "

    Sletter %{name}-kanalen og chathistorikken. Alle beskeder og relaterede data, såsom reaktioner og uploads, slettes permanent. Hvis du vil bevare kanalhistorikken og nedlægge den, kan du i stedet for arkivere kanalen.

    Er du sikker på, at du vil slette kanalen? Skriv navnet på kanalen i boksen nedenfor for at bekræfte.

    " confirm_channel_name: "Indtast kanalnavn" channels_list_popup: browse: "Gennemse kanaler" @@ -75,6 +71,8 @@ da: joined: "tilmeldt" email_frequency: never: "Aldrig" + header_indicator_preference: + never: "Aldrig" enable: "Aktiver chat" flag: "Rapportér" invalid_access: "Du har ikke adgang til at se denne chatkanal" @@ -82,13 +80,9 @@ da: in_reply_to: "Som svar til" heading: "Chat" join: "Tilslut" - new_messages: "nye beskeder" + last_visit: "sidste besøg" mention_warning: dismiss: "afvis" - groups: - users_limit: - one: "%{count} bruger" - other: "%{count} brugere" reply: "Svar" edit: "Rediger" rebake_message: "Gendan HTML" @@ -134,12 +128,11 @@ da: about: Om members: Brugere settings: Indstillinger + new_message_modal: + no_items: "Ingen punkter" 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" @@ -156,14 +149,11 @@ da: type: "Chatbesked" 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" + send: "Send" quote: copy_success: "Chat-citat kopieret til udklipsholderen" notification_levels: @@ -171,6 +161,8 @@ da: settings: follow: "Tilslut" followed: "Tilmeldt" + threading_enabled: "Aktiveret" + threading_disabled: "Deaktiveret" notifications: "Notifikationer" preview: "Forhåndsvisning" save: "Gem" @@ -204,14 +196,26 @@ da: 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." emoji_picker: objects: "Objekter" activities: "Aktiviteter" flags: "Flag" symbols: "Symboler" + thread: + title: "Titel" + replies: + one: "%{count} svar" + other: "%{count} svar" + settings: "Indstillinger" + last_reply: "seneste svar" + notifications: + regular: + title: "Normal" + tracking: + title: "Følger" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Ny Besked" cancel: "Annuller" @@ -247,10 +251,6 @@ da: sender: label: Afsender description: Standard er system - review: - types: - reviewable_chat_message: - flagged_by: "Markeret af" keyboard_shortcuts_help: chat: title: "Chat" diff --git a/plugins/chat/config/locales/client.de.yml b/plugins/chat/config/locales/client.de.yml index 9bd5943cce4..21eea82c65f 100644 --- a/plugins/chat/config/locales/client.de.yml +++ b/plugins/chat/config/locales/client.de.yml @@ -15,6 +15,7 @@ de: actions: chat_channel_status_change: "Chat-Kanal-Status geändert" chat_channel_delete: "Chat-Kanal gelöscht" + chat_auto_remove_membership: "Mitgliedschaften werden automatisch aus den Kanälen entfernt" api: scopes: descriptions: @@ -39,8 +40,8 @@ de: 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." + 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. Beachte, dass Antwortketten im neuen Kanal nicht erhalten bleiben und dass Nachrichten im alten Kanal nicht mehr als Antwort auf verschobene Nachrichten angezeigt werden." + 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. Beachte, dass die Antwortketten im neuen Kanal nicht erhalten bleiben und dass die Nachrichten im alten Kanal nicht mehr als Antwort auf die verschobenen Nachrichten angezeigt werden." confirm_move: "Nachrichten verschieben" channel_settings: title: "Kanaleinstellungen" @@ -71,7 +72,7 @@ de: 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.

    " + instructions: "

    Löscht den Kanal %{name} und den Chatverlauf. Alle Nachrichten und zugehörigen Daten wie Reaktionen und Uploads werden dauerhaft gelöscht. Wenn du den Kanalverlauf aufbewahren und stilllegen möchtest, solltest du den Kanal stattdessen archivieren.

    Bist du sicher, dass du den Kanal dauerhaft löschen möchtest? 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." @@ -81,8 +82,11 @@ de: click_to_join: "Klicke hier, um die verfügbaren Kanäle zu sehen." close: "Schließen" collapse: "Chat-Bereich ausblenden" + expand: "Chat-Bereich erweitern" confirm_flag: "Bist du sicher, dass du die Nachricht von %{username} markieren möchtest?" - deleted: "Eine Nachricht wurde gelöscht. [Anzeigen]" + deleted: + one: "Eine Nachricht wurde gelöscht. [Anzeigen]" + other: "%{count} Nachrichten wurden gelöscht. [Alle anzeigen]" hidden: "Eine Nachricht wurde ausgeblendet. [Anzeigen]" delete: "Löschen" edited: "bearbeitet" @@ -97,6 +101,11 @@ de: never: "Niemals" title: "E-Mail-Benachrichtigungen" when_away: "Nur bei Abwesenheit" + header_indicator_preference: + title: "Aktivitätsindikator in der Kopfzeile anzeigen" + all_new: "bei allen neuen Nachrichten" + dm_and_mentions: "bei Direktnachrichten und Erwähnungen" + never: "niemals" enable: "Chat aktivieren" flag: "Melden" emoji: "Emoji einfügen" @@ -106,53 +115,111 @@ de: in_reply_to: "Als Antwort auf" heading: "Chat" join: "Beitreten" - new_messages: "neue Nachrichten" + last_visit: "letzter Besuch" + summarization: + title: "Nachrichten zusammenfassen" + description: "Wähle unten eine Option aus, um die im gewünschten Zeitraum gesendete Konversation zusammenzufassen." + summarize: "Zusammenfassen" + since: + one: "Letzte Stunde" + other: "Letzte %{count} Stunden" mention_warning: dismiss: "verwerfen" - cannot_see: - one: "%{username} kann nicht auf diesen Kanal zugreifen und wurde nicht benachrichtigt." - other: "%{username} und %{others} können nicht auf diesen Kanal zugreifen und wurden nicht benachrichtigt." + cannot_see: "%{username} kann nicht auf diesen Kanal zugreifen und wurde nicht benachrichtigt." + cannot_see_multiple: + one: "%{username} und %{count} anderer Benutzer können nicht auf diesen Kanal zugreifen und wurden nicht benachrichtigt." + other: "%{username} und %{count} andere Benutzer können nicht auf diesen Kanal zugreifen und wurden nicht benachrichtigt." invitations_sent: one: "Einladung gesendet" other: "Einladungen gesendet" invite: "Zum Kanal einladen" - without_membership: - one: "%{username} ist diesem Kanal nicht beigetreten." - other: "%{username} und %{others} sind diesem Kanal nicht beigetreten." - group_mentions_disabled: - one: "%{group_name} erlaubt keine Erwähnungen" - other: "%{group_name} und %{others} erlauben keine Erwähnungen" - too_many_members: - one: "%{group_name} hat zu viele Mitglieder. Niemand wurde benachrichtigt" - other: "%{group_name} und %{others} haben zu viele Mitglieder. Niemand wurde benachrichtigt" - warning_multiple: - one: "%{count} anderer" - other: "%{count} andere" + without_membership: "%{username} ist diesem Kanal nicht beigetreten." + without_membership_multiple: + one: "%{username} und %{count} anderer Benutzer sind diesem Kanal nicht beigetreten." + other: "%{username} und %{count} andere Benutzer sind diesem Kanal nicht beigetreten." + group_mentions_disabled: "%{group_name} erlaubt keine Erwähnungen." + group_mentions_disabled_multiple: + one: "%{group_name} und %{count} andere Gruppe erlauben keine Erwähnungen." + other: "%{group_name} und %{count} andere Gruppen erlauben keine Erwähnungen." + too_many_members: "%{group_name} hat zu viele Mitglieder. Niemand wurde benachrichtigt." + too_many_members_multiple: + one: "%{group_name} und %{count} andere Gruppe haben zu viele Mitglieder. Es wurde niemand benachrichtigt." + other: "%{group_name} und %{count} andere Gruppen haben zu viele Mitglieder. Es wurde niemand benachrichtigt." groups: header: some: "Einige Benutzer werden nicht benachrichtigt" all: "Niemand wird benachrichtigt" - unreachable: - one: "@%{group} erlaubt keine Erwähnungen" - other: "@%{group} und @%{group_2} erlauben keine Erwähnungen" - unreachable_multiple: "@%{group} und %{count} andere erlauben keine Erwähnungen" - too_many_members: - one: "Die Erwähnung von @%{group} übersteigt das %{notification_limit} von %{limit}" - other: "Die Erwähnung von @%{group} oder @%{group_2} übersteigt das %{notification_limit} von %{limit}" - too_many_members_multiple: "Diese %{count} Gruppen übersteigen das %{notification_limit} von %{limit}" - users_limit: - one: "%{count} Benutzer" - other: "%{count} Benutzer" - notification_limit: "Benachrichtigungslimit" - too_many_mentions: "Diese Nachricht überschreitet das %{notification_limit} von %{limit}" - mentions_limit: - one: "%{count} Erwähnung" - other: "%{count} Erwähnungen" + unreachable_1: "@%{group} erlaubt keine Erwähnungen." + unreachable_2: "@%{group1} und @%{group2} erlauben keine Erwähnungen." + unreachable_multiple: + one: "@%{group} und %{count} andere Gruppe erlauben keine Erwähnungen." + other: "@%{group} und %{count} andere Gruppen erlauben keine Erwähnungen." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Erwähnung von @{group1} überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung von @{group1} überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + false { + { notificationLimit, plural, + one {Erwähnung von @{group1} überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung von @{group1} überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Erwähnung von @{group1} und @{group2} überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung von @{group1} und @{group2} überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + false { + { notificationLimit, plural, + one {Erwähnung von @{group1} und @{group2} überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung von @{group1} und @{group2} überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Erwähnung dieser {groupCount} Gruppen überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung dieser {groupCount} Gruppen überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + false { + { notificationLimit, plural, + one {Erwähnung dieser {groupCount} Gruppen überschreitet das Benachrichtigungslimit von # Benutzer.} + other {Erwähnung dieser {groupCount} Gruppen überschreitet das Benachrichtigungslimit von # Benutzern.} + } + } + other {} + } + } + } + too_many_mentions: + one: "Diese Nachricht überschreitet das Benachrichtigungslimit von %{count} Erwähnung." + other: "Diese Nachricht überschreitet das Benachrichtigungslimit von %{count} Erwähnungen." + too_many_mentions_admin: + one: 'Diese Nachricht überschreitet das Benachrichtigungslimit von %{count} Erwähnung.' + other: 'Diese Nachricht überschreitet das Benachrichtigungslimit von %{count} Erwähnungen.' aria_roles: header: "Chat-Kopfzeile" composer: "Chat-Composer" channels_list: "Liste der Chat-Kanäle" no_public_channels: "Du bist keinem Kanal beigetreten." + kicked_from_channel: "Du kannst nicht mehr auf diesen Kanal zugreifen." only_chat_push_notifications: title: "Nur Chat-Push-Benachrichtigungen senden" description: "Alle Nicht-Chat-Push-Benachrichtigungen blockieren und nicht senden" @@ -164,10 +231,16 @@ de: 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_channel: "Chat in %{channelName}" + placeholder_thread: "Chat im Thread" + placeholder_users: "Chat mit %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Der Kanal ist archiviert, du kannst im Moment keine neuen Nachrichten senden." + closed: "Der Kanal ist geschlossen, du kannst im Moment keine neuen Nachrichten senden." + read_only: "Der Kanal ist schreibgeschützt, du kannst im Moment keine neuen Nachrichten senden." placeholder_silenced: "Du kannst derzeit keine Nachrichten senden." - placeholder_start_conversation: Beginne eine Unterhaltung mit %{usernames} + placeholder_start_conversation: "Unterhaltung beginnen mit …" + placeholder_start_conversation_users: "Unterhaltung mit %{commaSeparatedUsernames} beginnen" remove_upload: "Datei löschen" react: "Mit Emoji reagieren" reply: "Antworten" @@ -183,8 +256,11 @@ de: restore: "Gelöschte Nachricht wiederherstellen" save: "Speichern" select: "Auswählen" - silence: "Benutzer stummschalten" return_to_list: "Zurück zur Kanalliste" + return_to_threads_list: "Zurück zu den laufenden Diskussionen" + unread_threads_count: + one: "Du hast %{count} ungelesene Diskussion" + other: "Du hast %{count} ungelesene Diskussionen" scroll_to_bottom: "Nach unten scrollen" scroll_to_new_messages: "Neue Nachrichten anzeigen" sound: @@ -196,6 +272,8 @@ de: title: "Chat" title_capitalized: "Chat" upload: "Datei anhängen" + upload_to_channel: "In %{title} hochladen" + upload_to_thread: "In Thread hochladen" uploaded_files: one: "%{count} Datei" other: "%{count} Dateien" @@ -239,10 +317,23 @@ de: about: Über members: Mitglieder settings: Einstellungen - channel_edit_name_modal: - title: Namen bearbeiten + new_message_modal: + title: Nachricht senden + add_user_long: Umschalt + Klick oder Umschalt + Eingabe@%{username} hinzufügen + add_user_short: Benutzer hinzufügen + open_channel: Kanal öffnen + default_search_placeholder: "#ein-kanal, @jemand oder sonstiges" + default_channel_search_placeholder: "#ein-kanal" + default_user_search_placeholder: "@jemand" + user_search_placeholder: "... weitere Benutzer hinzufügen" + disabled_user: "hat den Chat deaktiviert" + no_items: "Keine Elemente" + channel_edit_name_slug_modal: + title: Kanal bearbeiten input_placeholder: Einen Namen hinzufügen - description: Gib deinem Kanal einen kurzen aussagekräftigen Namen + slug_description: In der URL wird anstelle des Kanalnamens ein Kanal-Kürzel verwendet + name: Kanalname + slug: Kanal-Kürzel (optional) channel_edit_description_modal: title: Beschreibung bearbeiten input_placeholder: Beschreibung hinzufügen @@ -252,9 +343,6 @@ de: 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 @@ -262,23 +350,44 @@ de: one: "%{count} Mitglied" other: "%{count} Mitglieder" create_channel: + threading: + label: "Threading aktivieren" 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? + warning_1_group: + one: "Automatisch %{count} Benutzer aus %{group} hinzufügen?" + other: "Automatisch %{count} Benutzer aus %{group} hinzufügen?" + warning_2_groups: + one: "Automatisch %{count} Benutzer aus %{group1} und %{group2} hinzufügen?" + other: "Automatisch %{count} Benutzer aus %{group1} und %{group2} hinzufügen?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {{userCount} Benutzer aus {groupName} und {groupCount} anderer Gruppe automatisch hinzufügen?} + other {{userCount} Benutzer aus {groupName} und {groupCount} anderer Gruppe automatisch hinzufügen?} + } + } + other { + { userCount, plural, + one {{userCount} Benutzer aus {groupName} und {groupCount} anderen Gruppen automatisch hinzufügen?} + other {{userCount} Benutzer aus {groupName} und {groupCount} anderen Gruppen automatisch 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 + hint_1_group: 'Benutzer in %{group} haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal' + hint_2_groups: 'Benutzer in %{group1} und %{group2} haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal' + hint_multiple_groups: + one: 'Benutzer in %{group} und %{count} anderen Gruppe haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal' + other: 'Benutzer in %{group} und %{count} anderen Gruppen haben gemäß den Sicherheitseinstellungen Zugriff auf diesen Kanal' create: "Kanal erstellen" description: "Beschreibung (optional)" name: "Kanalname" + slug: "Kanal-Kürzel (optional)" title: "Neuer Kanal" type: "Typ" types: @@ -288,15 +397,22 @@ de: 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}:" + you_and_single_user: "Du und %{username} haben mit :%{emoji}: reagiert" + you_and_multiple_users: "Du, %{commaSeparatedUsernames} und %{username} haben mit :%{emoji}: reagiert" + you_multiple_users_and_more: + one: "Du, %{commaSeparatedUsernames} und %{count} andere Person haben mit :%{emoji}: reagiert" + other: "Du, %{commaSeparatedUsernames} und %{count} andere haben mit :%{emoji}: reagiert" + single_user: "%{username} hat mit :%{emoji}: reagiert" + multiple_users: "%{commaSeparatedUsernames} und %{username} haben mit :%{emoji}: reagiert" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} und %{count} andere Person haben mit :%{emoji}: reagiert" + other: "%{commaSeparatedUsernames} und %{count} andere haben mit :%{emoji}: reagiert" composer: toggle_toolbar: "Symbolleiste umschalten" italic_text: "hervorgehobener Text" bold_text: "fett gedruckter Text" code_text: "Code-Text" + send: "Senden" quote: original_channel: 'Ursprünglich gesendet in %{channel}' copy_success: "Chat-Zitat in die Zwischenablage kopiert" @@ -307,15 +423,19 @@ de: settings: channel_wide_mentions_label: "@all- und @here-Erwähnungen zulassen" channel_wide_mentions_description: "Erlaube Benutzern, alle Mitglieder von #%{channel} mit @all zu benachrichtigen oder nur diejenigen, die gerade aktiv sind, mit @here" + channel_threading_label: "Threading" + channel_threading_description: "Wenn Threading aktiviert ist, wird durch Antworten auf eine Chat-Nachricht eine separate Unterhaltung erstellt, die neben dem Hauptkanal existiert." auto_join_users_label: "Benutzer automatisch hinzufügen" - auto_join_users_info: "Stündlich prüfen, welche Benutzer in den letzten 3 Monaten aktiv waren, und zu diesem Kanal hinzufügen, falls sie Zugriff auf die Kategorie %{category} haben." - enable_auto_join_users: "Automatisch alle kürzlich aktiven Benutzer hinzufügen" + auto_join_users_info: "Stündlich prüfen, welche Benutzer in den letzten 3 Monaten aktiv waren. Diese Benutzer zu diesem Kanal hinzufügen, wenn sie Zugang zur Kategorie %{category} haben." + auto_join_users_info_no_category: "Stündlich prüfen, welche Benutzer in den letzten 3 Monaten aktiv waren. Diese Benutzer zu diesem Kanal hinzufügen, wenn sie Zugang zur ausgewählten Kategorie haben." 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" + threading_enabled: "Aktiviert" + threading_disabled: "Deaktiviert" muted_on: "An" muted_off: "Aus" notifications: "Benachrichtigungen" @@ -324,9 +444,13 @@ de: saved: "Gespeichert" unfollow: "Verlassen" admin_title: "Administrator" - retention_info: "Der Chat-Verlauf wird für %{days} Tage gespeichert." admin: title: "Chat" + export_messages: + title: "Chat-Nachrichten exportieren" + description: "Der Export ist derzeit auf die letzten 10.000 Nachrichten der letzten 6 Monate beschränkt." + create_export: "Export erstellen" + export_has_started: "Der Export hat begonnen. Du erhältst eine PN, wenn er abgeschlossen ist." direct_messages: title: "Persönlicher Chat" new: "Persönlichen Chat erstellen" @@ -390,8 +514,14 @@ de: 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." + public_none: "Der Kanalverlauf wird auf unbestimmte Zeit gespeichert." + public: + one: "Der Kanalverlauf wird für %{count} Tag gespeichert." + other: "Der Kanalverlauf wird für %{count} Tage gespeichert." + dm_none: "Der persönliche Chatverlauf wird auf unbestimmte Zeit gespeichert." + dm: + one: "Der persönliche Chatverlauf wird für %{count} Tag gespeichert." + other: "Der persönliche Chatverlauf wird für %{count} Tage gespeichert." 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." @@ -413,6 +543,33 @@ de: symbols: "Symbole" search_placeholder: "Nach Emoji-Namen und -Alias suchen …" no_results: "Keine Ergebnisse" + thread: + title: "Titel" + view_thread: Thread aufrufen + default_title: "Thread" + replies: + one: "%{count} Antwort" + other: "%{count} Antworten" + label: Thread + close: "Thread schließen" + original_message: + started_by: "Gestartet von" + settings: "Einstellungen" + last_reply: "letzte Antw." + notifications: + regular: + title: "Normal" + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ in diesem Thread erwähnt." + tracking: + title: "Verfolgen" + description: "Die Anzahl der neuen Antworten auf diesen Thread wird in der Thread-Liste und im Kanal angezeigt. Du wirst benachrichtigt, wenn jemand deinen Namen mit @ in diesem Thread erwähnt." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Thread öffnen" + list: "Laufende Diskussionen" + none: "Du nimmst an keinem Thread in diesem Kanal teil." draft_channel_screen: header: "Neue Nachricht" cancel: "Abbrechen" @@ -457,9 +614,8 @@ de: transcript: view: "Transkript früherer Nachrichten anzeigen" types: - reviewable_chat_message: - title: "Chat-Nachricht markiert" - flagged_by: "Markiert von" + chat_reviewable_message: + title: "Markierte Chat-Nachricht" keyboard_shortcuts_help: chat: title: "Chat" @@ -472,6 +628,7 @@ de: composer_code: "%{shortcut} Code (nur Composer)" drawer_open: "%{shortcut} Chat-Bereich öffnen" drawer_close: "%{shortcut} Chat-Bereich schließen" + mark_all_channels_read: "%{shortcut} Alle Kanäle als gelesen markieren" topic_statuses: chat: help: "Der Chat ist für dieses Thema aktiviert" @@ -490,3 +647,7 @@ de: chat_notifications_with_unread: one: "Chat-Benachrichtigungen – %{count} ungelesene Benachrichtigung" other: "Chat-Benachrichtigungen – %{count} ungelesene Benachrichtigungen" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.el.yml b/plugins/chat/config/locales/client.el.yml index f35be627822..c02979e09c0 100644 --- a/plugins/chat/config/locales/client.el.yml +++ b/plugins/chat/config/locales/client.el.yml @@ -21,14 +21,13 @@ el: joined: "έγινε μέλος" email_frequency: never: "Ποτέ" + header_indicator_preference: + never: "Ποτέ" flag: "Επισήμανση" join: "Γίνετε μέλος" + last_visit: "τελευταία επίσκεψη" mention_warning: dismiss: "απόρριψη" - groups: - users_limit: - one: "%{count} χρήστης" - other: "%{count} χρήστες" reply: "Απάντηση" edit: "Επεξεργασία" rebake_message: "Ανανέωση HTML" @@ -57,6 +56,8 @@ el: about: Σχετικά members: Μέλη settings: Ρυθμίσεις + new_message_modal: + no_items: "Δεν υπάρχουν στοιχεία" direct_message_creator: title: Νέο Μήνυμα prefix: "Προς:" @@ -68,11 +69,13 @@ el: composer: italic_text: "κείμενο σε έμφαση" bold_text: "έντονη γραφή" + send: "Αποστολή" notification_levels: never: "Ποτέ" settings: follow: "Γίνετε μέλος" followed: "Έγινε μέλος" + threading_disabled: "Απενεργοποιημένο" notifications: "Ειδοποιήσεις" preview: "Προεπισκόπηση" save: "Αποθήκευση" @@ -104,6 +107,18 @@ el: activities: "Δραστηριότητες" flags: "Σημάνσεις" symbols: "Σύμβολα" + thread: + title: "Τίτλος" + replies: + one: "%{count} απάντηση" + other: "%{count} απαντήσεις" + settings: "Ρυθμίσεις" + last_reply: "τελευταία απάντηση" + notifications: + regular: + title: "Φυσιολογικό" + tracking: + title: "Παρακολουθείται" draft_channel_screen: header: "Νέο Μήνυμα" cancel: "Ακύρωση" @@ -115,7 +130,3 @@ el: 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 index 58e3b4d7aee..6c1d82dc2c0 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -9,6 +9,7 @@ en: actions: chat_channel_status_change: "Chat channel status changed" chat_channel_delete: "Chat channel deleted" + chat_auto_remove_membership: "Memberships automatically removed from channels" api: scopes: descriptions: @@ -34,8 +35,8 @@ en: 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." + 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. Note that reply chains will not be preserved in the new channel, and messages in the old channel will no longer show as replying to any moved messages." + 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. Note that reply chains will not be preserved in the new channel, and messages in the old channel will no longer show as replying to any moved messages." confirm_move: "Move Messages" channel_settings: title: "Channel settings" @@ -66,7 +67,7 @@ en: 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.

    + 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 decommission 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" @@ -78,8 +79,12 @@ en: click_to_join: "Click here to view available channels." close: "Close" collapse: "Collapse Chat Drawer" + expand: "Expand Chat Drawer" confirm_flag: "Are you sure you want to flag %{username}'s message?" - deleted: "A message was deleted. [view]" + deleted: + one: "A message was deleted. [view]" + other: "%{count} messages were deleted. [view all]" + hidden: "A message was hidden. [view]" delete: "Delete" edited: "edited" @@ -94,6 +99,11 @@ en: never: "Never" title: "Email Notifications" when_away: "Only when away" + header_indicator_preference: + title: "Show activity indicator in header" + all_new: "All New Messages" + dm_and_mentions: "Direct Messages and Mentions" + never: "Never" enable: "Enable chat" flag: "Flag" emoji: "Insert emoji" @@ -103,55 +113,114 @@ en: in_reply_to: "In reply to" heading: "Chat" join: "Join" - new_messages: "new messages" + last_visit: "last visit" + + summarization: + title: "Summarize messages" + description: "Select an option below to summarize the conversation sent during the desired timeframe." + summarize: "Summarize" + since: + one: "Last hour" + other: "Last %{count} hours" mention_warning: dismiss: "dismiss" - cannot_see: - one: "%{username} cannot access this channel and was not notified." - other: "%{username} and %{others} cannot access this channel and were not notified." + cannot_see: "%{username} can't access this channel and was not notified." + cannot_see_multiple: + one: "%{username} and %{count} other user cannot access this channel and were not notified." + other: "%{username} and %{count} other users cannot access this channel and were not notified." invitations_sent: one: "Invitation sent" other: "Invitations sent" invite: "Invite to channel" - without_membership: - one: "%{username} has not joined this channel." - other: "%{username} and %{others} have not joined this channel." - group_mentions_disabled: - one: "%{group_name} doesn't allow mentions" - other: "%{group_name} and %{others} doesn't allow mentions" - too_many_members: - one: "%{group_name} has too many members. No one was notified" - other: "%{group_name} and %{others} have too many members. No one was notified" - warning_multiple: - one: "%{count} other" - other: "%{count} others" - + without_membership: "%{username} has not joined this channel." + without_membership_multiple: + one: "%{username} and %{count} other user have not joined this channel." + other: "%{username} and %{count} other users have not joined this channel." + group_mentions_disabled: "%{group_name} doesn't allow mentions." + group_mentions_disabled_multiple: + one: "%{group_name} and %{count} other group don't allow mentions." + other: "%{group_name} and %{count} other groups don't allow mentions." + too_many_members: "%{group_name} has too many members. No one was notified." + too_many_members_multiple: + one: "%{group_name} and %{count} other group have too many members. No one was notified." + other: "%{group_name} and %{count} other groups have too many members. No one was notified." groups: header: some: "Some users won't be notified" all: "Nobody will be notified" - unreachable: - one: "@%{group} doesn't allow mentions" - other: "@%{group} and @%{group_2} doesn't allow mentions" - unreachable_multiple: "@%{group} and %{count} others doesn't allow mentions" - too_many_members: - one: "Mentioning @%{group} exceeds the %{notification_limit} of %{limit}" - other: "Mentioning both @%{group} or @%{group_2} exceeds the %{notification_limit} of %{limit}" - too_many_members_multiple: "These %{count} groups exceed the %{notification_limit} of %{limit}" - users_limit: - one: "%{count} user" - other: "%{count} users" - notification_limit: "notification limit" - too_many_mentions: "This message exceeds the %{notification_limit} of %{limit}" - mentions_limit: - one: "%{count} mention" - other: "%{count} mentions" + unreachable_1: "@%{group} doesn't allow mentions." + unreachable_2: "@%{group1} and @%{group2} don't allow mentions." + unreachable_multiple: + one: "@%{group} and %{count} other group don't allow mentions." + other: "@%{group} and %{count} other groups don't allow mentions." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mentioning @{group1} exceeds the notification limit of # user.} + other {Mentioning @{group1} exceeds the notification limit of # users.} + } + } + false { + { notificationLimit, plural, + one {Mentioning @{group1} exceeds the notification limit of # user.} + other {Mentioning @{group1} exceeds the notification limit of # users.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mentioning @{group1} and @{group2} exceeds the notification limit of # user.} + other {Mentioning @{group1} and @{group2} exceeds the notification limit of # users.} + } + } + false { + { notificationLimit, plural, + one {Mentioning @{group1} and @{group2} exceeds the notification limit of # user.} + other {Mentioning @{group1} and @{group2} exceeds the notification limit of # users.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mentioning these {groupCount} groups exceeds the notification limit of # user.} + other {Mentioning these {groupCount} groups exceeds the notification limit of # users.} + } + } + false { + { notificationLimit, plural, + one {Mentioning these {groupCount} groups exceeds the notification limit of # user.} + other {Mentioning these {groupCount} groups exceeds the notification limit of # users.} + } + } + other {} + } + } + } + too_many_mentions: + one: "This message exceeds the notification limit of %{count} mention." + other: "This message exceeds the notification limit of %{count} mentions." + too_many_mentions_admin: + one: 'This message exceeds the notification limit of %{count} mention.' + other: 'This message exceeds the notification limit of %{count} mentions.' + aria_roles: header: "Chat header" composer: "Chat composer" channels_list: "Chat channels list" no_public_channels: "You have not joined any channels." + kicked_from_channel: "You can no longer access this channel." only_chat_push_notifications: title: "Only send chat push notifications" description: "Block all non-chat push notifications from being sent" @@ -163,10 +232,16 @@ en: 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_channel: "Chat in %{channelName}" + placeholder_thread: "Chat in thread" + placeholder_users: "Chat with %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Channel is archived, you cannot send new messages right now." + closed: "Channel is closed, you cannot send new messages right now." + read_only: "Channel is read only, you cannot send new messages right now." placeholder_silenced: "You cannot send messages at this time." - placeholder_start_conversation: Start a conversation with %{usernames} + placeholder_start_conversation: "Start a conversation with ..." + placeholder_start_conversation_users: "Start a conversation with %{commaSeparatedUsernames}" remove_upload: "Remove file" react: "React with emoji" reply: "Reply" @@ -182,8 +257,11 @@ en: restore: "Restore deleted message" save: "Save" select: "Select" - silence: "Silence user" return_to_list: "Return to channels list" + return_to_threads_list: "Return to ongoing discussions" + unread_threads_count: + one: "You have %{count} unread discussion" + other: "You have %{count} unread discussions" scroll_to_bottom: "Scroll to bottom" scroll_to_new_messages: "See new messages" sound: @@ -195,6 +273,8 @@ en: title: "chat" title_capitalized: "Chat" upload: "Attach a file" + upload_to_channel: "Upload to %{title}" + upload_to_thread: "Upload to thread" uploaded_files: one: "%{count} file" other: "%{count} files" @@ -205,7 +285,7 @@ en: read_only: "Read Only" archived_header: "Channel is archived" archived: "Archived" - archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived. the destination topic. Press retry to attempt to complete the archive." + archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived. The destination topic was created. Press retry to attempt to complete the archive." archive_failed_no_topic: "Archive channel failed. %{completed}/%{total} messages have been archived, the destination topic was not created. Press retry to attempt to complete the archive." archive_completed: "See the archive topic" closed_header: "Channel is closed" @@ -244,10 +324,24 @@ en: members: Members settings: Settings - channel_edit_name_modal: - title: Edit name + new_message_modal: + title: Send message + add_user_long: shift + click or shift + enterAdd @%{username} + add_user_short: Add user + open_channel: Open channel + default_search_placeholder: "#a-channel, @somebody or anything" + default_channel_search_placeholder: "#a-channel" + default_user_search_placeholder: "@somebody" + user_search_placeholder: "...add more users" + disabled_user: "has disabled chat" + no_items: "No items" + + channel_edit_name_slug_modal: + title: Edit channel input_placeholder: Add a name - description: Give a short descriptive name to your channel + slug_description: A channel slug is used in the URL instead of the channel name + name: Channel name + slug: Channel slug (optional) channel_edit_description_modal: title: Edit description @@ -260,10 +354,6 @@ en: 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 @@ -272,23 +362,44 @@ en: other: "%{count} members" create_channel: + threading: + label: "Enable threading" 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? + warning_1_group: + one: "Automatically add %{count} user from %{group}?" + other: "Automatically add %{count} users from %{group}?" + warning_2_groups: + one: "Automatically add %{count} user from %{group1} and %{group2}?" + other: "Automatically add %{count} users from %{group1} and %{group2}?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {Automatically add {userCount} user from {groupName} and {groupCount} other group?} + other {Automatically add {userCount} users from {groupName} and {groupCount} other group?} + } + } + other { + { userCount, plural, + one {Automatically add {userCount} user from {groupName} and {groupCount} other groups?} + other {Automatically add {userCount} users from {groupName} and {groupCount} other groups?} + } + } + } 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 + hint_1_group: 'Users in %{group} will have access to this channel per the security settings' + hint_2_groups: 'Users in %{group1} and %{group2} will have access to this channel per the security settings' + hint_multiple_groups: + one: 'Users in %{group} and %{count} other group will have access to this channel per the security settings' + other: 'Users in %{group} and %{count} other groups will have access to this channel per the security settings' create: "Create channel" description: "Description (optional)" name: "Channel name" + slug: "Channel slug (optional)" title: "New channel" type: "Type" types: @@ -300,16 +411,23 @@ en: 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}:" + you_and_single_user: "You and %{username} reacted with :%{emoji}:" + you_and_multiple_users: "You, %{commaSeparatedUsernames} and %{username} reacted with :%{emoji}:" + you_multiple_users_and_more: + one: "You, %{commaSeparatedUsernames} and %{count} other reacted with :%{emoji}:" + other: "You, %{commaSeparatedUsernames} and %{count} others reacted with :%{emoji}:" + single_user: "%{username} reacted with :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} and %{username} reacted with :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} and %{count} other reacted with :%{emoji}:" + other: "%{commaSeparatedUsernames} and %{count} others reacted with :%{emoji}:" composer: toggle_toolbar: "Toggle toolbar" italic_text: "emphasized text" bold_text: "strong text" code_text: "code text" + send: "Send" quote: original_channel: 'Originally sent in %{channel}' @@ -323,15 +441,19 @@ en: settings: channel_wide_mentions_label: "Allow @all and @here mentions" channel_wide_mentions_description: "Allow users to notify all members of #%{channel} with @all or only those who are active in the moment with @here" + channel_threading_label: "Threading" + channel_threading_description: "When threading is enabled, replies to a chat message will create a separate conversation, which will exist alongside the main channel." auto_join_users_label: "Automatically add users" - auto_join_users_info: "Check hourly which users have been active in the last 3 months and, if they have access to the %{category} category, add them to this channel." - enable_auto_join_users: "Automatically add all recently active users" + auto_join_users_info: "Check hourly which users have been active in the last 3 months. Add them to this channel if they have access to the %{category} category." + auto_join_users_info_no_category: "Check hourly which users have been active in the last 3 months. Add them to this channel if they have access to the selected category." 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" + threading_enabled: "Enabled" + threading_disabled: "Disabled" muted_on: "On" muted_off: "Off" notifications: "Notifications" @@ -340,10 +462,14 @@ en: saved: "Saved" unfollow: "Leave" admin_title: "Admin" - retention_info: "Chat history will be saved for %{days} days." admin: title: "Chat" + export_messages: + title: "Export chat messages" + description: "Export is currently limited to 10000 most recent messages in the last 6 months." + create_export: "Create export" + export_has_started: "The export has started. You'll receive a PM when it's ready." direct_messages: title: "Personal chat" @@ -412,8 +538,14 @@ en: 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." + public_none: "Channel history is retained indefinitely." + public: + one: "Channel history is retained for %{count} day." + other: "Channel history is retained for %{count} days." + dm_none: "Personal chat history is retained indefinitely." + dm: + one: "Personal chat history is retained for %{count} day." + other: "Personal chat history is retained for %{count} 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." @@ -439,6 +571,34 @@ en: search_placeholder: "Search by emoji name and alias..." no_results: "No results" + thread: + title: "Title" + view_thread: View thread + default_title: "Thread" + replies: + one: "%{count} reply" + other: "%{count} replies" + label: Thread + close: "Close Thread" + original_message: + started_by: "Started by" + settings: "Settings" + last_reply: "last reply" + notifications: + regular: + title: "Normal" + description: "You will be notified if someone mentions your @name in this thread." + tracking: + title: "Tracking" + description: "A count of new replies for this thread will be shown in the thread list and the channel. You will be notified if someone mentions your @name in this thread." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Open Thread" + list: "Ongoing discussions" + none: "You are not participating in any threads in this channel." + draft_channel_screen: header: "New Message" cancel: "Cancel" @@ -452,11 +612,13 @@ en: direct: 'mentioned you in "%{channel}"' direct_html: '%{username} mentioned you in "%{channel}"' other_plain: 'mentioned %{identifier} in "%{channel}"' + # %{identifier} is either @here or @all 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" + # %{identifier} is either @here or @all other_html: "%{username} mentioned %{identifier} in personal chat" chat_message: "New chat message" chat_quoted: "%{username} quoted your chat message" @@ -485,9 +647,8 @@ en: transcript: view: "View previous messages transcript" types: - reviewable_chat_message: + chat_reviewable_message: title: "Flagged Chat Message" - flagged_by: "Flagged By" keyboard_shortcuts_help: chat: title: "Chat" @@ -500,6 +661,7 @@ en: composer_code: "%{shortcut} Code (composer only)" drawer_open: "%{shortcut} Open chat drawer" drawer_close: "%{shortcut} Close chat drawer" + mark_all_channels_read: "%{shortcut} Mark all channels read" topic_statuses: chat: help: "Chat is enabled for this topic" @@ -520,3 +682,8 @@ en: chat_notifications_with_unread: one: "Chat notifications - %{count} unread notification" other: "Chat notifications - %{count} unread notifications" + + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.es.yml b/plugins/chat/config/locales/client.es.yml index d0701eec9b4..bb07b7a36bb 100644 --- a/plugins/chat/config/locales/client.es.yml +++ b/plugins/chat/config/locales/client.es.yml @@ -15,6 +15,7 @@ es: actions: chat_channel_status_change: "Se ha cambiado el estado del canal de chat" chat_channel_delete: "Canal de chat eliminado" + chat_auto_remove_membership: "Las afiliaciones se eliminan automáticamente de los canales" api: scopes: descriptions: @@ -39,8 +40,8 @@ es: 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." + one: "Estás moviendo %{count} mensaje. Selecciona un canal de destino. Se creará un mensaje de marcador de posición en el canal %{channelTitle} para indicar que este mensaje se ha movido. Ten en cuenta que las cadenas de respuesta no se conservarán en el nuevo canal, y los mensajes del canal antiguo ya no aparecerán como respuesta a ningún mensaje movido." + other: "Estás moviendo %{count} mensajes. Selecciona un canal de destino. Se creará un mensaje de marcador de posición en el canal %{channelTitle} para indicar que se han movido estos mensajes. Ten en cuenta que las cadenas de respuesta no se conservarán en el nuevo canal, y los mensajes del canal antiguo ya no aparecerán como respuesta a los mensajes movidos." confirm_move: "Mover mensajes" channel_settings: title: "Ajustes del canal" @@ -71,7 +72,7 @@ es: 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.

    " + instructions: "

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

    ¿Seguro que quieres eliminar permanentemente el canal? Para confirmar, 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." @@ -81,8 +82,11 @@ es: click_to_join: "Haz clic aquí para ver los canales disponibles." close: "Cerrar" collapse: "Contraer contenedor del chat" + expand: "Ampliar el cajón de chat" confirm_flag: "¿Seguro que quieres denunciar el mensaje de %{username}?" - deleted: "Se eliminó un mensaje. [ver]" + deleted: + one: "Se eliminó un mensaje. [view]" + other: "Se han eliminado %{count} mensajes. [ver todos]" hidden: "Se ha ocultado un mensaje. [ver]" delete: "Eliminar" edited: "editado" @@ -97,6 +101,11 @@ es: never: "Nunca" title: "Notificaciones por correo electrónico" when_away: "Solo cuando estés ausente" + header_indicator_preference: + title: "Mostrar indicador de actividad en el encabezado" + all_new: "Todos los mensajes nuevos" + dm_and_mentions: "Mensajes directos y menciones" + never: "Nunca" enable: "Habilitar chat" flag: "Denunciar" emoji: "Insertar emoji" @@ -106,53 +115,111 @@ es: in_reply_to: "En respuesta a" heading: "Chat" join: "Unirse" - new_messages: "nuevos mensajes" + last_visit: "última visita" + summarization: + title: "Resumir mensajes" + description: "Selecciona una opción a continuación para resumir la conversación enviada durante el periodo de tiempo deseado." + summarize: "Resumir" + since: + one: "Última hora" + other: "Últimas %{count} horas" mention_warning: dismiss: "descartar" - cannot_see: - one: "%{username} no puede acceder a este canal y no fue notificado." - other: "%{username} y %{others} no pueden acceder a este canal y no fueron notificados." + cannot_see: "%{username} no puede acceder a este canal y no ha sido notificado." + cannot_see_multiple: + one: "%{username} y otro usuario no pueden acceder a este canal y no fueron notificados." + other: "%{username} y otros %{count} usuarios no pueden acceder a este canal y no fueron notificados." invitations_sent: one: "Invitación enviada" other: "Invitaciones enviadas" invite: "Invitar al canal" - without_membership: - one: "%{username} no se ha unido a este canal." - other: "%{username} y %{others} no se han unido a este canal." - group_mentions_disabled: - one: "%{group_name} no permite menciones" - other: "%{group_name} y %{others} no permiten menciones" - too_many_members: - one: "%{group_name} tiene demasiados miembros. No se notificó a nadie" - other: "%{group_name} y %{others} tienen demasiados miembros. No se notificó a nadie" - warning_multiple: - one: "%{count} otro" - other: "%{count} otros" + without_membership: "%{username} no se ha unido a este canal." + without_membership_multiple: + one: "%{username} y otro usuario no se han unido a este canal." + other: "%{username} y otros %{count} usuarios no se han unido a este canal." + group_mentions_disabled: "%{group_name} no permite menciones." + group_mentions_disabled_multiple: + one: "%{group_name} y otro grupo no permiten menciones." + other: "%{group_name} y otros %{count} grupos no permiten menciones." + too_many_members: "%{group_name} tiene demasiados miembros. No se notificó a nadie." + too_many_members_multiple: + one: "%{group_name} y otro grupo tienen demasiados miembros. No se ha notificado a nadie." + other: "%{group_name} y otros %{count} grupos tienen demasiados miembros. No se ha notificado a nadie." groups: header: some: "Algunos usuarios no serán notificados" all: "No se notificará a nadie" - unreachable: - one: "@%{group} no permite menciones" - other: "@%{group} y @%{group_2} no permiten menciones" - unreachable_multiple: "@%{group} y otros %{count} no permiten menciones" - too_many_members: - one: "Mencionar a @%{grupo} supera el %{notification_limit} de %{limit}" - other: "Mencionar tanto a @%{grupo} como a @%{group_2} supera el %{notification_limit} de %{limit}" - too_many_members_multiple: "Estos %{count} grupos superan el %{notification_limit} de %{limit}" - users_limit: - one: "%{count} usuario" - other: "%{count} usuarios" - notification_limit: "límite de notificaciones" - too_many_mentions: "Este mensaje supera el %{notification_limit} de %{limit}" - mentions_limit: - one: "%{count} mención" - other: "%{count} menciones" + unreachable_1: "@%{group} no permite menciones." + unreachable_2: "@%{group1} y @%{group2} no permiten menciones." + unreachable_multiple: + one: "@%{group} y otro grupo no permiten menciones." + other: "@%{group} y otros %{count} grupos no permiten menciones." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mencionar a @{group1} excede el límite de notificaciones de # usuario.} + other {Mencionar a @{group1} excede el límite de notificaciones de # usuarios.} + } + } + false { + { notificationLimit, plural, + one {Mencionar a @{group1} excede el límite de notificación de # usuario.} + other {Mencionar a @{group1} excede el límite de notificación de # usuarios.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mencionar a @{group1} y @{group2} excede el límite de notificación de # usuario.} + other {Mencionar a @{group1} y @{group2} excede el límite de notificación de # usuarios.} + } + } + false { + { notificationLimit, plural, + one {Mencionar a @{group1} y @{group2} excede el límite de notificación de # usuario.} + other {Mencionar a @{group1} y @{group2} excede el límite de notificación de # usuarios.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Mencionar a estos {groupCount} grupos excede el límite de notificación de # usuario.} + other {Mencionar a estos {groupCount} grupos excede el límite de notificación de # usuarios.} + } + } + false { + { notificationLimit, plural, + one {Mencionar a estos {groupCount} grupos excede el límite de notificación de # usuario.} + other {Mencionar a estos {groupCount} grupos excede el límite de notificación de # usuarios.} + } + } + other {} + } + } + } + too_many_mentions: + one: "Este mensaje supera el límite de notificaciones de %{count} mención." + other: "Este mensaje supera el límite de notificaciones de %{count} menciones." + too_many_mentions_admin: + one: 'Este mensaje supera el límite de notificaciones de %{count} mención.' + other: 'Este mensaje supera el límite de notificaciones de %{count} menciones.' 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." + kicked_from_channel: "Ya no puedes acceder a este 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" @@ -164,10 +231,16 @@ es: 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 está %{status}, no puedes enviar nuevos mensajes en este momento." + placeholder_channel: "Chat en %{channelName}" + placeholder_thread: "Chatear en el hilo" + placeholder_users: "Chatear con %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "El canal está archivado, no puedes enviar nuevos mensajes en este momento." + closed: "El canal está cerrado, no puedes enviar nuevos mensajes en este momento." + read_only: "El canal es de solo lectura, 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} + placeholder_start_conversation: "Iniciar una conversación con ..." + placeholder_start_conversation_users: "Iniciar una conversación con %{commaSeparatedUsernames}" remove_upload: "Eliminar archivo" react: "Reaccionar con emojis" reply: "Responder" @@ -183,8 +256,11 @@ es: restore: "Restaurar mensaje eliminado" save: "Guardar" select: "Seleccionar" - silence: "Silenciar al usuario" return_to_list: "Volver a la lista de canales" + return_to_threads_list: "Volver a las discusiones en curso" + unread_threads_count: + one: "Tienes %{count} discusión sin leer" + other: "Tienes %{count} discusiones sin leer" scroll_to_bottom: "Desplazar hacia abajo" scroll_to_new_messages: "Ver nuevos mensajes" sound: @@ -196,6 +272,8 @@ es: title: "chat" title_capitalized: "Chat" upload: "Adjuntar un archivo" + upload_to_channel: "Subir a %{title}" + upload_to_thread: "Subir al hilo" uploaded_files: one: "%{count} archivo" other: "%{count} archivos" @@ -239,10 +317,23 @@ es: about: Acerca de members: Miembros settings: Ajustes - channel_edit_name_modal: - title: Editar nombre + new_message_modal: + title: Enviar mensaje + add_user_long: mayúsculas + clic o mayúsculas + introAñadir @%{username} + add_user_short: Añadir usuario + open_channel: Abrir canal + default_search_placeholder: "#un-canal, @alguien o lo que sea" + default_channel_search_placeholder: "#un-canal" + default_user_search_placeholder: "@alguien" + user_search_placeholder: "...añadir más usuarios" + disabled_user: "ha desactivado el chat" + no_items: "No hay elementos" + channel_edit_name_slug_modal: + title: Editar canal input_placeholder: Añadir un nombre - description: Pon un título corto y descriptivo a tu canal + slug_description: Se utiliza un slug del canal en la URL en lugar del nombre del canal + name: Nombre del canal + slug: Slug del canal (opcional) channel_edit_description_modal: title: Editar descripción input_placeholder: Añade una descripción @@ -252,9 +343,6 @@ es: 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 @@ -262,23 +350,44 @@ es: one: "%{count} miembro" other: "%{count} miembros" create_channel: + threading: + label: "Activar el hilado" 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?' + warning_1_group: + one: "¿Añadir automáticamente %{count} usuario de %{group}?" + other: "¿Añadir automáticamente %{count} usuarios de %{group}?" + warning_2_groups: + one: "¿Añadir automáticamente %{count} usuario de %{group1} y %{group2}?" + other: "¿Añadir automáticamente %{count} usuarios de %{group1} y %{group2}?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {¿Añadir automáticamente {userCount} usuario de {groupName} y otro grupo?} + other {¿Añadir automáticamente {userCount} usuarios de {groupName} y otro grupo?} + } + } + other { + { userCount, plural, + one {¿Añadir automáticamente {userCount} usuario de {groupName} y otros grupos?} + other {¿Añadir automáticamente {userCount} usuarios de {groupName} y otros grupos?} + } + } + } 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. + hint_1_group: 'Los usuarios de %{group} tendrán acceso a este canal según la configuración de seguridad' + hint_2_groups: 'Los usuarios de %{group1} y %{group2} tendrán acceso a este canal según la configuración de seguridad.' + hint_multiple_groups: + one: 'Los usuarios de %{group} y otro grupo tendrán acceso a este canal según la configuración de seguridad' + other: 'Los usuarios de %{group} y otros %{count} 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" + slug: "Slug del canal (opcional)" title: "Nuevo canal" type: "Tipo" types: @@ -288,15 +397,22 @@ es: 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}:" + you_and_single_user: "Tú y %{username} reaccionasteis con :%{emoji}:" + you_and_multiple_users: "Tú, %{commaSeparatedUsernames} y %{username} reaccionasteis con :%{emoji}:" + you_multiple_users_and_more: + one: "Tú, %{commaSeparatedUsernames} y otro reaccionasteis con :%{emoji}:" + other: "Tú, %{commaSeparatedUsernames} y %{count} personas más reaccionasteis con :%{emoji}:" + single_user: "%{username} reaccionó con :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} y %{username} reaccionaron con :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} y otro reaccionaron con :%{emoji}:" + other: "%{commaSeparatedUsernames} y %{count} personas más reaccionaron con :%{emoji}:" composer: toggle_toolbar: "Alternar barra de herramientas" italic_text: "texto enfatizado" bold_text: "texto fuerte" code_text: "texto del código" + send: "Enviar" quote: original_channel: 'Enviado originalmente en %{channel}' copy_success: "Cita del chat copiada en el portapapeles" @@ -307,15 +423,19 @@ es: settings: channel_wide_mentions_label: "Permitir menciones @all y @here" channel_wide_mentions_description: "Permite a los usuarios notificar a todos los miembros de #%{channel} con @all o solo a los que estén activos en ese momento con @here" + channel_threading_label: "Hilado" + channel_threading_description: "Cuando se activa el hilado, las respuestas a un mensaje de chat crearán una conversación separada, que existirá junto al canal principal." auto_join_users_label: "Añadir usuarios automáticamente" - auto_join_users_info: "Consulta cada hora qué usuarios han estado activos en los últimos 3 meses y, si tienen acceso a la categoría %{category} , añádelos a este canal." - enable_auto_join_users: "Añade automáticamente todos los usuarios activos recientemente" + auto_join_users_info: "Comprueba cada hora qué usuarios han estado activos en los últimos 3 meses. Añádelos a este canal si tienen acceso a la categoría %{category}." + auto_join_users_info_no_category: "Comprueba cada hora qué usuarios han estado activos en los últimos 3 meses. Añádelos a este canal si tienen acceso a la categoría seleccionada." 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" + threading_enabled: "Activado" + threading_disabled: "Desactivado" muted_on: "Activado" muted_off: "Desactivado" notifications: "Notificaciones" @@ -324,9 +444,13 @@ es: saved: "Guardado" unfollow: "Abandonar" admin_title: "Administrador" - retention_info: "El historial de chat se guardará durante %{days} días." admin: title: "Chat" + export_messages: + title: "Exportar mensajes de chat" + description: "Actualmente, la exportación está limitada a los 10 000 mensajes más recientes de los últimos 6 meses." + create_export: "Crear exportación" + export_has_started: "La exportación ha comenzado. Recibirás un MP cuando esté lista." direct_messages: title: "Chat personal" new: "Crear un chat personal" @@ -390,8 +514,14 @@ es: 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." + public_none: "El historial del canal se conserva indefinidamente." + public: + one: "El historial del canal se conserva durante %{count} día." + other: "El historial del canal se conserva durante %{count} días." + dm_none: "El historial personal de chat se conserva indefinidamente." + dm: + one: "El historial de chat personal se conserva durante %{count} día." + other: "El historial de chat personal se conserva durante %{count} días." 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." @@ -413,6 +543,33 @@ es: symbols: "Símbolos" search_placeholder: "Busca por nombre de emoji y alias..." no_results: "No hay resultados" + thread: + title: "Título" + view_thread: Ver hilo + default_title: "Hilo" + replies: + one: "%{count} respuesta" + other: "%{count} respuestas" + label: Hilo + close: "Cerrar hilo" + original_message: + started_by: "Iniciado por" + settings: "Ajustes" + last_reply: "última respuesta" + notifications: + regular: + title: "Normal" + description: "Recibirás una notificación si alguien menciona tu @nombre en este hilo." + tracking: + title: "En seguimiento" + description: "Se mostrará un recuento de las nuevas respuestas de este hilo en la lista de hilos y en el canal. Se te notificará si alguien menciona tu @nombre en este hilo." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Hilo abierto" + list: "Discusiones en curso" + none: "No participas en ningún hilo de este canal." draft_channel_screen: header: "Nuevo mensaje" cancel: "Cancelar" @@ -457,9 +614,8 @@ es: transcript: view: "Ver la transcripción de los mensajes anteriores" types: - reviewable_chat_message: + chat_reviewable_message: title: "Mensaje de chat denunciado" - flagged_by: "Denunciado por" keyboard_shortcuts_help: chat: title: "Chat" @@ -472,6 +628,7 @@ es: composer_code: "%{shortcut} Código (solo compositor)" drawer_open: "%{shortcut} Abrir el cajón del chat" drawer_close: "%{shortcut} Cerrar cajón del chat" + mark_all_channels_read: "%{shortcut} Marcar todos los canales como leídos" topic_statuses: chat: help: "El chat está activado para este tema" @@ -490,3 +647,7 @@ es: chat_notifications_with_unread: one: "Notificaciones del chat: %{count} notificación no leída" other: "Notificaciones de chat: %{count} notificaciones no leídas" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.et.yml b/plugins/chat/config/locales/client.et.yml index 08ba7f3f3fd..70354ff4f18 100644 --- a/plugins/chat/config/locales/client.et.yml +++ b/plugins/chat/config/locales/client.et.yml @@ -20,14 +20,13 @@ et: joined: "liitus" email_frequency: never: "Mitte kunagi" + header_indicator_preference: + never: "Mitte kunagi" flag: "Tähis" join: "Liitu" + last_visit: "viimane visiit" mention_warning: dismiss: "ignoreeri" - groups: - users_limit: - one: "%{count} kasutaja" - other: "%{count} kasutajat" reply: "Vasta" edit: "Muuda" rebake_message: "Rekonstrueeri HTML" @@ -67,11 +66,14 @@ et: composer: italic_text: "esiletõstetud tekst" bold_text: "rasvane tekst" + send: "Saada" notification_levels: never: "Mitte kunagi" settings: follow: "Liitu" followed: "Liitus" + threading_enabled: "Sisse lülitatud" + threading_disabled: "Välja lülitatud" notifications: "Teavitus" preview: "Eelvaade" save: "Salvesta" @@ -101,6 +103,21 @@ et: emoji_picker: objects: "Objektid" flags: "Tähised" + thread: + title: "Pealkiri" + replies: + one: "%{count} vastus" + other: "%{count} vastust" + settings: "Sätted" + last_reply: "viimane vastus" + notifications: + regular: + title: "Normaalne" + tracking: + title: "Jälgimine" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Uus sõnum" cancel: "Tühista" diff --git a/plugins/chat/config/locales/client.fa_IR.yml b/plugins/chat/config/locales/client.fa_IR.yml index de5f1a01fe4..90f1228b7e0 100644 --- a/plugins/chat/config/locales/client.fa_IR.yml +++ b/plugins/chat/config/locales/client.fa_IR.yml @@ -64,7 +64,6 @@ fa_IR: click_to_join: "برای مشاهده کانال‌های موجود، اینجا را کلیک کنید." close: "بستن" confirm_flag: "آیا برای پرچم گذاری پیام %{username} مطمئن هستید؟" - deleted: "یک پیام حذف شد. [view]" hidden: "یک پیام پنهان شده است. [view]" delete: "حذف" edited: "ویرایش شده" @@ -77,6 +76,10 @@ fa_IR: email_frequency: never: "هرگز" title: "آگاه‌سازی‌های ایمیل" + header_indicator_preference: + all_new: "همه پیام‌های جدید" + dm_and_mentions: "پیام‌های خصوصی و اشاره شده" + never: "هرگز" enable: "فعال کردن گفتگو" flag: "پرچم" flagged: "این پیام برای بررسی پرچم گذاری شده است" @@ -84,24 +87,19 @@ fa_IR: in_reply_to: "در پاسخ به" heading: "گفتگو" join: "عضو شدن" - new_messages: "پیام‌های جدید" + last_visit: "آخرین بازدید" mention_warning: dismiss: "رد کردن" invitations_sent: one: "دعوت‌نامه ارسال شد" other: "دعوت‌نامه‌ها ارسال شد" invite: "دعوت به کانال" - groups: - users_limit: - one: "%{count} کاربر" - other: "%{count} کاربر" aria_roles: channels_list: "فهرست کانال‌های گفتگو" no_public_channels: "شما هنوز عضو هیچ کانالی نشده‌اید." open: "باز کردن گفتگو..." close_full_page: "بستن گفتگو تمام صفحه" open_message: "پیام را در گفتگو باز کن" - placeholder_start_conversation: شروع گفتگو با %{usernames} remove_upload: "حذف پرونده" react: "واکنش با شکلک" reply: "پاسخ" @@ -118,6 +116,10 @@ fa_IR: save: "ذخیره" select: "انتخاب کنید" return_to_list: "بازگشت به فهرست کانال‌ها" + return_to_threads_list: "بازگشت به گفتگوی جاری" + unread_threads_count: + one: "شما %{count} گفتگوی خوانده نشده دارید" + other: "شما %{count} گفتگوی خوانده نشده دارید" scroll_to_bottom: "حرکت به پایین" scroll_to_new_messages: "مشاهده پیام‌های جدید" sound: @@ -167,9 +169,10 @@ fa_IR: about: درباره members: اعضاء settings: تنظیمات - channel_edit_name_modal: - title: ویرایش نام + channel_edit_name_slug_modal: + title: ویرایش کانال input_placeholder: افزودن نام + name: نام کانال channel_edit_description_modal: title: ویرایش توضیحات input_placeholder: افزودن توضیحات @@ -178,8 +181,6 @@ fa_IR: title: پیام جدید prefix: "به:" no_results: هیج نتیجه‌ای نداشت - channel_selector: - no_channels: "هیچ کانالی با جستجوی شما مطابقت ندارد" channel: no_memberships: این کانال هنوز هیچ عضوی ندارد no_memberships_found: هیچ عضوی یافت نشد @@ -187,11 +188,6 @@ fa_IR: 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: "یکی را انتخاب کنید..." @@ -210,6 +206,7 @@ fa_IR: composer: italic_text: "متن تاکید شده" bold_text: "نوشته‌ی ضخیم " + send: "ارسال" quote: copy_success: "نقل قول گفتگو در کلیپ‌بورد کپی شد" notification_levels: @@ -220,12 +217,11 @@ fa_IR: channel_wide_mentions_label: "اجازه داده به استفاده از اشاره @all و @here" channel_wide_mentions_description: "به کاربران اجازه دهید به همه اعضای #%{channel} با @all یا فقط افرادی که در حال حاضر فعال هستند با @here، آگاه‌سازی کنند." auto_join_users_label: "افزودن خودکار کاربران" - auto_join_users_info: "بررسی کنید که کاربران در ۳ ماه گذشته فعال بوده‌اند و در دسته‌بندی %{category} دسترسی داشته باشند، آنها را به این کانال اضافه کنید." - enable_auto_join_users: "به طور خودکار همه کاربران فعال اخیر را اضافه کنید" desktop_notification_level: "آگاه‌سازی‌های دسکتاپ" follow: "عضو شدن" followed: "عضو شده" mute: "بی‌صدا کردن کانال" + threading_disabled: "غیرفعال" muted_on: "روشن" muted_off: "خاموش" notifications: "اعلان‌ها" @@ -234,7 +230,6 @@ fa_IR: saved: "ذخیره شد" unfollow: "ترک کردن" admin_title: "مدیر کل" - retention_info: "تاریخچه گفتگو به مدت %{days} روز ذخیره خواهد شد." admin: title: "گفتگو" direct_messages: @@ -285,6 +280,23 @@ fa_IR: flags: "پرچم‌ها" symbols: "نشانه ها" no_results: "هیج نتیجه‌ای نداشت" + thread: + title: "عنوان" + view_thread: مشاهده موضوع + replies: + one: "%{count} پاسخ" + other: "%{count} پاسخ" + original_message: + started_by: "شروع شده توسط" + settings: "تنظیمات" + last_reply: "آخرین پاسخ" + notifications: + regular: + title: "معمولی" + tracking: + title: "پیگیری" + threads: + list: "گفتگوهای در حال انجام" draft_channel_screen: header: "پیام جدید" cancel: "انصراف" @@ -315,10 +327,6 @@ fa_IR: review: transcript: view: "مشاهده رونوشت متن پیام‌های قبلی" - types: - reviewable_chat_message: - title: "پیام گفتگوی پرچم گذاری شده" - flagged_by: "پرچم شده توسط" keyboard_shortcuts_help: chat: title: "گفتگو" @@ -342,3 +350,7 @@ fa_IR: chat_notifications_with_unread: one: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" other: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" + styleguide: + sections: + chat: + title: گفتگو diff --git a/plugins/chat/config/locales/client.fi.yml b/plugins/chat/config/locales/client.fi.yml index 45833a40dbb..e10a33cc757 100644 --- a/plugins/chat/config/locales/client.fi.yml +++ b/plugins/chat/config/locales/client.fi.yml @@ -38,9 +38,6 @@ fi: 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" @@ -71,7 +68,6 @@ fi: 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." @@ -81,8 +77,11 @@ fi: click_to_join: "Näytä saatavilla olevat kanavat napsauttamalla tätä." close: "Sulje" collapse: "Tiivistä chat-laatikko" + expand: "Laajenna chat-laatikko" confirm_flag: "Haluatko varmasti liputtaa käyttäjän %{username} viestin?" - deleted: "Viesti poistettiin. [näytä]" + deleted: + one: "Viesti poistettiin. [näytä]" + other: "%{count} viestiä poistettiin. [näytä kaikki]" hidden: "Viesti piilotettiin. [näytä]" delete: "Poista" edited: "muokattu" @@ -97,6 +96,9 @@ fi: never: "Ei koskaan" title: "Sähköposti-ilmoitukset" when_away: "Vain poissa ollessa" + header_indicator_preference: + all_new: "Kaikki uudet viestit" + never: "Ei koskaan" enable: "Ota chat käyttöön" flag: "Liputa" emoji: "Lisää emoji" @@ -106,48 +108,22 @@ fi: in_reply_to: "Vastauksena:" heading: "Chat" join: "Liity" - new_messages: "uusia viestejä" + last_visit: "edellinen vierailu" + summarization: + summarize: "Tee yhteenveto" mention_warning: dismiss: "hylkää" - cannot_see: - one: "%{username} ei voi käyttää tätä kanavaa, eikä hänelle ilmoitettu." - other: "%{username} ja %{others} eivät voi käyttää tätä kanavaa, eikä heille ilmoitettu." invitations_sent: one: "Kutsu lähetetty" other: "Kutsut lähetettiin" invite: "Kutsu kanavalle" - without_membership: - one: "%{username} ei ole liittynyt tälle kanavalle." - other: "%{username} ja %{others} eivät ole liittyneet tälle kanavalle." - group_mentions_disabled: - one: "%{group_name} ei salli mainintoja" - other: "%{group_name} ja %{others} eivät salli mainintoja" - too_many_members: - one: "Ryhmässä %{group_name} on liian monta jäsentä. Kukaan ei saanut ilmoitusta." - other: "Ryhmä %{group_name} ja %{others} sisältävät liikaa jäseniä. Kukaan ei saanut ilmoitusta." - warning_multiple: - one: "%{count} muu" - other: "%{count} muuta" + without_membership: "%{username} ei ole liittynyt tälle kanavalle." + group_mentions_disabled: "%{group_name} ei salli mainintoja." groups: header: some: "Osa käyttäjistä ei saa ilmoitusta" all: "Kukaan ei saa ilmoitusta" - unreachable: - one: "@%{group} ei salli mainintoja" - other: "@%{group} ja @%{group_2} eivät salli mainintoja" - unreachable_multiple: "@%{group} ja %{count} muuta eivät salli mainintoja" - too_many_members: - one: "Ryhmän @%{group} mainitseminen ylittää %{notification_limit}: %{limit}" - other: "Ryhmien @%{group} ja @%{group_2} mainitseminen ylittää %{notification_limit}: %{limit}" - too_many_members_multiple: "Nämä %{count} ryhmää ylittää %{notification_limit}: %{limit}" - users_limit: - one: "%{count} käyttäjä" - other: "%{count} käyttäjää" - notification_limit: "ilmoitusrajan" - too_many_mentions: "Tämä viesti ylittää %{notification_limit}: %{limit}" - mentions_limit: - one: "%{count} maininta" - other: "%{count} mainintaa" + unreachable_1: "@%{group} ei salli mainintoja." aria_roles: header: "Chatin ylätunniste" composer: "Chatin tekstieditori" @@ -164,10 +140,11 @@ fi: 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_channel: "Keskustelu kanavalla %{channelName}" + placeholder_thread: "Keskustelu ketjussa" + placeholder_users: "Chat-keskustelu käyttäjän %{commaSeparatedNames} kanssa" placeholder_silenced: "Et voi lähettää viestejä tällä hetkellä." - placeholder_start_conversation: Aloita keskustelu käyttäjän %{usernames} kanssa + placeholder_start_conversation_users: "Aloita keskustelu käyttäjän %{commaSeparatedUsernames} kanssa" remove_upload: "Poista tiedosto" react: "Reagoi emojilla" reply: "Vastaa" @@ -183,8 +160,11 @@ fi: restore: "Palauta poistettu viesti" save: "Tallenna" select: "Valitse" - silence: "Hiljennä käyttäjä" return_to_list: "Palaa kanavaluetteloon" + return_to_threads_list: "Palaa aktiivisiin keskusteluihin" + unread_threads_count: + one: "Sinulla on %{count} lukematon keskustelu" + other: "Sinulla on %{count} lukematonta keskustelua" scroll_to_bottom: "Vieritä alas" scroll_to_new_messages: "Katso uudet viestit" sound: @@ -196,6 +176,8 @@ fi: title: "chat" title_capitalized: "Chat" upload: "Liitä tiedosto" + upload_to_channel: "Lähetä kanavalle %{title}" + upload_to_thread: "Lähetä ketjuun" uploaded_files: one: "%{count} tiedosto" other: "%{count} tiedostoa" @@ -237,9 +219,14 @@ fi: about: Tietoja members: Jäsenet settings: Asetukset - channel_edit_name_modal: - title: Muokkaa nimeä + new_message_modal: + default_channel_search_placeholder: "#a-kanava" + default_user_search_placeholder: "@joku" + no_items: "Ei kohteita" + channel_edit_name_slug_modal: + title: Muokkaa kanavaa input_placeholder: Lisää nimi + name: Kanavan nimi channel_edit_description_modal: title: Muokkaa kuvausta input_placeholder: Lisää kuvaus @@ -249,9 +236,6 @@ fi: 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 @@ -261,18 +245,10 @@ fi: 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" @@ -285,15 +261,13 @@ fi: 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}:" + single_user: "%{username} reagoivat emojilla :%{emoji}:" composer: toggle_toolbar: "Vaihda työkalupalkki" italic_text: "korostettu teksti" bold_text: "lihavoitu teksti" code_text: "kooditeksti" + send: "Lähetä" quote: original_channel: 'Lähetetty alun perin kanavalla %{channel}' copy_success: "Chat-lainaus kopioitiin leikepöydälle" @@ -305,14 +279,14 @@ fi: channel_wide_mentions_label: "Salli @all- ja @here-maininnat" channel_wide_mentions_description: "Salli käyttäjien ilmoittaa kaikille kanavan #%{channel} käyttäjille @all-maininnalla tai niille, jotka ovat aktiivisia kyseisellä hetkellä, @here-maininnalla" auto_join_users_label: "Lisää käyttäjiä automaattisesti" - auto_join_users_info: "Tarkista tunnin välein, ketkä käyttäjistä ovat olleet aktiivisia viimeisten kolmen kuukauden aikana, ja jos heillä on pääsy alueelle %{category}, lisää heidät tälle kanavalle." - enable_auto_join_users: "Lisää automaattisesti kaikki hiljattain aktiiviset käyttäjät" 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: "Vaimenna kanava" + threading_enabled: "Otettu käyttöön" + threading_disabled: "Pois käytöstä" muted_on: "Käytössä" muted_off: "Pois käytöstä" notifications: "Ilmoitukset" @@ -321,7 +295,6 @@ fi: saved: "Tallennettu" unfollow: "Poistu" admin_title: "Ylläpitäjä" - retention_info: "Chat-historia tallennetaan %{days} päivän ajaksi." admin: title: "Chat" direct_messages: @@ -386,9 +359,6 @@ fi: 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 chat-historia säilytetään %{days} päivän ajan." 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." @@ -410,6 +380,29 @@ fi: symbols: "Symbolit" search_placeholder: "Hae emojin nimen ja aliaksen mukaan..." no_results: "Ei tuloksia" + thread: + title: "Otsikko" + view_thread: Näytä ketju + replies: + one: "%{count} vastaus" + other: "%{count} vastausta" + close: "Sulje ketju" + original_message: + started_by: "Aloittanut" + settings: "Asetukset" + last_reply: "viimeisin vastaus" + notifications: + regular: + title: "Normaali" + tracking: + title: "Seuranta" + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Avaa ketju" + list: "Käynnissä olevat keskustelut" + none: "Et osallistu mihinkään viestiketjuun tällä kanavalla." draft_channel_screen: header: "Uusi viesti" cancel: "Peruuta" @@ -454,9 +447,8 @@ fi: transcript: view: "Näytä aiempien viestien transkriptio" types: - reviewable_chat_message: + chat_reviewable_message: title: "Liputettu chat-viesti" - flagged_by: "Liputtanut" keyboard_shortcuts_help: chat: title: "Chat" @@ -487,3 +479,7 @@ fi: chat_notifications_with_unread: one: "Chat-ilmoitukset – %{count} lukematon ilmoitus" other: "Chat-ilmoitukset – %{count} lukematonta ilmoitusta" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.fr.yml b/plugins/chat/config/locales/client.fr.yml index 515bcd03054..8572fbb7275 100644 --- a/plugins/chat/config/locales/client.fr.yml +++ b/plugins/chat/config/locales/client.fr.yml @@ -38,9 +38,6 @@ fr: 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" @@ -71,7 +68,6 @@ fr: 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." @@ -82,7 +78,6 @@ fr: 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é" @@ -97,6 +92,8 @@ fr: never: "Jamais" title: "Notifications par e-mail" when_away: "Seulement en cas d'absence" + header_indicator_preference: + never: "Jamais" enable: "Activer la discussion" flag: "Drapeau" emoji: "Insérer un émoji" @@ -106,48 +103,23 @@ fr: in_reply_to: "En réponse à" heading: "Discussion" join: "Rejoindre" - new_messages: "nouveaux messages" + last_visit: "dernière visite" + summarization: + summarize: "Résumer" mention_warning: dismiss: "rejeter" - cannot_see: - one: "%{username} ne peut pas accéder à ce canal et n'a pas été averti(e)." - other: "%{username} et %{others} ne peuvent pas accéder à ce canal et n'ont pas été avertis." invitations_sent: one: "Invitation envoyée" other: "Invitations envoyées" invite: "Inviter à rejoindre le canal" - without_membership: - one: "%{username} n'a pas rejoint ce canal." - other: "%{username} et %{others} n'ont pas rejoint ce canal." - group_mentions_disabled: - one: "%{group_name} n'autorise pas les mentions" - other: "%{group_name} et %{others} n'autorisent pas les mentions" - too_many_members: - one: "%{group_name} a trop de membres. Personne n'a reçu de notification" - other: "%{group_name} et %{others} ont trop de membres. Personne n'a reçu de notification" - warning_multiple: - one: "%{count} autre" - other: "%{count} autres" + without_membership: "%{username} n'a pas rejoint ce canal." + group_mentions_disabled: "%{group_name} n'autorise pas les mentions." + too_many_members: "%{group_name} a trop de membres. Personne n'a reçu de notification." groups: header: some: "Certains utilisateurs ne recevront pas de notification" all: "Personne ne recevra de notification" - unreachable: - one: "@%{group} n'autorise pas les mentions" - other: "@%{group} et @%{group_2} n'autorisent pas les mentions" - unreachable_multiple: "@%{group} et %{count} autres groupes n'autorisent pas les mentions" - too_many_members: - one: "Mentionner @%{group} dépasse la %{notification_limit} de %{limit}" - other: "Mentionner @%{group} et @%{group_2} dépasse la %{notification_limit} de %{limit}" - too_many_members_multiple: "Ces %{count} groupes dépassent la %{notification_limit} de %{limit}" - users_limit: - one: "%{count} utilisateur" - other: "%{count} utilisateurs" - notification_limit: "limite de notification" - too_many_mentions: "Ce message dépasse la %{notification_limit} de %{limit}" - mentions_limit: - one: "%{count} mention" - other: "%{count} mentions" + unreachable_1: "@%{group} n'autorise pas les mentions." aria_roles: header: "En-tête de discussion" composer: "Compositeur de discussion" @@ -164,10 +136,9 @@ fr: 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_users: "Discuter avec %{commaSeparatedNames}" placeholder_silenced: "Vous ne pouvez pas envoyer de messages pour le moment." - placeholder_start_conversation: Démarrer une conversation avec %{usernames} + placeholder_start_conversation_users: "Démarrer une conversation avec %{commaSeparatedUsernames}" remove_upload: "Supprimer le fichier" react: "Réagir avec un émoji" reply: "Répondre" @@ -183,7 +154,6 @@ fr: 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" @@ -239,6 +209,10 @@ fr: about: À propos members: Membres settings: Paramètres + new_message_modal: + no_items: "Aucun élément" + channel_edit_name_slug_modal: + name: Nom du canal channel_edit_description_modal: title: Modifier la description input_placeholder: Ajouter une description @@ -248,9 +222,6 @@ fr: 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é @@ -260,18 +231,10 @@ fr: 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 de %{group_2} ? - warning_multiple_groups: Ajouter automatiquement %{members_count} utilisateurs de %{group_1} et de %{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" @@ -284,15 +247,13 @@ fr: 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}:" + single_user: "%{username} a réagi avec :%{emoji} :" composer: toggle_toolbar: "Basculer la barre d'outils" italic_text: "texte souligné" bold_text: "texte gras" code_text: "texte codé" + send: "Envoyer" quote: original_channel: 'Envoyé à l''origine dans le canal %{channel}' copy_success: "Citation de discussion copiée dans le presse-papiers" @@ -304,14 +265,14 @@ fr: channel_wide_mentions_label: "Autoriser les mentions @all et @here" channel_wide_mentions_description: "Autoriser les utilisateurs à notifier tous les membres de #%{channel} avec @all ou uniquement ceux qui sont actifs en ce moment avec @here" auto_join_users_label: "Ajouter automatiquement des utilisateurs" - auto_join_users_info: "Vérifier toutes les heures quels utilisateurs ont été actifs au cours des 3 derniers mois et, s'ils ont accès à la catégorie %{category}, les ajouter à ce canal." - enable_auto_join_users: "Ajouter automatiquement tous les utilisateurs récemment actifs" 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" + threading_enabled: "Activée " + threading_disabled: "Désactivée" muted_on: "Activé" muted_off: "Désactivé" notifications: "Notifications" @@ -320,7 +281,6 @@ fr: saved: "Enregistré" unfollow: "Quitter" admin_title: "Administrateur" - retention_info: "L'historique des discussions sera enregistré pendant %{days} jours." admin: title: "Discussion" direct_messages: @@ -385,9 +345,6 @@ fr: 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." 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." @@ -409,6 +366,21 @@ fr: symbols: "Symboles" search_placeholder: "Recherche par nom d'émoji et alias…" no_results: "Aucun résultat" + thread: + title: "Titre" + replies: + one: "%{count} réponse" + other: "%{count} réponses" + settings: "Paramètres" + last_reply: "dernière réponse" + notifications: + regular: + title: "Normale" + tracking: + title: "Suivre" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Nouveau message" cancel: "Annuler" @@ -453,9 +425,8 @@ fr: transcript: view: "Afficher la transcription des messages précédents" types: - reviewable_chat_message: + chat_reviewable_message: title: "Message de discussion signalé" - flagged_by: "Signalé par" keyboard_shortcuts_help: chat: title: "Discussion" @@ -486,3 +457,7 @@ fr: chat_notifications_with_unread: one: "Notifications de discussion - %{count} notification non lue" other: "Notifications de discussion - %{count} notifications non lues" + styleguide: + sections: + chat: + title: Discussion diff --git a/plugins/chat/config/locales/client.gl.yml b/plugins/chat/config/locales/client.gl.yml index e0c19ffc21f..a642cf89b75 100644 --- a/plugins/chat/config/locales/client.gl.yml +++ b/plugins/chat/config/locales/client.gl.yml @@ -21,14 +21,13 @@ gl: joined: "uniuse" email_frequency: never: "Nunca" + header_indicator_preference: + never: "Nunca" flag: "Sinalar" join: "Participar" + last_visit: "última visita" mention_warning: dismiss: "desbotar" - groups: - users_limit: - one: "%{count} usuario" - other: "%{count} usuarios" reply: "Responder" edit: "Editar" rebake_message: "Reconstruír HTML" @@ -57,6 +56,8 @@ gl: about: Verbo de members: Membros settings: Configuración + new_message_modal: + no_items: "Sen elementos" direct_message_creator: title: Nova mensaxe prefix: "A:" @@ -68,11 +69,14 @@ gl: composer: italic_text: "texto recalcado" bold_text: "texto groso" + send: "Enviar" notification_levels: never: "Nunca" settings: follow: "Participar" followed: "Uniuse" + threading_enabled: "Activado" + threading_disabled: "Desactivado" notifications: "Notificacións" preview: "Visualizar" save: "Gardar" @@ -104,6 +108,21 @@ gl: activities: "Actividades" flags: "Alertas" symbols: "Símbolos" + thread: + title: "Título" + replies: + one: "%{count} resposta" + other: "%{count} repostas" + settings: "Configuración" + last_reply: "última resposta" + notifications: + regular: + title: "Normal" + tracking: + title: "Seguimento" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Nova mensaxe" cancel: "Cancelar" @@ -115,7 +134,3 @@ gl: 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 index fd5e5f56770..2259586f466 100644 --- a/plugins/chat/config/locales/client.he.yml +++ b/plugins/chat/config/locales/client.he.yml @@ -15,6 +15,7 @@ he: actions: chat_channel_status_change: "מצב ערוץ הצ׳אט השתנה" chat_channel_delete: "ערוץ הצ׳אט נמחק" + chat_auto_remove_membership: "חברות הוסרה אוטומטית מערוצים" api: scopes: descriptions: @@ -39,10 +40,10 @@ he: move_to_channel: title: "העברת הודעות לערוץ" instructions: - one: "פעולה זו תעביר הודעה %{count}. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעה הזאת הועברה." - two: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." - many: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." - other: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + one: "בחרת להעביר הודעה %{count}. נא לבחור את ערוץ היעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעה הזאת הועברה. נא לשים לב ששרשורי תגובה לא יישמרו בערוץ החדש והודעות בערוץ הישן לא תופענה עוד כתגובה להודעות כלשהן שהועברו." + two: "בחרת להעביר %{count} הודעות. נא לבחור את ערוץ היעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו. נא לשים לב ששרשורי תגובה לא יישמרו בערוץ החדש והודעות בערוץ הישן לא תופענה עוד כתגובה להודעות כלשהן שהועברו." + many: "בחרת להעביר %{count} הודעות. נא לבחור את ערוץ היעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו. נא לשים לב ששרשורי תגובה לא יישמרו בערוץ החדש והודעות בערוץ הישן לא תופענה עוד כתגובה להודעות כלשהן שהועברו." + other: "בחרת להעביר %{count} הודעות. נא לבחור את ערוץ היעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו. נא לשים לב ששרשורי תגובה לא יישמרו בערוץ החדש והודעות בערוץ הישן לא תופענה עוד כתגובה להודעות כלשהן שהועברו." confirm_move: "העברת הודעות" channel_settings: title: "הגדרות ערוץ" @@ -73,7 +74,7 @@ he: instructions: "סגירת הערוץ מונעת ממשתמשים מחוץ לסגל לשלוח הודעות חדשות או לערוך הודעות קיימות. לסגור את הערוץ הזה?" channel_delete: title: "מחיקת ערוץ" - instructions: "

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

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

    " + instructions: "

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

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

    " confirm: "ההשלכות ברורות לי, נא למחוק את הערוץ" confirm_channel_name: "נא למלא את שם הערוץ" process_started: "תהליך מחיקת הערוץ החל. חלונית צצה זו תיסגר בקרוב, הערוץ שנמחק לא יופיעו עוד בשום מקום." @@ -83,8 +84,13 @@ he: click_to_join: "לחיצה כאן תציג את הערוצים הזמינים." close: "סגירה" collapse: "צמצום מגירת צ׳אט" + expand: "הרחבת מגירת הצ׳אט" confirm_flag: "לסמן את ההודעה של %{username}?" - deleted: "הודעה נמחקה. [צפייה]" + deleted: + one: "הודעה נמחקה. [הצגה]" + two: "%{count} הודעות נמחקו. [הצגה של הכול]" + many: "%{count} הודעות נמחקו. [הצגה של הכול]" + other: "%{count} הודעות נמחקו. [הצגה של הכול]" hidden: "הודעה הוסתרה. [צפייה]" delete: "מחיקה" edited: "נערך" @@ -99,6 +105,11 @@ he: never: "לעולם לא" title: "הודעות בדוא״ל" when_away: "רק כשלא במערכת" + header_indicator_preference: + title: "הצגת מחוון פעילות בכותרת" + all_new: "כל ההודעות החדשות" + dm_and_mentions: "הודעות ישירות ואזכורים" + never: "לעולם לא" enable: "הפעלת צ׳אט" flag: "דיגול" emoji: "הוספת אמוג׳י" @@ -108,73 +119,129 @@ he: in_reply_to: "בתגובה אל" heading: "צ׳אט" join: "הצטרף" - new_messages: "הודעות חדשות" + last_visit: "ביקור אחרון" + summarization: + title: "סיכום הודעות" + description: "נא לבחור אפשרות להלן כדי לסכם את הדיון שנשלח בפרק הזמן המבוקש." + summarize: "סיכום" + since: + one: "שעה אחרונה" + two: "השעתיים האחרונות" + many: "%{count} השעות האחרונות" + other: "%{count} השעות האחרונות" mention_warning: dismiss: "התעלמות" - cannot_see: - one: "ל־%{username} אין גישה לערוץ הזה ולא נשלחה הודעה." - two: "ל־%{username} ולעוד %{others} אין גישה לערוץ הזה ולא נשלחו הודעות." - many: "ל־%{username} ולעוד %{others} אין גישה לערוץ הזה ולא נשלחו הודעות." - other: "ל־%{username} ולעוד %{others} אין גישה לערוץ הזה ולא נשלחו הודעות." + cannot_see: "ל־%{username} אין גישה לערוץ הזה ולא נשלחה התראה." + cannot_see_multiple: + one: "ל־%{username} ולעוד %{count} אין גישה לערוץ הזה ולא נשלחו הודעות." + two: "ל־%{username} ולעוד %{count} אין גישה לערוץ הזה ולא נשלחו הודעות." + many: "ל־%{username} ולעוד %{count} אין גישה לערוץ הזה ולא נשלחו הודעות." + other: "ל־%{username} ולעוד %{count} אין גישה לערוץ הזה ולא נשלחו הודעות." invitations_sent: one: "נשלחה הזמנה" two: "נשלחו הזמנות" many: "נשלחו הזמנות" other: "נשלחו הזמנות" invite: "הזמנה לערוץ" - without_membership: - one: "לא הצטרפו לערוץ הזה: %{username}." - two: "לא הצטרפו לערוץ הזה: %{username} ועוד %{others}." - many: "לא הצטרפו לערוץ הזה: %{username} ועוד %{others}." - other: "לא הצטרפו לערוץ הזה: %{username} ועוד %{others}." - group_mentions_disabled: - one: "אזכורים אסורים בקבוצה %{group_name}" - two: "אזכורים אסורים בקבוצה %{group_name} ובעוד %{others}" - many: "אזכורים אסורים בקבוצה %{group_name} ובעוד %{others}" - other: "אזכורים אסורים בקבוצה %{group_name} ובעוד %{others}" - too_many_members: - one: "בקבוצה %{group_name} יש יותר מדי חברים. לא נשלחו התראות." - two: "בקבוצה %{group_name} וב־%{others} נוספות יש יותר מדי חברים. לא נשלחו התראות." - many: "בקבוצה %{group_name} וב־%{others} נוספות יש יותר מדי חברים. לא נשלחו התראות." - other: "בקבוצה %{group_name} וב־%{others} נוספות יש יותר מדי חברים. לא נשלחו התראות." - warning_multiple: - one: "%{count} נוסף" - two: "%{count} נוספים" - many: "%{count} נוספים" - other: "%{count} נוספים" + without_membership: "לא הצטרפו לערוץ הזה: %{username}." + without_membership_multiple: + one: "לא הצטרפו לערוץ הזה: %{username} ועוד %{count}." + two: "לא הצטרפו לערוץ הזה: %{username} ועוד %{count}." + many: "לא הצטרפו לערוץ הזה: %{username} ועוד %{count}." + other: "לא הצטרפו לערוץ הזה: %{username} ועוד %{count}." + group_mentions_disabled: "אזכורים אסורים בקבוצה %{group_name}." + group_mentions_disabled_multiple: + one: "%{group_name} וקבוצה %{count} נוספת לא מרשות אזכורים." + two: "%{group_name} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + many: "%{group_name} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + other: "%{group_name} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + too_many_members: "בקבוצה %{group_name} יש יותר מדי חברים. לא נשלחו התראות." + too_many_members_multiple: + one: "בקבוצה %{group_name} וב־%{count} נוספת יש יותר מדי חברים. לא נשלחו התראות." + two: "בקבוצה %{group_name} וב־%{count} נוספות יש יותר מדי חברים. לא נשלחו התראות." + many: "בקבוצה %{group_name} וב־%{count} נוספות יש יותר מדי חברים. לא נשלחו התראות." + other: "בקבוצה %{group_name} וב־%{count} נוספות יש יותר מדי חברים. לא נשלחו התראות." groups: header: some: "חלק מהמשתמשים לא יקבלו התראה" all: "אף אחד לא יקבל התראה" - unreachable: - one: "אזכורים אסורים בקבוצה ‎@%{group}" - two: "אזכורים אסורים בקבוצה ‎@%{group} ובקבוצה ‎@%{group_2}" - many: "אזכורים אסורים בקבוצה ‎@%{group} ובקבוצה ‎@%{group_2}" - other: "אזכורים אסורים בקבוצה ‎@%{group} ובקבוצה ‎@%{group_2}" - unreachable_multiple: "‎@%{group} ועוד %{count} נוספות לא מרשות לאזכר" - too_many_members: - one: "אזכור ‎@%{group} חורג מ%{notification_limit} של %{limit}" - two: "אזכור של ‎@%{group} או ‎@%{group_2} חורג מ%{notification_limit} של %{limit}" - many: "אזכור של @%{group} או @%{group_2} חורג מ%{notification_limit} של %{limit}" - other: "אזכור של @%{group} או @%{group_2} חורג מ%{notification_limit} של %{limit}" - too_many_members_multiple: "%{count} קבוצות אלו חורגות מ%{notification_limit} של %{limit}" - users_limit: - one: "משתמש/ת %{count}" - two: "%{count} משתמשים" - many: "%{count} משתמשים" - other: "%{count} משתמשים" - notification_limit: "מגבלת התראה" - too_many_mentions: "הודעה זו חורגת מ%{notification_limit} של %{limit}" - mentions_limit: - one: "אזכור %{count}" - two: "%{count} אזכורים" - many: "%{count} אזכורים" - other: "%{count} אזכורים" + unreachable_1: "@אזכורים אסורים בקבוצה %{group}." + unreachable_2: "‎@%{group1} ו־‎@%{group2} לא מרשות אזכורים." + unreachable_multiple: + one: "‎@%{group} וקבוצה %{count} נוספת לא מרשות אזכורים." + two: "‎@%{group} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + many: "‎@%{group} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + other: "‎@%{group} ו־%{count} קבוצות נוספת לא מרשות אזכורים." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {אזכור של ‎@{group1} חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור של ‎@{group1} חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + false { + { notificationLimit, plural, + one {אזכור של ‎@{group1} חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור של ‎@{group1} חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {אזכור של ‎@{group1} ושל ‎@{group2} חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור של ‎@{group1} ושל ‎@{group2} חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + false { + { notificationLimit, plural, + one {אזכור של ‎@{group1} ושל ‎@{group2} חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור של ‎@{group1} ושל ‎@{group2} חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {אזכור {groupCount} הקבוצות האלו חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור {groupCount} הקבוצות האלו חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + false { + { notificationLimit, plural, + one {אזכור {groupCount} הקבוצות האלו חורג ממגבלת ההתראה על סך משתמש #.} + other {אזכור {groupCount} הקבוצות האלו חורג ממגבלת ההתראה על סך # משתמשים.} + } + } + other {} + } + } + } + too_many_mentions: + one: "ההודעה חורגת ממגבלת ההתראות של אזכור %{count}." + two: "ההודעה חורגת ממגבלת ההתראות של %{count} אזכורים." + many: "ההודעה חורגת ממגבלת ההתראות של %{count} אזכורים." + other: "ההודעה חורגת ממגבלת ההתראות של %{count} אזכורים." + too_many_mentions_admin: + one: 'הודעה זו חורגת ממגבלת ההתראות על סך אזכור %{count}.' + two: 'הודעה זו חורגת ממגבלת ההתראות על סך %{count} אזכורים.' + many: 'הודעה זו חורגת ממגבלת ההתראות על סך %{count} אזכורים.' + other: 'הודעה זו חורגת ממגבלת ההתראות על סך %{count} אזכורים.' aria_roles: header: "כותרת צ׳אט" composer: "כותב צ׳אט" channels_list: "רשימת ערוצי צ׳אט" no_public_channels: "לא הצטרפת לאף ערוץ." + kicked_from_channel: "אין לך יותר גישה לערוץ הזה." only_chat_push_notifications: title: "לשלוח התראות בדחיפה על הצ׳אט בלבד" description: "לחסום שליחה של התראות בדחיפה שאינן לגבי הצ׳אט" @@ -186,10 +253,16 @@ he: close_full_page: "סגירת צ׳אט במסך מלא" open_message: "פתיחת הודעה בצ׳אט" placeholder_self: "לקשקש משהו" - placeholder_others: "צ׳אט עם %{messageRecipient}" - placeholder_new_message_disallowed: "הערוץ %{status}, אין לך אפשרות לשלוח הודעות חדשות כעת." + placeholder_channel: "צ׳אט ב־%{channelName}" + placeholder_thread: "צ'אט בשרשור" + placeholder_users: "צ׳אט עם %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "הערוץ הועבר לארכיון, אי אפשר לשלוח אליו הודעות חדשות כעת." + closed: "הערוץ סגור, אי אפשר לשלוח אליו הודעות חדשות כעת." + read_only: "הערוץ לקריאה בלבד, אי אפשר לשלוח אליו הודעות חדשות כעת." placeholder_silenced: "אין לך אפשרות לשלוח הודעות כרגע." - placeholder_start_conversation: פתיחת דיון עם %{usernames} + placeholder_start_conversation: "פציחה בשיחה עם…" + placeholder_start_conversation_users: "פציחה בשיחה עם %{commaSeparatedUsernames}" remove_upload: "הסרת קובץ" react: "להגיב עם אמוג׳י" reply: "להגיב" @@ -205,8 +278,13 @@ he: restore: "שחזור הודעה שנמחקה" save: "שמירה" select: "בחירה" - silence: "השתקת משתמש" return_to_list: "חזרה לרשימת הערוצים" + return_to_threads_list: "חזרה לדיונים שמתקיימים כרגע" + unread_threads_count: + one: "יש דיון שטרם קראת" + two: "יש %{count} דיונים שטרם קראת" + many: "יש %{count} דיונים שטרם קראת" + other: "יש %{count} דיונים שטרם קראת" scroll_to_bottom: "גלילה לתחתית" scroll_to_new_messages: "הצגת הודעות חדשות" sound: @@ -218,6 +296,8 @@ he: title: "צ׳אט" title_capitalized: "צ׳אט" upload: "צירוף קובץ" + upload_to_channel: "העלאה אל %{title}" + upload_to_thread: "העלאה לשרשור" uploaded_files: one: "קובץ %{count}" two: "%{count} קבצים" @@ -263,10 +343,23 @@ he: about: אודות members: חברים settings: הגדרות - channel_edit_name_modal: - title: עריכת שם + new_message_modal: + title: שליחת הודעה + add_user_long: shift + לחיצת עכבר או shift + enterמוסיפים ‎@%{username} + add_user_short: הוספת משתמש + open_channel: פתיחת ערוץ + default_search_placeholder: "#ערוץ, @מישהם או כל דבר אחר" + default_channel_search_placeholder: "#ערוץ" + default_user_search_placeholder: "@מישהם" + user_search_placeholder: "…הוספת משתמשים נוספים" + disabled_user: "השבית את הצ׳אט" + no_items: "אין פריטים" + channel_edit_name_slug_modal: + title: עריכת ערוץ input_placeholder: הוספת שם - description: נא להעניק שם מפורט אך קצר לערוץ שלך + slug_description: במזהה הייצוגי נעשה שימוש בכתובת במקום בשם הערוץ + name: שם הערוץ + slug: מזהה ייצוגי של הערוץ (רשות) channel_edit_description_modal: title: עריכת תיאור input_placeholder: הוספת תיאור @@ -276,9 +369,6 @@ he: prefix: "אל:" no_results: אין תוצאות selected_user_title: "ביטול בחירת %{username}" - channel_selector: - title: "קפיצה לערוץ" - no_channels: "אין ערוצים שתואמים לחיפוש שלך" channel: no_memberships: אין חברים בערוץ הזה no_memberships_found: לא נמצאו חברים @@ -288,27 +378,50 @@ he: many: "%{count} חברים" other: "%{count} חברים" create_channel: + threading: + label: "הפעלת שרשור" 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} קבוצות אוטומטית? + warning_1_group: + one: "להוסיף משתמש %{count} מהקבוצה %{group} אוטומטית?" + two: "להוסיף %{count} משתמשים מהקבוצה %{group} אוטומטית?" + many: "להוסיף %{count} משתמשים מהקבוצה %{group} אוטומטית?" + other: "להוסיף %{count} משתמשים מהקבוצה %{group} אוטומטית?" + warning_2_groups: + one: "להוסיף משתמש %{count} מהקבוצות %{group1} וגם %{group2} אוטומטית?" + two: "להוסיף %{count} משתמשים מהקבוצות %{group1} וגם %{group2} אוטומטית?" + many: "להוסיף %{count} משתמשים מהקבוצות %{group1} וגם %{group2} אוטומטית?" + other: "להוסיף %{count} משתמשים מהקבוצות %{group1} וגם %{group2} אוטומטית?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {להוסיף אוטומטית משתמש {userCount} מהקבוצה {groupName} ומ־{groupCount} נוספת?} + other {להוסיף אוטומטית {userCount} משתמשים מהקבוצה {groupName} ומ־{groupCount} נוספת?} + } + } + other { + { userCount, plural, + one {להוסיף אוטומטית משתמש {userCount} מהקבוצה {groupName} ומ־{groupCount} נוספות?} + other {להוסיף אוטומטית {userCount} משתמשים מהקבוצה {groupName} ומ־{groupCount} נוספות?} + } + } + } 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} קבוצות נוספות תהיה גישה לערוץ בהתאם להגדרות האבטחה + hint_1_group: 'למשתמשים ב־%{group} תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' + hint_2_groups: 'למשתמשים ב־%{group1} וב־%{group2} תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' + hint_multiple_groups: + one: 'למשתמשים ב־%{group} ובקבוצה %{count} נוספת תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' + two: 'למשתמשים ב־%{group} וב־%{count} קבוצות נוספות תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' + many: 'למשתמשים ב־%{group} וב־%{count} קבוצות נוספות תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' + other: 'למשתמשים ב־%{group} וב־%{count} קבוצות נוספות תהיה גישה לערוץ הזה בהתאם להגדרות האבטחה' create: "יצירת ערוץ" description: "תיאור (רשות)" name: "שם הערוץ" + slug: "מזהה ייצוגי של הערוץ (רשות)" title: "ערוץ חדש" type: "סוג" types: @@ -318,15 +431,26 @@ he: 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}:" + you_and_single_user: "הגבת יחד עם %{username} את האמוג׳י :%{emoji}:" + you_and_multiple_users: "הגבת יחד עם %{commaSeparatedUsernames} ו־%{username} את האמוג׳י :%{emoji}:" + you_multiple_users_and_more: + one: "הגבת יחד עם %{commaSeparatedUsernames} ועוד %{count} את האמוג׳י :%{emoji}:" + two: "הגבת יחד עם %{commaSeparatedUsernames} ו־%{count} נוספים את האמוג׳י :%{emoji}:" + many: "הגבת יחד עם %{commaSeparatedUsernames} ו־%{count} נוספים את האמוג׳י :%{emoji}:" + other: "הגבת יחד עם %{commaSeparatedUsernames} ו־%{count} נוספים את האמוג׳י :%{emoji}:" + single_user: "האמוג׳י שנוסף ע״י %{username} הוא :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} ו־%{username} הגיבו את האמוג׳י :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} ועוד %{count} הגיבו את האמוג׳י :%{emoji}:" + two: "%{commaSeparatedUsernames} ועוד %{count} הגיבו את האמוג׳י :%{emoji}:" + many: "%{commaSeparatedUsernames} ועוד %{count} הגיבו את האמוג׳י :%{emoji}:" + other: "%{commaSeparatedUsernames} ועוד %{count} הגיבו את האמוג׳י :%{emoji}:" composer: toggle_toolbar: "החלפת מצב סרגל כלים" italic_text: "טקסט נטוי" bold_text: "טקסט מודגש" code_text: "טקסט קוד" + send: "שליחה" quote: original_channel: 'נשלח במקור ב־%{channel}' copy_success: "ציטוט מהצ׳אט הועתק ללוח הגזירים" @@ -337,15 +461,19 @@ he: settings: channel_wide_mentions_label: "לאפשר אזכורים של ‎@all (כולם) ו־‎@here (כאן)" channel_wide_mentions_description: "לאפשר למשתמשים להודיע לכל החברים שב־‎#%{channel} באמצעות ‎@all (כולם) או רק לאלו שפעילים כרגע עם ‎@here (כאן)" + channel_threading_label: "שרשור" + channel_threading_description: "כאשר שרשור פעיל, תגובות להודעות בצ׳אט תיצורנה דיון נפרד, שיתקיים לצד הערוץ הראשי." auto_join_users_label: "להוסיף משתמשים אוטומטית" - auto_join_users_info: "לבדוק כל שעה אילו משתמשים היו פעילים ב־3 החודשים האחרונים ואם יש להם גישה לקטגוריה %{category}, להוסיף אותם לערוץ הזה." - enable_auto_join_users: "להוסיף אוטומטית את כל המשתמשים שהיו פעילים לאחרונה" + auto_join_users_info: "לבדוק כל שעה אילו משתמשים היו פעילים ב־3 החודשים האחרונים. להוסיף אותם לערוץ אם יש להם גישה לקטגוריה %{category}." + auto_join_users_info_no_category: "לבדוק כל שעה אילו משתמשים היו פעילים ב־3 החודשים האחרונים. להוסיף אותם לערוץ אם יש להם גישה לקטגוריה הנבחרת." auto_join_users_warning: "כל משתמש שאינו חבר בערוץ הזה ויש לו גישה לקטגוריה %{category} יצטרף. זה בסדר?" desktop_notification_level: "התראות שולחן עבודה" follow: "הצטרף" followed: "הצטרפו" mobile_notification_level: "התראות בדחיפה לנייד" mute: "השתקת ערוץ" + threading_enabled: "פעיל" + threading_disabled: "כבוי" muted_on: "פעילה" muted_off: "כבויה" notifications: "התראות" @@ -354,9 +482,13 @@ he: saved: "נשמר" unfollow: "עזוב" admin_title: "הנהלה" - retention_info: "היסטוריית הצ׳אט נשמרת למשך %{days} ימים." admin: title: "צ׳אט" + export_messages: + title: "ייצוא הודעות צ׳אט" + description: "הייצוא מוגבל כרגע ל־10000 ההודעות העדכניות ביותר ב־6 החודשים האחרונים." + create_export: "יצירת ייצוא" + export_has_started: "הייצוא החל. תישלח אליך הודעה פרטית כשהוא מוכן." direct_messages: title: "צ׳אט אישי" new: "יצירת צ׳אט אישי" @@ -428,8 +560,18 @@ he: many: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" other: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" retention_reminders: - public: "היסטוריית הערוץ נשמרת למשך %{days} ימים." - dm: "היסטוריית הצ׳אט האישית נשמרת למשך %{days} ימים." + public_none: "היסטוריית הערוץ נשמרת לעד." + public: + one: "היסטוריית הערוץ נשמרת למשך יום %{count}." + two: "היסטוריית הערוץ נשמרת למשך %{count} ימים." + many: "היסטוריית הערוץ נשמרת למשך %{count} ימים." + other: "היסטוריית הערוץ נשמרת למשך %{count} ימים." + dm_none: "היסטוריית הצ׳אט האישי נשמרת לעד." + dm: + one: "היסטוריית הצ׳אט האישי נשמרת למשך יום %{count}." + two: "היסטוריית הצ׳אט האישי נשמרת למשך %{count} ימים." + many: "היסטוריית הצ׳אט האישי נשמרת למשך %{count} ימים." + other: "היסטוריית הצ׳אט האישי נשמרת למשך %{count} ימים." flags: off_topic: "הודעה זו לא תואמת לדיון הנוכחי כפי שהוגדר בכותרת הערוץ וכנראה שצריך להעביר אותה." inappropriate: "הודעה זו מכילה תוכן שאדם מן השורה עשוי להחשיב כפוגעני, נצלני או מפר את הכללים המנחים את הקהילה שלנו." @@ -451,6 +593,37 @@ he: symbols: "סמלים" search_placeholder: "חיפוש לפי שם וכינוי של האמוג׳י…" no_results: "אין תוצאות" + thread: + title: "כותרת" + view_thread: הצגת שרשור + default_title: "שרשור" + replies: + one: "תגובה אחת" + two: "%{count} תגובות" + many: "%{count} תגובות" + other: "%{count} תגובות" + label: שרשור + close: "סגירת שרשור" + original_message: + started_by: "נפתח ע״י" + settings: "הגדרות" + last_reply: "תגובה אחרונה" + notifications: + regular: + title: "רגיל" + description: "תישלח אליך התראה אם @שמך מוזכר בשרשור הזה." + tracking: + title: "מעקב" + description: "מספר התגובות החדשות בשרשור הזה יופיע ברשימת השרשורים ובערוץ. תישלח אליך התראה אם הזכירו את @שמך בשרשור הזה." + participants_other_count: + one: "+%{count}" + two: "+%{count}" + many: "+%{count}" + other: "+%{count}" + threads: + open: "פתיחת שרשור" + list: "דיונים שמתקיימים כרגע" + none: "אין לך חלק באף אחד מהשרשורים בערוץ הזה." draft_channel_screen: header: "הודעה חדשה" cancel: "ביטול" @@ -495,9 +668,8 @@ he: transcript: view: "הצגת תמלול הודעות קודמות" types: - reviewable_chat_message: + chat_reviewable_message: title: "הודעת צ׳אט מסומנת" - flagged_by: "דוגל על ידי" keyboard_shortcuts_help: chat: title: "צ׳אט" @@ -510,6 +682,7 @@ he: composer_code: "%{shortcut} קוד (עורך בלבד)" drawer_open: "%{shortcut} פתיחת מגירת הצ׳אט" drawer_close: "%{shortcut} סגירת מגירת הצ׳אט" + mark_all_channels_read: "%{shortcut} סימון כל הערוצים כנקראו" topic_statuses: chat: help: "הצ׳אט מופעל בנושא הזה" @@ -530,3 +703,7 @@ he: two: "התראות צ׳אט - %{count} התראות שלא נקראו" many: "התראות צ׳אט - %{count} התראות שלא נקראו" other: "התראות צ׳אט - %{count} התראות שלא נקראו" + styleguide: + sections: + chat: + title: צ׳אט diff --git a/plugins/chat/config/locales/client.hr.yml b/plugins/chat/config/locales/client.hr.yml index 2bd64eca175..3e1ca76d507 100644 --- a/plugins/chat/config/locales/client.hr.yml +++ b/plugins/chat/config/locales/client.hr.yml @@ -15,6 +15,7 @@ hr: actions: chat_channel_status_change: "Status chat kanala je promijenjen" chat_channel_delete: "Chat kanal je izbrisan" + chat_auto_remove_membership: "Članstva su automatski uklonjena s kanala" api: scopes: descriptions: @@ -35,6 +36,10 @@ hr: chat_channels: "Kanali" move_to_channel: title: "Premještanje poruka na kanal" + instructions: + one: "Premještate %{count} poruku. Odaberite odredišni kanal. Poruka rezerviranog mjesta stvorit će se u kanalu %{channelTitle} kako bi označila da je ova poruka premještena. Imajte na umu da se lanci odgovora neće sačuvati u novom kanalu, a poruke u starom kanalu više se neće prikazivati kao odgovori na premještene poruke." + few: "Premještate %{count} poruke. Odaberite odredišni kanal. Poruka rezerviranog mjesta stvorit će se u kanalu %{channelTitle} kako bi označila da je ova poruka premještena. Imajte na umu da se lanci odgovora neće sačuvati u novom kanalu, a poruke u starom kanalu više se neće prikazivati kao odgovori na premještene poruke." + other: "Premještate %{count} poruka. Odaberite odredišni kanal. Poruka rezerviranog mjesta stvorit će se u kanalu %{channelTitle} kako bi označila da su te poruke premještene. Imajte na umu da se lanci odgovora neće sačuvati u novom kanalu, a poruke u starom kanalu više se neće prikazivati kao odgovori na premještene poruke." confirm_move: "Premjesti poruke" channel_settings: title: "Postavke kanala" @@ -56,7 +61,7 @@ hr: 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.

    " + instructions: "

    Briše %{name} kanal i povijest čavrljanja. Sve poruke i povezani podaci, kao što su reakcije i prijenosi, bit će trajno izbrisani. Ako želite sačuvati povijest kanala i poništiti ga, možda radije želite arhivirati kanal.

    Jeste li sigurni da želite trajno izbrisati kanal? Za potvrdu 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." @@ -65,8 +70,12 @@ hr: click_to_join: "Kliknite ovdje da biste vidjeli dostupne kanale." close: "Zatvori" collapse: "Sažmi ladicu za chat" + expand: "Proširi ladicu za čavrljanje" confirm_flag: "Jeste li sigurni da želite označiti poruku korisnika %{username}?" - deleted: "Poruka je izbrisana. [pogledaj]" + deleted: + one: "Poruka je izbrisana. [view]" + few: "%{count} poruke su izbrisane. [view all]" + other: "%{count} poruka je izbrisano. [view all]" delete: "Pobriši" edited: "uredio" muted: "utišano" @@ -75,6 +84,11 @@ hr: direct_message: "Također možete započeti osobni razgovor s jednim ili više korisnika." email_frequency: never: "Nikad" + header_indicator_preference: + title: "Prikaži indikator aktivnosti u zaglavlju" + all_new: "Sve nove poruke" + dm_and_mentions: "Izravne poruke i spominjanja" + never: "Nikad" enable: "Omogući chat" flag: "Označi zastavicom" flagged: "Ova poruka je označena za pregled" @@ -83,32 +97,87 @@ hr: in_reply_to: "U odgovoru na" heading: "Čet" join: "Pridružite se" - new_messages: "nove poruke" + last_visit: "posljednji posjet" + summarization: + title: "Sažmi poruke" + description: "Odaberite opciju u nastavku da biste saželi razgovor poslan tijekom željenog vremenskog okvira." + summarize: "Rezimirati" + since: + one: "Posljednji sat" + few: "Posljednja %{count} sata" + other: "Posljednjih %{count} sati" mention_warning: dismiss: "skloni" + cannot_see: "%{username} ne može pristupiti ovom kanalu i nije obaviješten." + cannot_see_multiple: + one: "%{username} i %{count} drugi korisnik ne mogu pristupiti ovom kanalu i nisu bili obaviješteni." + few: "%{username} i %{count} druga korisnika ne mogu pristupiti ovom kanalu i nisu bili obaviješteni." + other: "%{username} i %{count} drugih korisnika ne može pristupiti ovom kanalu i nisu bili obaviješteni." invitations_sent: one: "Poziv poslan" few: "Pozivnice poslane" other: "Pozivnice poslane" invite: "Pozovite na kanal" + without_membership: "%{username} se nije pridružio ovom kanalu." + without_membership_multiple: + one: "%{username} i još %{count} korisnik se nisu pridružili ovom kanalu." + few: "%{username} i još %{count} korisnika se nisu pridružili ovom kanalu." + other: "%{username} i još %{count} korisnika se nisu pridružili ovom kanalu." + group_mentions_disabled: "%{group_name} ne dopušta spominjanje." + group_mentions_disabled_multiple: + one: "%{group_name} i %{count} druga grupa ne dopuštaju spominjanje." + few: "%{group_name} i %{count} druge grupe ne dopuštaju spominjanje." + other: "%{group_name} i %{count} drugih grupa ne dopuštaju spominjanje." + too_many_members: "%{group_name} ima previše članova. Nitko nije obaviješten." + too_many_members_multiple: + one: "%{group_name} i %{count} druga grupa imaju previše članova. Nitko nije obaviješten." + few: "%{group_name} i %{count} druge grupe imaju previše članova. Nitko nije obaviješten." + other: "%{group_name} i %{count} drugih grupa imaju previše članova. Nitko nije obaviješten." groups: - users_limit: - one: "%{count} korisnik" - few: "%{count} korisnika" - other: "%{count} korisnika" + unreachable_1: "@%{group} ne dopušta spominjanje." + unreachable_2: "@%{group1} i @%{group2} ne dopuštaju spominjanje." + unreachable_multiple: + one: "@%{group} i %{count} druga grupa ne dopuštaju spominjanje." + few: "@%{group} i %{count} druge grupe ne dopuštaju spominjanje." + other: "@%{group} i %{count} drugih grupa ne dopuštaju spominjanje." + too_many_mentions: + one: "Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja." + few: "Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja." + other: "Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja." + too_many_mentions_admin: + one: 'Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja.' + few: 'Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja.' + other: 'Ova poruka premašuje ograničenje obavijesti od %{count} spominjanja.' aria_roles: header: "Zaglavlje chata" composer: "Skladatelj chata" + kicked_from_channel: "Više ne možete pristupiti ovom kanalu." + placeholder_channel: "Čavrljanje u %{channelName}" + placeholder_thread: "Čavrljanje u niti" + placeholder_users: "Čavrljaj s %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Kanal je arhiviran, trenutno ne možete slati nove poruke." + closed: "Kanal je zatvoren, trenutno ne možete slati nove poruke." + read_only: "Kanal je samo za čitanje, trenutno ne možete slati nove poruke." + placeholder_start_conversation: "Započnite razgovor s..." + placeholder_start_conversation_users: "Započnite razgovor s %{commaSeparatedUsernames}" reply: "Odgovor" edit: "Uredi" rebake_message: "Popravi HTML" bookmark_message: "Zabilješka" bookmark_message_edit: "Uredi oznaku" save: "Spremi" + return_to_threads_list: "Povratak na tekuće rasprave" + unread_threads_count: + one: "Imate %{count} nepročitanu raspravu" + few: "Imate %{count} nepročitane rasprave" + other: "Imate %{count} nepročitanih rasprava" sounds: none: "Ništa" title: "čet" title_capitalized: "Čet" + upload_to_channel: "Prenesi na %{title}" + upload_to_thread: "Prenesi u nit" exit: "natrag" channel_status: archive_failed: "Arhiviranje kanala nije uspjelo. %{completed}/%{total} poruke su arhivirane. tema odredišta. Pritisnite ponovno za pokušaj dovršetka arhiviranja." @@ -133,22 +202,62 @@ hr: about: O nama members: Članovi settings: Postavke + new_message_modal: + no_items: "Nema stvari" + channel_edit_name_slug_modal: + title: Uredi kanal + input_placeholder: Dodajte ime + name: Naziv kanala direct_message_creator: title: Nova poruka prefix: "Za:" create_channel: + auto_join_users: + warning_1_group: + one: "Automatski dodati %{count} korisnika iz %{group}?" + few: "Automatski dodati %{count} korisnika iz %{group}?" + other: "Automatski dodati %{count} korisnika iz %{group}?" + warning_2_groups: + one: "Automatski dodati %{count} korisnika iz %{group1} i %{group2}?" + few: "Automatski dodati %{count} korisnika iz %{group1} i %{group2}?" + other: "Automatski dodati %{count} korisnika iz %{group1} i %{group2}?" + choose_category: + hint_1_group: 'Korisnici u %{group} će imati pristup ovom kanalu u skladu sa sigurnosnim postavkama' + hint_2_groups: 'Korisnici u %{group1} i %{group2} će imati pristup ovom kanalu u skladu sa sigurnosnim postavkama' + hint_multiple_groups: + one: 'Korisnici u %{group} i %{count} drugoj grupi će imati pristup ovom kanalu u skladu sa sigurnosnim postavkama' + few: 'Korisnici u %{group} i %{count} druge grupe će imati pristup ovom kanalu u skladu sa sigurnosnim postavkama' + other: 'Korisnici u %{group} i %{count} drugih grupa će imati pristup ovom kanalu u skladu sa sigurnosnim postavkama' type: "Tip" types: category: "Kategorija" topic: "Tema" + reactions: + you_and_single_user: "Vi i %{username} ste reagirali s :%{emoji}:" + you_and_multiple_users: "Vi, %{commaSeparatedUsernames} i %{username} ste reagirali s :%{emoji}:" + you_multiple_users_and_more: + one: "Vi, %{commaSeparatedUsernames} i još %{count} ste reagirali s :%{emoji}:" + few: "Vi, %{commaSeparatedUsernames} i još njih %{count} ste reagirali s :%{emoji}:" + other: "Vi, %{commaSeparatedUsernames} i još njih %{count} ste reagirali s :%{emoji}:" + single_user: "%{username} je reagirao s :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} i %{username} reagirali su s :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} i još %{count} reagirali su s :%{emoji}:" + few: "%{commaSeparatedUsernames} i još %{count} reagirali su s :%{emoji}:" + other: "%{commaSeparatedUsernames} i još %{count} reagirali su s :%{emoji}:" composer: italic_text: "naglašen tekst" bold_text: "jaki text" + send: "Pošalji" notification_levels: never: "Nikad" settings: + auto_join_users_info: "Provjeri svaki sat koji su korisnici bili aktivni u posljednja 3 mjeseca. Dodaj ih na ovaj kanal ako imaju pristup kategoriji %{category}." + auto_join_users_info_no_category: "Provjeri svaki sat koji su korisnici bili aktivni u posljednja 3 mjeseca. Dodaj ih na ovaj kanal ako imaju pristup odabranoj kategoriji." follow: "Pridružite se" followed: "Prijavljen" + threading_enabled: "Omogućeno" + threading_disabled: "Onemogućeno" notifications: "Obavijest" preview: "Pregled" save: "Spremi" @@ -157,6 +266,11 @@ hr: admin_title: "Administrator" admin: title: "Čet" + export_messages: + title: "Izvezi poruke čavrljanja" + description: "Izvoz je trenutno ograničen na 10 000 najnovijih poruka u zadnjih 6 mjeseci." + create_export: "Stvori izvoz" + export_has_started: "Izvoz je počeo. Dobit ćete PM kad bude spreman." incoming_webhooks: back: "Natrag" description: "Opis" @@ -177,11 +291,51 @@ hr: title: "Prebaci u postojeću temu" new_message: title: "Premjestite u novu poruku" + retention_reminders: + public_none: "Povijest kanala čuva se na neodređeno vrijeme." + public: + one: "Povijest kanala čuva se %{count} dan." + few: "Povijest kanala čuva se %{count} dana." + other: "Povijest kanala čuva se %{count} dana." + dm_none: "Osobna povijest čavrljanja čuva se na neodređeno vrijeme." + dm: + one: "Osobna povijest čavrljanja čuva se %{count} dan." + few: "Osobna povijest čavrljanja čuva se %{count} dana." + other: "Osobna povijest čavrljanja čuva se %{count} dana." emoji_picker: objects: "Objekti" activities: "Aktivnosti" flags: "Oznake zastavicom" symbols: "Simboli" + thread: + title: "Naslov" + view_thread: Pogledaj nit + default_title: "Nit" + replies: + one: "%{count} odgovor" + few: "%{count} odgovora" + other: "%{count} odgovora" + label: Nit + close: "Zatvori nit" + original_message: + started_by: "Započeto od" + settings: "Postavke" + last_reply: "zadnji odgovor" + notifications: + regular: + title: "Normalno" + description: "Bit ćete obaviješteni ako netko spominje vaš @name u ovoj temi." + tracking: + title: "Praćenje" + description: "Broj novih odgovora za ovu nit bit će prikazan na popisu niti i kanalu. Bit ćete obaviješteni ako netko spominje Vaš @name u ovoj niti." + participants_other_count: + one: "+%{count}" + few: "+%{count}" + other: "+%{count}" + threads: + open: "Otvori nit" + list: "Tekuće rasprave" + none: "Ne sudjelujete ni u jednoj niti na ovom kanalu." draft_channel_screen: header: "Nova poruka" cancel: "Odustani" @@ -196,8 +350,14 @@ hr: label: Poruka review: types: - reviewable_chat_message: - flagged_by: "Označio" + chat_reviewable_message: + title: "Označena poruka čavrljanja" keyboard_shortcuts_help: chat: title: "Čet" + keyboard_shortcuts: + mark_all_channels_read: "%{shortcut} Označi sve kanale kao pročitane" + styleguide: + sections: + chat: + title: Čavrljanje diff --git a/plugins/chat/config/locales/client.hu.yml b/plugins/chat/config/locales/client.hu.yml index a7bd650036f..903c3bf7b4b 100644 --- a/plugins/chat/config/locales/client.hu.yml +++ b/plugins/chat/config/locales/client.hu.yml @@ -15,6 +15,7 @@ hu: actions: chat_channel_status_change: "A csevegőcsatorna állapota megváltozott" chat_channel_delete: "Csevegőcsatorna törölve" + chat_auto_remove_membership: "A tagságok automatikusan eltávolításra kerülnek a csatornákról" api: scopes: descriptions: @@ -64,7 +65,6 @@ hu: 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." @@ -74,8 +74,8 @@ hu: click_to_join: "Kattintson ide az elérhető csatornák megtekintéséhez." close: "Lezárás" collapse: "Csevegőfiók összecsukása" + expand: "Chat fiók bővítése" 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" @@ -90,6 +90,11 @@ hu: never: "Soha" title: "E-mail értesítések" when_away: "Csak ha távol van" + header_indicator_preference: + title: "Aktivitásjelző megjelenítése a fejlécben" + all_new: "Minden új üzenet" + dm_and_mentions: "Közvetlen üzenetek és említések" + never: "Soha" enable: "Csevegés engedélyezése" flag: "Jelölés" emoji: "Emodzsi beszúrása" @@ -99,22 +104,21 @@ hu: in_reply_to: "Válaszul erre:" heading: "Csevegés" join: "Belépés" - new_messages: "új üzenetek" + last_visit: "utolsó látogatás" + summarization: + summarize: "Összefoglalás" mention_warning: dismiss: "elvetés" invitations_sent: one: "Meghívó elküldve" other: "Meghívók elküldve" invite: "Meghívás a csatornára" - groups: - users_limit: - one: "%{count} felhasználó" - other: "%{count} felhasználó" 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." + kicked_from_channel: "Ezt a csatornát már nem tudja elérni." 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" @@ -126,10 +130,8 @@ hu: 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_channel: "Csevegés %{channelName}" 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" @@ -145,7 +147,6 @@ hu: 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" @@ -158,6 +159,7 @@ hu: title: "csevegés" title_capitalized: "Csevegés" upload: "Fájl csatolása" + upload_to_channel: "Feltöltés %{title} címre" uploaded_files: one: "%{count} fájl" other: "%{count} fájl" @@ -199,8 +201,10 @@ hu: about: Névjegy members: Tagok settings: Beállítások - channel_edit_name_modal: + channel_edit_name_slug_modal: + title: Csatorna szerkesztése input_placeholder: Név hozzáadása + name: Csatorna neve channel_edit_description_modal: title: Leírás szerkesztése input_placeholder: Leírás hozzáadása @@ -210,9 +214,6 @@ hu: 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 @@ -222,10 +223,6 @@ hu: 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…" @@ -242,15 +239,12 @@ hu: 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" + send: "Küldés" quote: copy_success: "Csevegési idézet a vágólapra másolva" notification_levels: @@ -260,12 +254,13 @@ hu: settings: channel_wide_mentions_label: "Engedélyezve az @all és @here említések" auto_join_users_label: "Felhasználók automatikus hozzáadása" - enable_auto_join_users: "Az összes nemrégiben aktív felhasználó automatikus hozzáadá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" + threading_enabled: "Engedélyezed" + threading_disabled: "Kikapcsolt" muted_on: "Be" muted_off: "Ki" notifications: "Értesítések" @@ -336,8 +331,8 @@ hu: 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." + public_none: "A csatornaelőzmények korlátlan ideig megőrződnek." + dm_none: "A személyes csevegési előzmények korlátlan ideig megőrződnek." flagging: action: "Üzenet megjelölése" emoji_picker: @@ -352,6 +347,24 @@ hu: flags: "Zászlók" symbols: "Szimbólumok" no_results: "Nincs találat" + thread: + title: "Cím" + view_thread: Téma megtekintése + default_title: "Szál" + replies: + one: "%{count} válasz" + other: "%{count} válasz" + label: Szál + settings: "Beállítások" + last_reply: "utolsó válasz" + notifications: + regular: + title: "Normális" + tracking: + title: "Követés" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Új üzenet" cancel: "Visszavon" @@ -392,11 +405,6 @@ hu: 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" diff --git a/plugins/chat/config/locales/client.hy.yml b/plugins/chat/config/locales/client.hy.yml index 7e631debd6d..3e05f87a772 100644 --- a/plugins/chat/config/locales/client.hy.yml +++ b/plugins/chat/config/locales/client.hy.yml @@ -21,14 +21,13 @@ hy: joined: "միացել է" email_frequency: never: "Երբեք" + header_indicator_preference: + never: "Երբեք" flag: "Դրոշակավորել" join: "Միանալ" + last_visit: "վերջին այցելությունը" mention_warning: dismiss: "չեղարկել" - groups: - users_limit: - one: "%{count} օգտատեր" - other: "%{count} օգտատեր" reply: "Պատասխանել" edit: "Խմբագրել" rebake_message: "Վերակառուցել HTML-ը" @@ -57,6 +56,8 @@ hy: about: Մասին members: Անդամներ settings: Կարգավորումներ + new_message_modal: + no_items: "Տարրեր չկան" direct_message_creator: title: Նոր Հաղորդագրություն prefix: "Ում:" @@ -68,11 +69,13 @@ hy: composer: italic_text: "շեղ տեքստ" bold_text: "թավ տեքստ" + send: "Ուղարկել" notification_levels: never: "Երբեք" settings: follow: "Միանալ" followed: "Միացել է" + threading_disabled: "Անջատված" notifications: "Ծանուցումներ" preview: "Նախադիտում" save: "Պահպանել" @@ -104,6 +107,18 @@ hy: activities: "Ակտիվություն" flags: "Դրոշակներ" symbols: "Նշաններ" + thread: + title: "Վերնագիր" + replies: + one: "%{count} պատասխան" + other: "%{count} պատասխան" + settings: "Կարգավորումներ" + last_reply: "վերջին պատասխանը" + notifications: + regular: + title: "Նորմալ" + tracking: + title: "Հետևում Եմ" draft_channel_screen: header: "Նոր Հաղորդագրություն" cancel: "Չեղարկել" @@ -115,7 +130,3 @@ hy: 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 index 0962f1111ed..7972f01aee2 100644 --- a/plugins/chat/config/locales/client.id.yml +++ b/plugins/chat/config/locales/client.id.yml @@ -38,8 +38,6 @@ id: browse_all_channels: "Jelajahi semua kanal" move_to_channel: title: "Pindahkan pesan ke kanal" - instructions: - other: "Anda memindahkan %{count} pesan. Pilih kanal tujuan. Pesan placeholder akan dibuat di kanal %{channelTitle} untuk menunjukkan bahwa pesan ini telah dipindahkan." confirm_move: "Pindahkan Pesan" channel_settings: title: "Pengaturan kanal" @@ -55,17 +53,24 @@ id: joined: "joined" email_frequency: never: "Tidak pernah" + header_indicator_preference: + all_new: "Semua Pesan Baru" + never: "Tidak pernah" join: "Gabung" + summarization: + title: "Meringkas pesan" + description: "Pilih opsi di bawah untuk meringkas percakapan yang dikirim selama jangka waktu yang diinginkan." + summarize: "Meringkas" + since: + other: "%{count} jam terakhir" mention_warning: dismiss: "bubar" - groups: - users_limit: - other: "%{count} pengguna" reply: "Balas" edit: "Ubah" bookmark_message: "Penandaan" bookmark_message_edit: "Edit Bookmark" save: "Simpan" + return_to_threads_list: "Kembali ke diskusi yang sedang berlangsung" sounds: none: "Tidak ada" channel_status: @@ -82,10 +87,23 @@ id: about: Tentang members: Anggota settings: Pengaturan + new_message_modal: + title: Kirim pesan + add_user_long: shift + click atau shift + enterTambahkan @%{username} + add_user_short: Tambahkan pengguna + open_channel: Buka saluran + default_search_placeholder: "#a-channel, @somebody atau apapun" + default_channel_search_placeholder: "#a-channel" + default_user_search_placeholder: "@somebody" + user_search_placeholder: "...menambahkan lebih banyak pengguna" + disabled_user: "telah menonaktifkan obrolan" + no_items: "Tidak ada item" direct_message_creator: title: Pesan Baru prefix: "Kepada:" create_channel: + threading: + label: "Aktifkan threading" type: "Tipe" types: category: "Kategori" @@ -93,13 +111,23 @@ id: notification_levels: never: "Tidak pernah" settings: + channel_threading_label: "Threading" + channel_threading_description: "Ketika threading diaktifkan, balasan ke pesan obrolan akan membuat percakapan terpisah, yang akan ada di samping saluran utama." follow: "Gabung" followed: "Joined" + threading_enabled: "Diaktifkan" + threading_disabled: "Dinonaktifkan" notifications: "Pemberitahuan" save: "Simpan" saved: "Tersimpan" unfollow: "Keluar" admin_title: "Admin" + admin: + export_messages: + title: "Ekspor pesan obrolan" + description: "Ekspor saat ini dibatasi hingga 10.000 pesan terbaru dalam 6 bulan terakhir." + create_export: "Buat ekspor" + export_has_started: "Ekspor telah dimulai. Anda akan menerima PM jika sudah siap." incoming_webhooks: delete: "Hapus" name: "Name" @@ -114,6 +142,27 @@ id: emoji_picker: objects: "Objek" flags: "Flags" + thread: + title: "Judul" + default_title: "Utas" + replies: + other: "%{count} balasan" + original_message: + started_by: "Dimulai oleh" + settings: "Pengaturan" + last_reply: "balasan terakhir" + notifications: + regular: + title: "Normal" + description: "Anda akan diberi tahu jika seseorang menyebut @nama Anda di utas ini." + tracking: + title: "Pelacakan" + description: "Jumlah balasan baru untuk utas ini akan ditampilkan di daftar utas dan kanal. Anda akan diberi tahu jika seseorang menyebut @nama Anda di utas ini." + participants_other_count: + other: "+%{count}" + threads: + list: "Diskusi yang sedang berlangsung" + none: "Anda tidak berpartisipasi dalam utas apa pun di kanal ini." draft_channel_screen: header: "Pesan Baru" cancel: "Batal" @@ -123,7 +172,7 @@ id: fields: message: label: pesan - review: - types: - reviewable_chat_message: - flagged_by: "Dipanji Oleh" + styleguide: + sections: + chat: + title: Obrolan diff --git a/plugins/chat/config/locales/client.it.yml b/plugins/chat/config/locales/client.it.yml index 23c5729c2d2..774d70554fb 100644 --- a/plugins/chat/config/locales/client.it.yml +++ b/plugins/chat/config/locales/client.it.yml @@ -15,6 +15,7 @@ it: actions: chat_channel_status_change: "Lo stato del canale di chat è cambiato" chat_channel_delete: "Canale di chat eliminato" + chat_auto_remove_membership: "Membri automaticamente rimossi dai canali" api: scopes: descriptions: @@ -39,8 +40,8 @@ it: 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." + one: "Stai spostando il messaggio %{count} . Seleziona un canale di destinazione. Verrà creato un messaggio segnaposto nel canale %{channelTitle} per indicare che questo messaggio è stato spostato. Tieni presente che le catene di risposta non verranno mantenute nel nuovo canale e i messaggi nel vecchio canale non verranno più visualizzati come risposta ai messaggi spostati." + 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. Tieni presente che le catene di risposta non verranno mantenute nel nuovo canale e i messaggi nel vecchio canale non verranno più visualizzati come risposta ai messaggi spostati." confirm_move: "Sposta messaggi" channel_settings: title: "Impostazioni del canale" @@ -81,8 +82,11 @@ it: click_to_join: "Fai clic qui per visualizzare i canali disponibili." close: "Chiudi" collapse: "Comprimi il cassetto della chat" + expand: "Espandi il pannello della chat" confirm_flag: "Vuoi segnalare il messaggio di %{username}?" - deleted: "Un messaggio è stato eliminato. [visualizza]" + deleted: + one: "Un messaggio è stato cancellato. [view]" + other: "%{count} messaggi sono stati eliminati. [visualizza tutti]" hidden: "Un messaggio è stato nascosto. [visualizza]" delete: "Elimina" edited: "modificato" @@ -97,6 +101,11 @@ it: never: "Mai" title: "Notifiche via e-mail" when_away: "Solo quando non sono collegato" + header_indicator_preference: + title: "Mostra l'indicatore di attività nell'intestazione" + all_new: "Tutti i nuovi messaggi" + dm_and_mentions: "Messaggi diretti e menzioni" + never: "Mai" enable: "Abilita chat" flag: "Segnala" emoji: "Inserisci emoji" @@ -106,53 +115,57 @@ it: in_reply_to: "In risposta a" heading: "Chat" join: "Partecipa" - new_messages: "nuovi messaggi" + last_visit: "ultima visita" + summarization: + title: "Riassumi i messaggi" + description: "Seleziona un'opzione qui sotto per riepilogare la conversazione inviata nel periodo di tempo desiderato." + summarize: "Riassumi" + since: + one: "Ultima ora" + other: "Ultime %{count} ore" mention_warning: dismiss: "ignora" - cannot_see: - one: "%{username} non può accedere a questo canale e non è stato avvisato." - other: "%{username} e %{others} non possono accedere a questo canale e non sono stati avvisati." + cannot_see: "%{username} non può accedere a questo canale e non è stato avvisato." + cannot_see_multiple: + one: "%{username} e %{count} altro utente non possono accedere a questo canale e non sono stati avvisati." + other: "%{username} e altri %{count} utenti non possono accedere a questo canale e non sono stati avvisati." invitations_sent: one: "Invito inviato" other: "Inviti inviati" invite: "Invita al canale" - without_membership: - one: "%{username} non ha partecipato a questo canale." - other: "%{username} e %{others} non hanno partecipato a questo canale." - group_mentions_disabled: - one: "%{group_name} non consente menzioni" - other: "%{group_name} e %{others} non consentono menzioni" - too_many_members: - one: "%{group_name} ha troppi membri. Nessuno è stato avvisato" - other: "%{group_name} e %{others} hanno troppi membri. Nessuno è stato avvisato" - warning_multiple: - one: "%{count} altro" - other: "%{count} altri" + without_membership: "%{username} non ha partecipato a questo canale." + without_membership_multiple: + one: "%{username} e %{count} altro utente non si sono uniti a questo canale." + other: "%{username} e altri %{count} utenti non si sono uniti a questo canale." + group_mentions_disabled: "%{group_name} non consente menzioni." + group_mentions_disabled_multiple: + one: "%{group_name} e %{count} altro gruppo non consentono menzioni." + other: "%{group_name} e %{count} altri gruppi non consentono menzioni." + too_many_members: "%{group_name} ha troppi membri. Nessuno è stato avvisato." + too_many_members_multiple: + one: "%{group_name} e %{count} altro gruppo hanno troppi membri. Nessuno è stato notificato." + other: "%{group_name} e %{count} altri gruppi hanno troppi membri. Nessuno è stato notificato." groups: header: some: "Alcuni utenti non riceveranno notifiche" all: "Nessuno verrà avvisato" - unreachable: - one: "@%{group} non consente menzioni" - other: "@%{group} e @%{group_2} non consentono menzioni" - unreachable_multiple: "@%{group} e altri %{count} non consentono menzioni" - too_many_members: - one: "La menzione di @%{group} supera il %{notification_limit} di %{limit}" - other: "Menzionando sia @%{group} che @%{group_2} si supera il %{notification_limit} di %{limit}" - too_many_members_multiple: "Questi %{count} gruppi superano il %{notification_limit} di %{limit}" - users_limit: - one: "%{count} utente" - other: "%{count} utenti" - notification_limit: "limite di notifica" - too_many_mentions: "Questo messaggio supera il %{notification_limit} di %{limit}" - mentions_limit: - one: "%{count} menzione" - other: "%{count} menzioni" + unreachable_1: "@%{group} non consente menzioni." + unreachable_2: "@%{group1} e @%{group2} non consentono menzioni." + unreachable_multiple: + one: "@%{group} e %{count} altro gruppo non consentono menzioni." + other: "@%{group} e %{count} altri gruppi non consentono menzioni." + too_many_mentions: + one: "Questo messaggio supera il limite di notifica di %{count} menzione." + other: "Questo messaggio supera il limite di notifica di %{count} menzioni." + too_many_mentions_admin: + one: 'Questo messaggio supera il limite di notifiche di %{count} menzione.' + other: 'Questo messaggio supera il limite di notifiche di %{count} menzioni.' 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." + kicked_from_channel: "Non puoi più accedere a questo 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" @@ -164,10 +177,16 @@ it: 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_channel: "Chatta in %{channelName}" + placeholder_thread: "Chat nel thread" + placeholder_users: "Chatta con %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Il canale è archiviato, non puoi inviare nuovi messaggi in questo momento." + closed: "Il canale è chiuso, non puoi inviare nuovi messaggi in questo momento." + read_only: "Il canale è in sola lettura, non è possibile inviare nuovi messaggi in questo momento." placeholder_silenced: "In questo momento non puoi inviare messaggi." - placeholder_start_conversation: Inizia una conversazione con %{usernames} + placeholder_start_conversation: "Inizia una conversazione con..." + placeholder_start_conversation_users: "Inizia una conversazione con %{commaSeparatedUsernames}" remove_upload: "Rimuovi file" react: "Reagisci con delle emoji" reply: "Rispondi" @@ -183,8 +202,11 @@ it: restore: "Ripristina messaggio eliminato" save: "Salva" select: "Seleziona" - silence: "Silenzia utente" return_to_list: "Torna all'elenco dei canali" + return_to_threads_list: "Torna alle discussioni in corso" + unread_threads_count: + one: "Hai %{count} discussione non lette" + other: "Hai %{count} discussioni non lette" scroll_to_bottom: "Scorri fino in fondo" scroll_to_new_messages: "Vedi nuovi messaggi" sound: @@ -196,6 +218,8 @@ it: title: "chat" title_capitalized: "Chat" upload: "Allega un file" + upload_to_channel: "Carica su %{title}" + upload_to_thread: "Carica nel thread" uploaded_files: one: "%{count} file" other: "%{count} file" @@ -239,10 +263,23 @@ it: about: Informazioni members: Membri settings: Impostazioni - channel_edit_name_modal: - title: Modifica nome + new_message_modal: + title: Invia messaggio + add_user_long: MAIUSC + clic o MAIUSC + INVIOAggiunge @%{username} + add_user_short: Aggiungi utente + open_channel: Apri canale + default_search_placeholder: "#uncanale, @qualcuno o altro" + default_channel_search_placeholder: "#un-canale" + default_user_search_placeholder: "@qualcuno" + user_search_placeholder: "...aggiungi altri utenti" + disabled_user: "ha disabilitato la chat" + no_items: "Nessun elemento" + channel_edit_name_slug_modal: + title: Modifica canale input_placeholder: Aggiungi un nome - description: Assegna un breve nome descrittivo al tuo canale + slug_description: Lo slug del canale viene utilizzato nell'URL al posto del nome del canale + name: Nome del canale + slug: Slug canale (opzionale) channel_edit_description_modal: title: Modifica descrizione input_placeholder: Aggiungi una descrizione @@ -252,9 +289,6 @@ it: 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 @@ -262,23 +296,44 @@ it: one: "%{count} membro" other: "%{count} membri" create_channel: + threading: + label: "Abilita il threading" 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}? + warning_1_group: + one: "Aggiungere automaticamente %{count} utente da %{group}?" + other: "Aggiungere automaticamente %{count} utenti da %{group}?" + warning_2_groups: + one: "Aggiungere automaticamente %{count} utente da %{group1} e %{group2}?" + other: "Aggiungere automaticamente %{count} utenti da %{group1} e %{group2}?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {Aggiungere automaticamente {userCount} utente di {groupName} e {groupCount} altro gruppo?} + other {Aggiungere automaticamente {userCount} utenti di {groupName} e {groupCount} altro gruppo ?} + } + } + other { + { userCount, plural, + one {Aggiungere automaticamente {userCount} utente di {groupName} e di {groupCount} altri gruppi?} + other {Aggiungere automaticamente {userCount} utenti di {groupName} e di {groupCount} altri gruppi?} + } + } + } 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 + hint_1_group: 'Gli utenti in %{group} avranno accesso a questo canale in base alle impostazioni di sicurezza' + hint_2_groups: 'Gli utenti in %{group1} e %{group2} avranno accesso a questo canale in base alle impostazioni di sicurezza' + hint_multiple_groups: + one: 'Gli utenti in %{group} e un altro gruppo avranno accesso a questo canale in base alle impostazioni di sicurezza' + other: 'Gli utenti in %{group} 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" + slug: "Slug canale (opzionale)" title: "Nuovo canale" type: "Tipo" types: @@ -288,15 +343,22 @@ it: 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}:" + you_and_single_user: "Tu e %{username} avete reagito con :%{emoji}:" + you_and_multiple_users: "Tu, %{commaSeparatedUsernames} e %{username} avete reagito con :%{emoji}:" + you_multiple_users_and_more: + one: "Tu, %{commaSeparatedUsernames} e %{count} altro avete reagito con :%{emoji}:" + other: "Tu, %{commaSeparatedUsernames} e altri %{count} avete reagito con :%{emoji}:" + single_user: "%{username} ha reagito con :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} e %{username} hanno reagito con :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} e %{count} altro avete reagito con :%{emoji}:" + other: "%{commaSeparatedUsernames} e altri %{count} 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" + send: "Invia" quote: original_channel: 'Inviato in origine in #%{channel}' copy_success: "Citazione della chat copiata negli appunti" @@ -307,15 +369,19 @@ it: settings: channel_wide_mentions_label: "Consenti le menzioni @all e @here" channel_wide_mentions_description: "Consenti agli utenti di avvisare tutti i membri di #%{channel} con @all o solo quelli che sono attivi al momento con @here" + channel_threading_label: "Threading" + channel_threading_description: "Quando il threading è abilitato, le risposte a un messaggio di chat creeranno una conversazione separata, che esisterà accanto al canale principale." auto_join_users_label: "Aggiunta automatica utenti" - auto_join_users_info: "Controlla ogni ora quali utenti sono stati attivi negli ultimi 3 mesi e, se hanno accesso alla categoria %{category}, aggiungili a questo canale." - enable_auto_join_users: "Aggiungi automaticamente tutti gli utenti attivi di recente" + auto_join_users_info: "Controlla ogni ora quali utenti sono stati attivi negli ultimi 3 mesi. Aggiungili a questo canale se hanno accesso alla categoria %{category}." + auto_join_users_info_no_category: "Controlla ogni ora quali utenti sono stati attivi negli ultimi tre mesi. Aggiungili a questo canale se hanno accesso alla categoria selezionata." 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" + threading_enabled: "Abilitato" + threading_disabled: "Disabilitato" muted_on: "On" muted_off: "Off" notifications: "Notifiche" @@ -324,9 +390,13 @@ it: saved: "Salvato" unfollow: "Esci" admin_title: "Amministratore" - retention_info: "La cronologia delle chat verrà salvata per %{days} giorni." admin: title: "Chat" + export_messages: + title: "Esporta i messaggi della chat" + description: "L'esportazione è attualmente limitata a 10000 messaggi più recenti negli ultimi 6 mesi." + create_export: "Crea esportazione" + export_has_started: "L'esportazione è iniziata. Riceverai un messaggio personale quando sarà pronto." direct_messages: title: "Chat personale" new: "Crea una chat personale" @@ -390,8 +460,14 @@ it: 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." + public_none: "La cronologia del canale viene conservata a tempo indeterminato." + public: + one: "La cronologia del canale è conservata per %{count} giorno." + other: "La cronologia del canale è conservata per %{count} giorni." + dm_none: "La cronologia della chat personale viene conservata a tempo indeterminato." + dm: + one: "La cronologia della chat personale è conservata per %{count} giorno." + other: "La cronologia della chat personale è conservata per %{count} giorni." 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." @@ -413,6 +489,33 @@ it: symbols: "Simboli" search_placeholder: "Cerca per nome emoji e alias..." no_results: "Nessun risultato" + thread: + title: "Titolo" + view_thread: Visualizza thread + default_title: "Thread" + replies: + one: "%{count} risposta" + other: "%{count} risposte" + label: Thread + close: "Chiudi Thread" + original_message: + started_by: "Iniziato da" + settings: "Impostazioni" + last_reply: "ultima risposta" + notifications: + regular: + title: "Normale" + description: "Riceverai notifiche se qualcuno menziona il tuo @nome o ti risponde." + tracking: + title: "Contenuto seguito" + description: "Il conteggio delle nuove risposte a questa discussione sarà visualizzato nell'elenco delle discussioni e nel canale. Sarai avvisato se qualcuno menziona il tuo @nome in questa discussione." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Apri Discussione" + list: "Discussioni in corso" + none: "Non stai partecipando a nessun thread in questo canale." draft_channel_screen: header: "Nuovo messaggio" cancel: "Annulla" @@ -457,9 +560,8 @@ it: transcript: view: "Visualizza la trascrizione dei messaggi precedenti" types: - reviewable_chat_message: + chat_reviewable_message: title: "Messaggio di chat segnalato" - flagged_by: "Segnalato da" keyboard_shortcuts_help: chat: title: "Chat" @@ -472,6 +574,7 @@ it: composer_code: "%{shortcut} Codice (solo compositore)" drawer_open: "%{shortcut} Apri il cassetto della chat" drawer_close: "%{shortcut} Chiudi il cassetto della chat" + mark_all_channels_read: "%{shortcut} Contrassegna tutti i canali come letti" topic_statuses: chat: help: "La chat è abilitata per questo argomento" @@ -490,3 +593,7 @@ it: chat_notifications_with_unread: one: "Notifiche chat - %{count} notifica non letta" other: "Notifiche chat - %{count} notifiche non lette" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.ja.yml b/plugins/chat/config/locales/client.ja.yml index 0b2d430755f..1f7b82706dd 100644 --- a/plugins/chat/config/locales/client.ja.yml +++ b/plugins/chat/config/locales/client.ja.yml @@ -38,8 +38,6 @@ ja: browse_all_channels: "すべてのチャンネルを閲覧する" move_to_channel: title: "メッセージをチャンネルに移動する" - instructions: - other: "%{count} 件のメッセージを移動しようとしています。移動先のチャンネルを選択してください。%{channelTitle} チャンネルに、これらのメッセージが移動されたことを示すプレースホルダーメッセージが作成されます。" confirm_move: "メッセージを移動" channel_settings: title: "チャンネルの設定" @@ -70,7 +68,6 @@ ja: instructions: "チャンネルを閉鎖すると、スタッフ以外のユーザーが新しいメッセージの送信や既存のメッセージの編集を行えなくなります。このチャンネルを閉鎖してもよろしいですか?" channel_delete: title: "チャンネルを削除" - instructions: "

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

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

    " confirm: "結果を理解し、チャンネルを削除します" confirm_channel_name: "チャンネル名を入力してください" process_started: "チャンネルの削除処理が開始しました。このモーダルは間もなく閉じられ、削除されたチャンネルがどこにも表示されなくなります。" @@ -81,7 +78,6 @@ ja: close: "閉じる" collapse: "チャットドロワーを折りたたむ" confirm_flag: "%{username} のメッセージを通報してよろしいですか?" - deleted: "メッセージは削除されました。[view]" hidden: "メッセージが非表示でした。[view]" delete: "削除" edited: "編集済み" @@ -96,6 +92,8 @@ ja: never: "なし" title: "メール通知" when_away: "退席中の時のみ" + header_indicator_preference: + never: "なし" enable: "チャットを有効にする" flag: "通報する" emoji: "絵文字を挿入する" @@ -105,38 +103,18 @@ ja: in_reply_to: "返信先" heading: "チャット" join: "参加" - new_messages: "新しいメッセージ" + last_visit: "最後の訪問" + summarization: + summarize: "要約" mention_warning: dismiss: "閉じる" - cannot_see: - other: "%{username} と他 %{others} 人はこのチャンネルにアクセスできないため通知されませんでした。" invitations_sent: other: "招待状を送信しました" invite: "チャンネルに招待する" - without_membership: - other: "%{username} と他 %{others} 人はこのチャンネルに参加していません。" - group_mentions_disabled: - other: "%{group_name} と他 %{others} グループはメンションを許可していません" - too_many_members: - other: "%{group_name} と他 %{others} グループのメンバーが多すぎます。誰にも通知されませんでした" - warning_multiple: - other: "他 %{count} 件" groups: header: some: "一部のユーザーには通知されません" all: "誰にも通知されません" - unreachable: - other: "@%{group} と @%{group_2} はメンションを許可していません" - unreachable_multiple: "@%{group} と他 %{count} グループはメンションを許可していません" - too_many_members: - other: "@%{group} と @%{group_2} の両方をメンションすると、%{limit}の%{notification_limit}を超過します" - too_many_members_multiple: "これらの %{count} グループは、%{limit}の%{notification_limit}を超過しています" - users_limit: - other: "%{count} 人のユーザー" - notification_limit: "通知制限" - too_many_mentions: "このメッセージは %{limit}の%{notification_limit}を超過しています" - mentions_limit: - other: "%{count} メンション" aria_roles: header: "チャットヘッダー" composer: "チャット作成ツール" @@ -153,10 +131,9 @@ ja: close_full_page: "全画面チャットを閉じる" open_message: "メッセージをチャットで開く" placeholder_self: "メモを書き留める" - placeholder_others: "%{messageRecipient} とチャット" - placeholder_new_message_disallowed: "チャンネルは %{status} です。現在、新しいメッセージを送信できません。" + placeholder_users: "%{commaSeparatedNames} とチャット" placeholder_silenced: "現在、メッセージを送信できません。" - placeholder_start_conversation: '%{usernames} と会話を始める' + placeholder_start_conversation_users: "%{commaSeparatedUsernames} と会話を始める" remove_upload: "ファイルを削除する" react: "絵文字でリアクション" reply: "返信" @@ -172,7 +149,6 @@ ja: restore: "削除されたメッセージを復元する" save: "保存" select: "選択" - silence: "ユーザーを投稿禁止にする" return_to_list: "チャンネルリストに戻る" scroll_to_bottom: "一番下にスクロール" scroll_to_new_messages: "新しいメッセージを見る" @@ -225,6 +201,10 @@ ja: about: 紹介 members: メンバー settings: 設定 + new_message_modal: + no_items: "項目なし" + channel_edit_name_slug_modal: + name: チャンネル名 channel_edit_description_modal: title: 説明を編集する input_placeholder: 説明を追加する @@ -234,9 +214,6 @@ ja: prefix: "宛先:" no_results: 結果がありません selected_user_title: "%{username} を選択解除" - channel_selector: - title: "チャンネルにジャンプ" - no_channels: "検索に一致するチャンネルはありません" channel: no_memberships: このチャンネルにはメンバーがいません no_memberships_found: メンバーが見つかりません @@ -245,16 +222,12 @@ ja: 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} 人を自動的に追加しますか?' + warning_2_groups: + other: "%{group1} と %{group2} から %{count} 人のユーザーを自動的に追加しますか?" choose_category: label: "カテゴリを選択する" none: "1 つ選択してください..." default_hint: %{category} のセキュリティ設定に移動し、アクセス権を管理します - hint_groups: - other: '%{hint} と %{hint_2} のユーザーは、セキュリティ設定に従って、このチャンネルにアクセスできます。' - hint_multiple_groups: '%{hint_1} と他 %{count} 個のグループのユーザーは、セキュリティ設定に従って、このチャンネルにアクセスできます。' create: "チャンネルを作成" description: "説明 (オプション)" name: "チャンネル名" @@ -267,15 +240,17 @@ ja: 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}: でリアクションしました" + you_multiple_users_and_more: + other: "あなた、%{commaSeparatedUsernames} と他 %{count} 人が :%{emoji}: でリアクションしました" + single_user: "%{username} が :%{emoji}: でリアクションしました" + multiple_users_and_more: + other: "%{commaSeparatedUsernames} と他 %{count} 人が :%{emoji}: でリアクションしました" composer: toggle_toolbar: "ツールバーの切り替え" italic_text: "強調文字" bold_text: "太字" code_text: "コードテキスト" + send: "送信" quote: original_channel: '最初に %{channel} で送信されました' copy_success: "チャットの引用をクリップボードにコピーしました" @@ -287,14 +262,13 @@ ja: channel_wide_mentions_label: "@all と @here メンションを許可する" channel_wide_mentions_description: "@all で #%{channel} の全メンバーに通知するか、@here で現在アクティブなメンバーのみに通知することを許可します" auto_join_users_label: "自動的にユーザーを追加する" - auto_join_users_info: "過去 3 か月にアクティブだったユーザーを 1 時間ごとに確認し、それらのユーザーが %{category} カテゴリにアクセスできる場合はこのチャンネルに追加します。" - enable_auto_join_users: "最近アクティブなユーザーをすべて自動的に追加する" auto_join_users_warning: "このチャンネルのメンバーでなく、%{category} カテゴリにアクセスできるすべてのユーザーが参加します。よろしいですか?" desktop_notification_level: "デスクトップ通知" follow: "参加" followed: "参加中" mobile_notification_level: "モバイルプッシュ通知" mute: "チャンネルをミュート" + threading_disabled: "無効" muted_on: "オン" muted_off: "オフ" notifications: "通知" @@ -303,7 +277,6 @@ ja: saved: "保存済み" unfollow: "退出" admin_title: "管理者" - retention_info: "チャット履歴は %{days} 日間保存されます。" admin: title: "チャット" direct_messages: @@ -365,8 +338,10 @@ ja: many_users: other: "%{commaSeparatedUsernames} と他 %{count} 人が入力中です" retention_reminders: - public: "チャンネル履歴は %{days} 日間保持されます。" - dm: "パーソナルチャット履歴は %{days} 日間保持されます。" + public: + other: "チャンネル履歴は %{count} 日間保持されます。" + dm: + other: "パーソナルチャット履歴は %{count} 日間保持されます。" flags: off_topic: "このメッセージは、チャンネルのタイトルで定義された現在のディスカッションとは関係がないため、おそらく別の場所に移動する必要があります。" inappropriate: "このメッセージには、合理的な人が攻撃的、虐待的、またはコミュニティーガイドラインに違反すると見なすコンテンツが含まれます。" @@ -388,6 +363,19 @@ ja: symbols: "記号" search_placeholder: "絵文字名とエイリアスで検索..." no_results: "結果がありません" + thread: + title: "タグライン" + replies: + other: "返信: %{count} 件" + settings: "設定" + last_reply: "最後の返信" + notifications: + regular: + title: "標準" + tracking: + title: "追跡中" + participants_other_count: + other: "+%{count} 件" draft_channel_screen: header: "新しいメッセージ" cancel: "キャンセル" @@ -432,9 +420,8 @@ ja: transcript: view: "前のメッセージのトランスクリプトを表示" types: - reviewable_chat_message: + chat_reviewable_message: title: "通報されたチャットメッセージ" - flagged_by: "通報者" keyboard_shortcuts_help: chat: title: "チャット" @@ -464,3 +451,7 @@ ja: chat_notifications: "チャット通知" chat_notifications_with_unread: other: "チャット通知 - %{count} 件の未読の通知" + styleguide: + sections: + chat: + title: チャット diff --git a/plugins/chat/config/locales/client.ko.yml b/plugins/chat/config/locales/client.ko.yml index 9a0c7cee1a9..2ace0db2237 100644 --- a/plugins/chat/config/locales/client.ko.yml +++ b/plugins/chat/config/locales/client.ko.yml @@ -59,18 +59,19 @@ ko: never: "알림 받지 않기" title: "이메일 알림" when_away: "자리를 비울 때만" + header_indicator_preference: + never: "알림 받지 않기" enable: "채팅 활성화" flag: "신고" emoji: "이모티콘 삽입" flagged: "이 메시지는 신고가 되어 검토가 필요합니다." heading: "채팅" join: "가입" - new_messages: "새 메시지" + last_visit: "마지막 방문" + summarization: + summarize: "요약하기" mention_warning: dismiss: "무시" - groups: - users_limit: - other: "사용자 %{count}명" aria_roles: header: "채팅 헤더" composer: "채팅 작성기" @@ -138,6 +139,10 @@ ko: about: 소개 members: 회원 settings: 설정 + new_message_modal: + no_items: "항목이 없습니다" + channel_edit_name_slug_modal: + name: 채널명 channel_edit_description_modal: title: 설명 수정 input_placeholder: 설명 추가 @@ -145,9 +150,6 @@ ko: title: 새로운 메시지 prefix: "받는사람:" no_results: 결과 없음 - channel_selector: - title: "채널로 이동" - no_channels: "검색과 일치하는 채널이 없습니다." channel: no_memberships: 이 채널에는 회원이 없습니다. no_memberships_found: 회원을 찾을 수 없음 @@ -172,6 +174,7 @@ ko: italic_text: "강조하기" bold_text: "굵게하기" code_text: "코드 텍스트" + send: "보내기" notification_levels: never: "알림 받지 않기" settings: @@ -179,6 +182,8 @@ ko: desktop_notification_level: "데스크톱 알림" follow: "가입" followed: "가입" + threading_enabled: "활성화" + threading_disabled: "비활성" muted_on: "켜기" muted_off: "끄기" notifications: "알림" @@ -236,6 +241,19 @@ ko: symbols: "기호" search_placeholder: "이모티콘 이름과 별칭으로 검색..." no_results: "결과 없음" + thread: + title: "제목" + replies: + other: "%{count} 댓글" + settings: "사이트 설정" + last_reply: "마지막 댓글" + notifications: + regular: + title: "일반" + tracking: + title: "알림" + participants_other_count: + other: "+%{count}" draft_channel_screen: header: "새로운 메시지" cancel: "취소" @@ -261,9 +279,8 @@ ko: description: 시스템 기본값 review: types: - reviewable_chat_message: + chat_reviewable_message: title: "신고된 채팅 메시지" - flagged_by: "신고자" keyboard_shortcuts_help: chat: title: "채팅" @@ -272,3 +289,7 @@ ko: chat_notifications: "채팅 알림" chat_notifications_with_unread: other: "채팅 알림 - 읽지 않은 알림 %{count}개" + styleguide: + sections: + chat: + title: 채팅 diff --git a/plugins/chat/config/locales/client.lt.yml b/plugins/chat/config/locales/client.lt.yml index 45434460581..fe2e7395b3e 100644 --- a/plugins/chat/config/locales/client.lt.yml +++ b/plugins/chat/config/locales/client.lt.yml @@ -21,16 +21,15 @@ lt: joined: "prisijungė" email_frequency: never: "Niekada" + header_indicator_preference: + never: "Niekada" flag: "Pranešti" join: "Prisijungti" + last_visit: "paskutinis apsilankymas" + summarization: + summarize: "Apibendrinti" mention_warning: dismiss: "praleisti" - groups: - users_limit: - one: "%{count} vartotojas" - few: "%{count} vartotojų" - many: "%{count} vartotojų" - other: "%{count} vartotojų" reply: "Atsakyti" edit: "Redaguoti" rebake_message: "Perkurti HTML" @@ -60,6 +59,8 @@ lt: about: Apie members: Nariai settings: Nustatymai + new_message_modal: + no_items: "Nėra elementų" direct_message_creator: title: Nauja žinutė prefix: "Kam:" @@ -71,11 +72,14 @@ lt: composer: italic_text: "pasviras tekstas" bold_text: "paryškintas tekstas" + send: "Siųsti" notification_levels: never: "Niekada" settings: follow: "Prisijungti" followed: "Prisijungė" + threading_enabled: "Galimas" + threading_disabled: "Uždrausta" notifications: "Pranešimai" preview: "Peržiūrėti" save: "Išsaugoti" @@ -107,6 +111,25 @@ lt: activities: "Veikla" flags: "Pažymėk!" symbols: "Simboliai" + thread: + title: "Antraštė" + replies: + one: "%{count} atsakymas" + few: "%{count} atsakymai" + many: "%{count} atsakymai" + other: "%{count} atsakymai" + settings: "Nustatymai" + last_reply: "Paskutinis atsakymas" + notifications: + regular: + title: "Įprastas" + tracking: + title: "Seku" + participants_other_count: + one: "+%{count}" + few: "+%{count}" + many: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Nauja žinutė" cancel: "Atšaukti" @@ -118,7 +141,3 @@ lt: 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 index 4e11375693c..f35132e4765 100644 --- a/plugins/chat/config/locales/client.lv.yml +++ b/plugins/chat/config/locales/client.lv.yml @@ -21,15 +21,13 @@ lv: joined: "pievienojās" email_frequency: never: "Nekad" + header_indicator_preference: + never: "Nekad" flag: "Sūdzība" join: "Pievienojieties" + last_visit: "pēdējais apmeklējums" mention_warning: dismiss: "nerādīt" - groups: - users_limit: - zero: "%{count} lietotāji" - one: "%{count} lietotājs" - other: "%{count} lietotāji" reply: "Atbilde" edit: "Rediģēt" rebake_message: "Pārbūvēt HTML" @@ -70,11 +68,14 @@ lv: composer: italic_text: "Uzsvērts teksts" bold_text: "treknrakstā" + send: "Sūtīt" notification_levels: never: "Nekad" settings: follow: "Pievienojieties" followed: "Pievienojās" + threading_enabled: "Ieslēgts" + threading_disabled: "Atslēgt" notifications: "Paziņojumi" preview: "Priekšskatījums" save: "Saglabāt" @@ -104,6 +105,19 @@ lv: activities: "Aktivitātes" flags: "Sūdzības" symbols: "Simboli" + thread: + title: "Virsraksts" + replies: + zero: "%{count} atbildes" + one: "%{count} atbilde" + other: "%{count} atbildes" + settings: "Iestatījumi" + last_reply: "pēdējā atbilde" + notifications: + regular: + title: "Normāls" + tracking: + title: "Sekot" draft_channel_screen: header: "Jauns ziņa" cancel: "Atcelt" @@ -113,7 +127,3 @@ lv: 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 index 5f413a287ee..eb47e6eafc2 100644 --- a/plugins/chat/config/locales/client.nb_NO.yml +++ b/plugins/chat/config/locales/client.nb_NO.yml @@ -21,14 +21,13 @@ nb_NO: joined: "medlem fra" email_frequency: never: "Aldri" + header_indicator_preference: + never: "Aldri" flag: "Flagg" join: "Bli medlem" + last_visit: "siste besøk" mention_warning: dismiss: "forkast" - groups: - users_limit: - one: "%{count} bruker" - other: "%{count} brukere" reply: "Svar" edit: "Endre" rebake_message: "Generer HTML på nytt" @@ -69,6 +68,7 @@ nb_NO: composer: italic_text: "kursiv tekst" bold_text: "sterk tekst" + send: "Send" notification_levels: never: "Aldri" settings: @@ -76,6 +76,8 @@ nb_NO: channel_wide_mentions_description: "Tillat brukere å varsle alle medlemmer av #%{channel} med @alle eller bare de som er aktive i øyeblikket med @alleher" follow: "Bli medlem" followed: "Medlem fra" + threading_enabled: "Aktivert" + threading_disabled: "Deaktivert" notifications: "Varsler" preview: "Forhåndsvis" save: "Lagre" @@ -107,6 +109,21 @@ nb_NO: activities: "Aktiviteter" flags: "Flagg" symbols: "Symboler" + thread: + title: "Tittel" + replies: + one: "%{count} svar" + other: "%{count} svar" + settings: "Instillinger" + last_reply: "siste svar" + notifications: + regular: + title: "Normal" + tracking: + title: "Overvåkning" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Ny Melding" cancel: "Avbryt" @@ -118,7 +135,3 @@ nb_NO: 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 index 02f8fe730e3..c98b74e5fe6 100644 --- a/plugins/chat/config/locales/client.nl.yml +++ b/plugins/chat/config/locales/client.nl.yml @@ -38,9 +38,6 @@ nl: browse_all_channels: "Alle kanalen bekijken" move_to_channel: title: "Berichten verplaatsen naar kanaal" - instructions: - one: "Je gaat %{count} bericht verplaatsen. Selecteer een bestemmingskanaal. Er wordt een vervangend bericht gemaakt in het kanaal %{channelTitle} om aan te geven dat dit bericht is verplaatst." - other: "Je gaat %{count} berichten verplaatsen. Selecteer een bestemmingskanaal. Er wordt een vervangend bericht gemaakt in het kanaal %{channelTitle} om aan te geven dat deze berichten zijn verplaatst." confirm_move: "Berichten verplaatsen" channel_settings: title: "Kanaalinstellingen" @@ -71,7 +68,6 @@ nl: instructions: "Door het kanaal te sluiten, kunnen niet-medewerkers geen nieuwe berichten sturen of bestaande berichten bewerken. Weet je zeker dat je dit kanaal wilt sluiten?" channel_delete: title: "Kanaal verwijderen" - instructions: "

    Verwijdert de het kanaal %{name} en de chatgeschiedenis. Alle berichten en gerelateerde gegevens, zoals antwoorden en uploads, worden definitief verwijderd. Als je de kanaalgeschiedenis wilt behouden en buiten gebruik wilt stellen, kun je het kanaal in plaats daarvan archiveren.

    Weet je zeker dat je het kanaal permanent wilt verwijderen? Typ ter bevestiging de naam van het kanaal in het onderstaande vak.

    " confirm: "Ik begrijp de gevolgen, verwijder het kanaal" confirm_channel_name: "Voer kanaalnaam in" process_started: "Het proces om het kanaal te verwijderen is gestart. Dit venster wordt over enkele ogenblikken gesloten, je ziet het verwijderde kanaal nergens meer." @@ -82,7 +78,6 @@ nl: close: "Sluiten" collapse: "Chatlade samenvouwen" confirm_flag: "Weet je zeker dat je het bericht van %{username} wilt markeren?" - deleted: "Er is een bericht verwijderd. [weergeven]" hidden: "Er is een bericht verborgen. [weergeven]" delete: "Verwijderen" edited: "bewerkt" @@ -97,6 +92,8 @@ nl: never: "Nooit" title: "E-mailmeldingen" when_away: "Alleen wanneer afwezig" + header_indicator_preference: + never: "Nooit" enable: "Chat inschakelen" flag: "Markeren" emoji: "Emoji invoegen" @@ -106,48 +103,23 @@ nl: in_reply_to: "In antwoord op" heading: "Chat" join: "Deelnemen" - new_messages: "nieuwe berichten" + last_visit: "laatste bezoek" + summarization: + summarize: "Samenvatten" mention_warning: dismiss: "negeren" - cannot_see: - one: "%{username} heeft geen toegang tot dit kanaal en is niet geïnformeerd." - other: "%{username} hebben geen toegang tot dit kanaal en zijn niet geïnformeerd." invitations_sent: one: "Uitnodiging gestuurd" other: "Uitnodigingen gestuurd" invite: "Uitnodigen voor kanaal" - without_membership: - one: "%{username} neemt niet deel aan dit kanaal." - other: "%{username} en %{others} nemen niet deel aan dit kanaal." - group_mentions_disabled: - one: "%{group_name} staat geen vermeldingen toe" - other: "%{group_name} en %{others} staan geen vermeldingen toe" - too_many_members: - one: "%{group_name} heeft te veel leden. Niemand is geïnformeerd" - other: "%{group_name} en %{others} hebben te veel leden. Niemand is geïnformeerd" - warning_multiple: - one: "%{count} ander" - other: "%{count} anderen" + without_membership: "%{username} neemt niet deel aan dit kanaal." + group_mentions_disabled: "%{group_name} staat geen vermeldingen toe." + too_many_members: "%{group_name} heeft te veel leden. Niemand is geïnformeerd." groups: header: some: "Sommige gebruikers worden niet geïnformeerd" all: "Niemand wordt geïnformeerd" - unreachable: - one: "@%{group} staat geen vermeldingen toe" - other: "@%{group} en %{group_2} staan geen vermeldingen toe" - unreachable_multiple: "@%{group} en %{count} andere groepen staan geen vermeldingen toe" - too_many_members: - one: "Het noemen van @%{group} overschrijdt de %{notification_limit} van %{limit}" - other: "Het noemen van zowel @%{group} als @%{group_2} overschrijdt de %{notification_limit} van %{limit}" - too_many_members_multiple: "Deze %{count} groepen overschrijden de %{notification_limit} van %{limit}" - users_limit: - one: "%{count} gebruiker" - other: "%{count} gebruikers" - notification_limit: "meldingslimiet" - too_many_mentions: "Dit bericht overschrijdt de %{notification_limit} van %{limit}" - mentions_limit: - one: "%{count} vermelding" - other: "%{count} vermeldingen" + unreachable_1: "@%{group} staat geen vermeldingen toe." aria_roles: header: "Chatkop" composer: "Chateditor" @@ -164,10 +136,9 @@ nl: close_full_page: "Chat op volledig scherm sluiten" open_message: "Bericht openen in chat" placeholder_self: "Schrijf iets" - placeholder_others: "Chatten met %{messageRecipient}" - placeholder_new_message_disallowed: "Kanaal is %{status}, je kunt momenteel geen nieuwe berichten sturen." + placeholder_users: "Chatten met %{commaSeparatedNames}" placeholder_silenced: "Je kunt op dit moment geen berichten sturen." - placeholder_start_conversation: Begin een gesprek met %{usernames} + placeholder_start_conversation_users: "Begin een gesprek met %{commaSeparatedUsernames}" remove_upload: "Bestand verwijderen" react: "Reageren met emoji" reply: "Antwoorden" @@ -183,7 +154,6 @@ nl: restore: "Verwijderd bericht herstellen" save: "Opslaan" select: "Selecteren" - silence: "Gebruiker dempen" return_to_list: "Terug naar kanalenlijst" scroll_to_bottom: "Naar beneden scrollen" scroll_to_new_messages: "Nieuwe berichten weergeven" @@ -237,6 +207,10 @@ nl: about: Over members: Leden settings: Instellingen + new_message_modal: + no_items: "Geen items" + channel_edit_name_slug_modal: + name: Kanaalnaam channel_edit_description_modal: title: Beschrijving bewerken input_placeholder: Voeg een beschrijving toe @@ -246,9 +220,6 @@ nl: prefix: "Aan:" no_results: Geen resultaten selected_user_title: "%{username} deselecteren" - channel_selector: - title: "Naar kanaal springen" - no_channels: "Er komen geen kanalen overeen met je zoekopdracht" channel: no_memberships: Dit kanaal heeft geen leden no_memberships_found: Geen leden gevonden @@ -258,18 +229,10 @@ nl: create_channel: auto_join_users: public_category_warning: "%{category} is een openbare categorie. Alle recent actieve gebruikers automatisch toevoegen aan dit kanaal?" - warning_groups: - one: '%{members_count} gebruikers uit %{group} automatisch toevoegen?' - other: '%{members_count} gebruikers uit %{group} en %{group_2} automatisch toevoegen?' - warning_multiple_groups: '%{members_count} gebruikers uit %{group_1} en %{count} anderen automatisch toevoegen?' choose_category: label: "Kies een categorie" none: "selecteer een..." default_hint: Beheer de toegang door naar %{category}beveiligingsinstellingen te gaan - hint_groups: - one: Gebruikers in %{hint} hebben toegang tot dit kanaal volgens de beveiligingsinstellingen - other: Gebruikers in %{hint} en %{hint_2} hebben toegang tot dit kanaal volgens de beveiligingsinstellingen - hint_multiple_groups: Gebruikers in %{hint_1} en %{count} andere groepen hebben toegang tot dit kanaal volgens de beveiligingsinstellingen create: "Kanaal maken" description: "Beschrijving (optioneel)" name: "Kanaalnaam" @@ -282,15 +245,13 @@ nl: type: "Chatbericht" reactions: only_you: "Je hebt gereageerd met :%{emoji}:" - and_others: "Jij, %{usernames} hebben gereageerd met :%{emoji}:" - only_others: "%{usernames} hebben gereageerd met :%{emoji}:" - others_and_more: "%{usernames} en %{more} anderen hebben gereageerd met :%{emoji}:" - you_others_and_more: "Jij, %{usernames} en %{more} anderen hebben gereageerd met :%{emoji}:" + single_user: "%{username} hebben gereageerd met :%{emoji}:" composer: toggle_toolbar: "Werkbalk schakelen" italic_text: "Cursieve tekst" bold_text: "Vetgedrukte tekst" code_text: "codetekst" + send: "Verzenden" quote: original_channel: 'Oorspronkelijk gestuurd in %{channel}' copy_success: "Chatcitaat gekopieerd naar klembord" @@ -302,14 +263,14 @@ nl: channel_wide_mentions_label: "Sta vermeldingen van @all en @here toe" channel_wide_mentions_description: "Sta gebruikers toe om alle leden van #%{channel} te informeren met @all of alleen degenen die op dat moment actief zijn met @here" auto_join_users_label: "Gebruikers automatisch toevoegen" - auto_join_users_info: "Controleer elk uur welke gebruikers de afgelopen 3 maanden actief zijn geweest en voeg ze toe aan dit kanaal als ze toegang hebben tot de categorie %{category}." - enable_auto_join_users: "Automatisch alle recent actieve gebruikers toevoegen" auto_join_users_warning: "Elke gebruiker die geen lid is van dit kanaal en toegang heeft tot de categorie %{category} wordt lid. Weet je het zeker?" desktop_notification_level: "Bureaubladmeldingen" follow: "Deelnemen" followed: "Lid sinds" mobile_notification_level: "Mobiele pushmeldingen" mute: "Kanaal dempen" + threading_enabled: "Ingeschakeld" + threading_disabled: "Uitgeschakeld" muted_on: "Aan" muted_off: "Uit" notifications: "Meldingen" @@ -318,7 +279,6 @@ nl: saved: "Opgeslagen" unfollow: "Verlaten" admin_title: "Beheerder" - retention_info: "Chatgeschiedenis wordt %{days} dagen bewaard." admin: title: "Chat" direct_messages: @@ -383,9 +343,6 @@ nl: many_users: one: "%{commaSeparatedUsernames} en %{count} ander zijn aan het typen" other: "%{commaSeparatedUsernames} en %{count} anderen zijn aan het typen" - retention_reminders: - public: "Kanaalgeschiedenis wordt %{days} dagen bewaard." - dm: "Persoonlijke chatgeschiedenis wordt %{days} dagen bewaard." flags: off_topic: "Dit bericht is niet relevant voor de huidige discussie zoals gedefinieerd door de kanaaltitel en moet waarschijnlijk worden verplaats naar elders." inappropriate: "Dit bericht bevat inhoud die een redelijk persoon als aanstootgevend, kwetsend of een overtreding van onze communityrichtlijnen zou beschouwen." @@ -407,6 +364,21 @@ nl: symbols: "Symbolen" search_placeholder: "Zoek op emojinaam en -alias..." no_results: "Geen resultaten" + thread: + title: "Titel" + replies: + one: "%{count} antwoord" + other: "%{count} antwoorden" + settings: "Instellingen" + last_reply: "laatste antwoord" + notifications: + regular: + title: "Normaal" + tracking: + title: "Tracking" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Nieuw bericht" cancel: "Annuleren" @@ -451,9 +423,8 @@ nl: transcript: view: "Transcript van eerdere berichten weergeven" types: - reviewable_chat_message: + chat_reviewable_message: title: "Gemarkeerd chatbericht" - flagged_by: "Gemarkeerd door" keyboard_shortcuts_help: chat: title: "Chat" @@ -484,3 +455,7 @@ nl: chat_notifications_with_unread: one: "Chatmeldingen - %{count} ongelezen melding" other: "Chatmeldingen - %{count} ongelezen meldingen" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.pl_PL.yml b/plugins/chat/config/locales/client.pl_PL.yml index c920efb054e..ac735ee974a 100644 --- a/plugins/chat/config/locales/client.pl_PL.yml +++ b/plugins/chat/config/locales/client.pl_PL.yml @@ -15,6 +15,7 @@ pl_PL: actions: chat_channel_status_change: "Zmieniono status kanału czatu" chat_channel_delete: "Kanał czatu usunięty" + chat_auto_remove_membership: "Członkostwa automatycznie usuwane z kanałów" api: scopes: descriptions: @@ -39,10 +40,10 @@ pl_PL: move_to_channel: title: "Przenieś wiadomości na kanał" instructions: - one: "Przenosisz %{count} wiadomość. Wybierz kanał docelowy. Na kanale %{channelTitle} zostanie utworzona wiadomość zastępcza informująca o przeniesieniu tej wiadomości." - few: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. Na kanale %{channelTitle} zostanie utworzona wiadomość zastępcza informująca o przeniesieniu tych wiadomości." - many: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. Na kanale %{channelTitle} zostanie utworzona wiadomość zastępcza informująca o przeniesieniu tych wiadomości." - other: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. Na kanale %{channelTitle} zostanie utworzona wiadomość zastępcza informująca o przeniesieniu tych wiadomości." + one: "Przenosisz %{count} wiadomość. Wybierz kanał docelowy. W kanale %{channelTitle} zostanie utworzona wiadomość zastępcza, aby wskazać, że ta wiadomość została przeniesiona. Pamiętaj, że łańcuchy odpowiedzi nie zostaną zachowane w nowym kanale, a wiadomości w starym kanale nie będą już wyświetlane jako odpowiedzi na przeniesione wiadomości." + few: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. W kanale %{channelTitle} zostanie utworzona wiadomość zastępcza, aby wskazać, że te wiadomości zostały przeniesione. Pamiętaj, że łańcuchy odpowiedzi nie zostaną zachowane w nowym kanale, a wiadomości w starym kanale nie będą już wyświetlane jako odpowiedzi na przeniesione wiadomości." + many: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. W kanale %{channelTitle} zostanie utworzona wiadomość zastępcza, aby wskazać, że te wiadomości zostały przeniesione. Pamiętaj, że łańcuchy odpowiedzi nie zostaną zachowane w nowym kanale, a wiadomości w starym kanale nie będą już wyświetlane jako odpowiedzi na przeniesione wiadomości." + other: "Przenosisz %{count} wiadomości. Wybierz kanał docelowy. W kanale %{channelTitle} zostanie utworzona wiadomość zastępcza, aby wskazać, że te wiadomości zostały przeniesione. Pamiętaj, że łańcuchy odpowiedzi nie zostaną zachowane w nowym kanale, a wiadomości w starym kanale nie będą już wyświetlane jako odpowiedzi na przeniesione wiadomości." confirm_move: "Przenieś wiadomości" channel_settings: title: "Ustawienia kanału" @@ -73,7 +74,7 @@ pl_PL: instructions: "Zamknięcie kanału uniemożliwia użytkownikom niebędącym personelem wysyłanie nowych wiadomości ani edytowanie istniejących wiadomości. Czy na pewno chcesz zamknąć ten kanał?" channel_delete: title: "Usuń kanał" - instructions: "

    Usuwa kanał %{name} i historię czatów. Wszystkie wiadomości i powiązane dane, takie jak reakcje i przesłane treści, zostaną trwale usunięte. Jeśli chcesz zachować historię kanału i usunąć go, możesz zamiast tego zarchiwizować kanał.

    Czy na pewno chcesz trwale usunąć kanał? Aby potwierdzić, wpisz nazwę kanału w polu poniżej.

    " + instructions: "

    Usuwa kanał %{name} i historię czatu. Wszystkie wiadomości i powiązane dane, takie jak reakcje i przesłane pliki, zostaną trwale usunięte. Jeśli chcesz zachować historię kanału i zlikwidować go, możesz zamiast tego zarchiwizować kanał.

    Czy na pewno chcesz trwale usunąć kanał? Aby potwierdzić, wpisz nazwę kanału w polu poniżej.

    " confirm: "Rozumiem konsekwencje, usuń kanał" confirm_channel_name: "Wpisz nazwę kanału" process_started: "Rozpoczął się proces usuwania kanału. Ten modal zostanie wkrótce zamknięty, nigdzie nie zobaczysz już usuniętego kanału." @@ -83,8 +84,13 @@ pl_PL: click_to_join: "Kliknij tutaj, aby wyświetlić dostępne kanały." close: "Zamknij" collapse: "Zwiń szufladę czatu" + expand: "Rozwiń szufladę czatu" confirm_flag: "Czy na pewno chcesz oflagować wiadomość od %{username}?" - deleted: "Wiadomość została usunięta. [view]" + deleted: + one: "Wiadomość została usunięta. [view]" + few: "%{count} wiadomości zostały usunięte. [view all]" + many: "%{count} wiadomości zostało usuniętych. [view all]" + other: "%{count} wiadomości zostało usuniętych. [view all]" hidden: "Wiadomość została ukryta. [view]" delete: "Usuń" edited: "edytowane" @@ -99,6 +105,11 @@ pl_PL: never: "Nigdy" title: "Powiadomienia e-mail" when_away: "Tylko gdy jesteś daleko" + header_indicator_preference: + title: "Pokaż wskaźnik aktywności w nagłówku" + all_new: "Wszystkie nowe wiadomości" + dm_and_mentions: "Bezpośrednie wiadomości i wzmianki" + never: "Nigdy" enable: "Włącz czat" flag: "Oflaguj" emoji: "Wstaw emoji" @@ -108,73 +119,75 @@ pl_PL: in_reply_to: "W odpowiedzi na" heading: "Czat" join: "Dołącz" - new_messages: "nowe wiadomości" + last_visit: "ostatnia wizyta" + summarization: + title: "Podsumuj wiadomości" + description: "Wybierz opcję poniżej, aby podsumować rozmowę wysłaną w żądanym przedziale czasowym." + summarize: "Podsumuj" + since: + one: "Ostatnia godzina" + few: "Ostatnie %{count} godziny" + many: "Ostatnie %{count} godzin" + other: "Ostatnie %{count} godzin" mention_warning: dismiss: "odrzuć" - cannot_see: - one: "%{username} nie może uzyskać dostępu do tego kanału i nie został powiadomiony." - few: "%{username} i %{others} nie mogą uzyskać dostępu do tego kanału i nie zostali powiadomieni." - many: "%{username} i %{others} nie mogą uzyskać dostępu do tego kanału i nie zostali powiadomieni." - other: "%{username} i %{others} nie mogą uzyskać dostępu do tego kanału i nie zostali powiadomieni." + cannot_see: "%{username} nie ma dostępu do tego kanału i nie został powiadomiony." + cannot_see_multiple: + one: "%{username} i %{count} inny użytkownik nie ma dostępu do tego kanału i nie został powiadomiony." + few: "%{username} i %{count} innych użytkowników nie ma dostępu do tego kanału i nie zostali powiadomieni." + many: "%{username} i %{count} innych użytkowników nie ma dostępu do tego kanału i nie zostali powiadomieni." + other: "%{username} i %{count} innych użytkowników nie ma dostępu do tego kanału i nie zostali powiadomieni." 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: "%{username} nie dołączył do tego kanału." - few: "%{username} i %{others} nie dołączyli do tego kanału." - many: "%{username} i %{others} nie dołączyli do tego kanału." - other: "%{username} i %{others} nie dołączyli do tego kanału." - group_mentions_disabled: - one: "%{group_name} nie zezwala na wzmianki" - few: "%{group_name} i %{others} nie zezwalają na wzmianki" - many: "%{group_name} i %{others} nie zezwalają na wzmianki" - other: "%{group_name} i %{others} nie zezwalają na wzmianki" - too_many_members: - one: "%{group_name} ma zbyt wielu członków. Nikt nie został powiadomiony" - few: "%{group_name} i %{others} mają zbyt wielu członków. Nikt nie został powiadomiony" - many: "%{group_name} i %{others} mają zbyt wielu członków. Nikt nie został powiadomiony" - other: "%{group_name} i %{others} mają zbyt wielu członków. Nikt nie został powiadomiony" - warning_multiple: - one: "%{count} inny" - few: "%{count} inni" - many: "%{count} inni" - other: "%{count} inni" + without_membership: "%{username} nie dołączył(a) do tego kanału." + without_membership_multiple: + one: "%{username} i %{count} inny użytkownik nie dołączył do tego kanału." + few: "%{username} i %{count} inni użytkownicy nie dołączyli do tego kanału." + many: "%{username} i %{count} inni użytkownicy nie dołączyli do tego kanału." + other: "%{username} i %{count} inni użytkownicy nie dołączyli do tego kanału." + group_mentions_disabled: "%{group_name} nie zezwala na wzmianki." + group_mentions_disabled_multiple: + one: "%{group_name} i %{count} inna grupa nie zezwala na wzmianki." + few: "%{group_name} i %{count} inne grupy nie zezwalają na wzmianki." + many: "%{group_name} i %{count} inne grupy nie zezwalają na wzmianki." + other: "%{group_name} i %{count} inne grupy nie zezwalają na wzmianki." + too_many_members: "%{group_name} ma zbyt wielu członków. Nikt nie został powiadomiony." + too_many_members_multiple: + one: "%{group_name} i %{count} inna grupa ma zbyt wielu członków. Nikt nie został powiadomiony." + few: "%{group_name} i %{count} inne grupy mają zbyt wielu członków. Nikt nie został powiadomiony." + many: "%{group_name} i %{count} innych grup ma zbyt wielu członków. Nikt nie został powiadomiony." + other: "%{group_name} i %{count} innych grup ma zbyt wielu członków. Nikt nie został powiadomiony." groups: header: some: "Niektórzy użytkownicy nie zostaną powiadomieni" all: "Nikt nie zostanie powiadomiony" - unreachable: - one: "@%{group} nie zezwala na wzmianki" - few: "@%{group} i @%{group_2} nie zezwalają na wzmianki" - many: "@%{group} i @%{group_2} nie zezwalają na wzmianki" - other: "@%{group} i @%{group_2} nie zezwalają na wzmianki" - unreachable_multiple: "@%{group} i %{count} inni nie zezwalają na wzmianki" - too_many_members: - one: "Wzmianka @%{group} przekracza %{notification_limit} z %{limit}" - few: "Wzmianka zarówno @%{group} lub %{group_2} przekracza %{notification_limit} z %{limit}" - many: "Wzmianka zarówno @%{group} lub %{group_2} przekracza %{notification_limit} z %{limit}" - other: "Wzmianka zarówno @%{group} lub %{group_2} przekracza %{notification_limit} z %{limit}" - too_many_members_multiple: "Te %{count} grup przekracza %{notification_limit} z %{limit}" - users_limit: - one: "%{count} użytkownik" - few: "%{count} użytkownicy" - many: "%{count} użytkowników" - other: "%{count} użytkownicy" - notification_limit: "limit powiadomień" - too_many_mentions: "Ta wiadomość przekracza %{notification_limit} z %{limit}" - mentions_limit: - one: "%{count} wzmianka" - few: "%{count} wzmianki" - many: "%{count} wzmianek" - other: "%{count} wzmianek" + unreachable_1: "@%{group} nie zezwala na wzmianki." + unreachable_2: "@%{group1} i @%{group2} nie zezwalają na wzmianki." + unreachable_multiple: + one: "@%{group} i %{count} inna grupa nie zezwala na wzmianki." + few: "@%{group} i %{count} inne grupy nie zezwalają na wzmianki." + many: "@%{group} i %{count} innych grup nie zezwala na wzmianki." + other: "@%{group} i %{count} innych grup nie zezwala na wzmianki." + too_many_mentions: + one: "Ta wiadomość przekracza limit powiadomień wynoszący %{count} wzmiankę." + few: "Ta wiadomość przekracza limit powiadomień wynoszący %{count} wzmianki." + many: "Ta wiadomość przekracza limit powiadomień wynoszący %{count} wzmianek." + other: "Ta wiadomość przekracza limit powiadomień wynoszący %{count} wzmianek." + too_many_mentions_admin: + one: 'Ta wiadomość przekracza limit powiadomień z %{count} wzmianki.' + few: 'Ta wiadomość przekracza limit powiadomień z %{count} wzmianek.' + many: 'Ta wiadomość przekracza limit powiadomień z %{count} wzmianek.' + other: 'Ta wiadomość przekracza limit powiadomień z %{count} wzmianek.' aria_roles: header: "Nagłówek czatu" composer: "Edytor czatu" channels_list: "Lista kanałów czatu" no_public_channels: "Nie dołączyłeś do żadnego kanału." + kicked_from_channel: "Nie masz już dostępu do tego kanału." only_chat_push_notifications: title: "Wysyłaj tylko powiadomienia push czatu" description: "Zablokuj wysyłanie wszystkich powiadomień push niezwiązanych z czatem" @@ -186,10 +199,16 @@ pl_PL: close_full_page: "Zamknij czat pełnoekranowy" 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_channel: "Czat w %{channelName}" + placeholder_thread: "Czat w wątku" + placeholder_users: "Czat z %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Kanał jest zarchiwizowany, nie możesz teraz wysyłać nowych wiadomości." + closed: "Kanał jest zamknięty, nie możesz teraz wysyłać nowych wiadomości." + read_only: "Kanał jest tylko do odczytu, nie możesz teraz wysyłać nowych wiadomości." placeholder_silenced: "W tej chwili nie możesz wysyłać wiadomości." - placeholder_start_conversation: Rozpocznij rozmowę z %{usernames} + placeholder_start_conversation: "Rozpocznij rozmowę z..." + placeholder_start_conversation_users: "Rozpocznij rozmowę z %{commaSeparatedUsernames}" remove_upload: "Usuń plik" react: "Zareaguj z emoji" reply: "Odpowiedz" @@ -205,8 +224,13 @@ pl_PL: restore: "Przywróć usuniętą wiadomość" save: "Zapisz" select: "Wybierz" - silence: "Wycisz użytkownika" return_to_list: "Powrót do listy kanałów" + return_to_threads_list: "Wróć do trwających dyskusji" + unread_threads_count: + one: "Masz %{count} nieprzeczytaną dyskusje" + few: "Masz %{count} nieprzeczytane dyskusje" + many: "Masz %{count} nieprzeczytanych dyskusji" + other: "Masz %{count} nieprzeczytanych dyskusji" scroll_to_bottom: "Przewiń na dół" scroll_to_new_messages: "Zobacz nowe wiadomości" sound: @@ -218,6 +242,8 @@ pl_PL: title: "czat" title_capitalized: "Czat" upload: "Dołącz plik" + upload_to_channel: "Prześlij do %{title}" + upload_to_thread: "Prześlij do wątku" uploaded_files: one: "%{count} plik" few: "%{count} pliki" @@ -263,10 +289,22 @@ pl_PL: about: O stronie members: Członkowie settings: Ustawienia - channel_edit_name_modal: - title: Edytuj nazwę + new_message_modal: + title: Wyślij wiadomość + add_user_long: shift + click lub shift + enterDodaj @%{username} + add_user_short: Dodaj użytkownika + open_channel: Otwórz kanał + default_search_placeholder: "#kanał, @ktoś lub cokolwiek" + default_user_search_placeholder: "@ktoś" + user_search_placeholder: "...dodaj więcej użytkowników" + disabled_user: "wyłączył czat" + no_items: "Brak elementu" + channel_edit_name_slug_modal: + title: Edytuj kanał input_placeholder: Dodaj nazwę - description: Nadaj swojemu kanałowi krótką, opisową nazwę + slug_description: Slug kanału jest używany w adresie URL zamiast nazwy kanału + name: Nazwa kanału + slug: Slug kanału (opcjonalnie) channel_edit_description_modal: title: Edytuj opis input_placeholder: Dodaj opis @@ -276,9 +314,6 @@ pl_PL: prefix: "Do:" no_results: Brak wyników selected_user_title: "Odznacz %{username}" - channel_selector: - title: "Przejdź do kanału" - no_channels: "Żadne kanały nie pasują do Twojego wyszukiwania" channel: no_memberships: Ten kanał nie ma członków no_memberships_found: Nie znaleziono członków @@ -288,27 +323,35 @@ pl_PL: many: "%{count} członków" other: "%{count} członków" create_channel: + threading: + label: "Włącz wątki" auto_join_users: public_category_warning: "%{category} to kategoria publiczna. Automatycznie dodać wszystkich ostatnio aktywnych użytkowników do tego kanału?" - warning_groups: - one: Automatycznie dodać %{members_count} użytkowników z %{group}? - few: Automatycznie dodać %{members_count} użytkowników z %{group} i %{group_2}? - many: Automatycznie dodać %{members_count} użytkowników z %{group} i %{group_2}? - other: Automatycznie dodać %{members_count} użytkowników z %{group} i %{group_2}? - warning_multiple_groups: Automatycznie dodać %{members_count} użytkowników z %{group_1} i %{count} innych? + warning_1_group: + one: "Automatycznie dodać %{count} użytkownika z %{group}?" + few: "Automatycznie dodać %{count} użytkowników z %{group}?" + many: "Automatycznie dodać %{count} użytkowników z %{group}?" + other: "Automatycznie dodać %{count} użytkowników z %{group}?" + warning_2_groups: + one: "Automatycznie dodać %{count} użytkownika z %{group1} i %{group2}?" + few: "Automatycznie dodać %{count} użytkowników z %{group1} i %{group2}?" + many: "Automatycznie dodać %{count} użytkowników z %{group1} i %{group2}?" + other: "Automatycznie dodać %{count} użytkowników z %{group1} i %{group2}?" choose_category: label: "Wybierz kategorię" none: "wybierz jeden..." default_hint: Zarządzaj dostępem, odwiedzając ustawienia bezpieczeństwa %{category} - hint_groups: - one: Użytkownicy w %{hint} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń - few: Użytkownicy w %{hint} i %{hint_2} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń - many: Użytkownicy w %{hint} i %{hint_2} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń - other: Użytkownicy w %{hint} i %{hint_2} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń - hint_multiple_groups: Użytkownicy w %{hint_1} i %{count} innych grupach będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń + hint_1_group: 'Użytkownicy w %{group} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' + hint_2_groups: 'Użytkownicy w %{group1} i %{group2} będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' + hint_multiple_groups: + one: 'Użytkownicy w %{group} i %{count} innej grupie będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' + few: 'Użytkownicy w %{group} i %{count} innych grupach będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' + many: 'Użytkownicy w %{group} i %{count} innych grupach będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' + other: 'Użytkownicy w %{group} i %{count} innych grupach będą mieli dostęp do tego kanału zgodnie z ustawieniami zabezpieczeń' create: "Utwórz kanał" description: "Opis (opcjonalnie)" name: "Nazwa kanału" + slug: "Slug kanału (opcjonalnie)" title: "Nowy kanał" type: "Typ" types: @@ -318,15 +361,26 @@ pl_PL: 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}:" + you_and_single_user: "Ty i %{username} zareagowaliście za pomocą :%{emoji}:" + you_and_multiple_users: "Ty, %{commaSeparatedUsernames} i %{username} zareagowaliście za pomocą :%{emoji}:" + you_multiple_users_and_more: + one: "Ty, %{commaSeparatedUsernames} i %{count} inny zareagowaliście za pomocą :%{emoji}:" + few: "Ty, %{commaSeparatedUsernames} i %{count} inni zareagowaliście za pomocą :%{emoji}:" + many: "Ty, %{commaSeparatedUsernames} i %{count} inni zareagowaliście za pomocą :%{emoji}:" + other: "Ty, %{commaSeparatedUsernames} i %{count} inni zareagowaliście za pomocą :%{emoji}:" + single_user: "%{username} zareagował za pomocą :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} i %{username} zareagowali za pomocą :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} i %{count} inni zareagowali za pomocą :%{emoji}:" + few: "%{commaSeparatedUsernames} i %{count} inni zareagowali za pomocą :%{emoji}:" + many: "%{commaSeparatedUsernames} i %{count} inni zareagowali za pomocą :%{emoji}:" + other: "%{commaSeparatedUsernames} i %{count} inni zareagowali za pomocą :%{emoji}:" composer: toggle_toolbar: "Przełącz pasek narzędzi" italic_text: "wyróżniony tekst" bold_text: "pogrubiony tekst" code_text: "kod" + send: "Wyślij" quote: original_channel: 'Oryginalnie wysłane w %{channel}' copy_success: "Cytat z czatu skopiowany do schowka" @@ -337,15 +391,19 @@ pl_PL: settings: channel_wide_mentions_label: "Zezwalaj na wzmianki @all i @here" channel_wide_mentions_description: "Zezwalaj użytkownikom na powiadamianie wszystkich członków #%{channel} za pomocą @all lub tylko tych, którzy są aktywni w danym momencie za pomocą @here" + channel_threading_label: "Wątki" + channel_threading_description: "Gdy wątki są włączone, odpowiedzi na wiadomości czatu utworzą osobną rozmowę, która będzie istnieć obok głównego kanału." auto_join_users_label: "Automatycznie dodawaj użytkowników" - auto_join_users_info: "Sprawdzaj co godzinę, którzy użytkownicy byli aktywni w ciągu ostatnich 3 miesięcy i jeśli mają dostęp do kategorii %{category} , dodaj ich do tego kanału." - enable_auto_join_users: "Automatycznie dodawaj wszystkich ostatnio aktywnych użytkowników" + auto_join_users_info: "Sprawdzaj co godzinę, którzy użytkownicy byli aktywni w ciągu ostatnich 3 miesięcy. Dodaj ich do tego kanału, jeśli mają dostęp do kategorii %{category}." + auto_join_users_info_no_category: "Sprawdzaj co godzinę, którzy użytkownicy byli aktywni w ciągu ostatnich 3 miesięcy. Dodaj ich do tego kanału, jeśli mają dostęp do wybranej kategorii." auto_join_users_warning: "Każdy użytkownik, który nie jest członkiem tego kanału i ma dostęp do kategorii %{category} , dołączy do niego. Czy jesteś pewien?" desktop_notification_level: "Powiadomienia na pulpicie" follow: "Dołącz" followed: "Dołączył" mobile_notification_level: "Mobilne powiadomienia push" mute: "Wycisz kanał" + threading_enabled: "Włączona" + threading_disabled: "Wyłączone" muted_on: "Wł" muted_off: "Wył" notifications: "Powiadomienia" @@ -354,9 +412,13 @@ pl_PL: saved: "Zapisano" unfollow: "Opuść" admin_title: "Administratorzy" - retention_info: "Historia czatu będzie zapisywana przez %{days} dni." admin: title: "Czat" + export_messages: + title: "Eksportuj wiadomości czatu" + description: "Eksport jest obecnie ograniczony do 10 000 najnowszych wiadomości z ostatnich 6 miesięcy." + create_export: "Utwórz eksport" + export_has_started: "Eksport rozpoczął się. Otrzymasz wiadomość prywatną, gdy będzie gotowy." direct_messages: title: "Czat osobisty" new: "Utwórz osobisty czat" @@ -427,8 +489,18 @@ pl_PL: many: "%{commaSeparatedUsernames} i %{count} inni piszą" other: "%{commaSeparatedUsernames} i %{count} innych piszą" retention_reminders: - public: "Historia kanału jest przechowywana przez %{days} dni." - dm: "Historia osobistego czatu jest przechowywana przez %{days} dni." + public_none: "Historia kanału jest przechowywana przez czas nieokreślony." + public: + one: "Historia kanału jest przechowywana przez %{count} dzień." + few: "Historia kanału jest przechowywana przez %{count} dni." + many: "Historia kanału jest przechowywana przez %{count} dni." + other: "Historia kanału jest przechowywana przez %{count} dni." + dm_none: "Osobista historia czatu jest przechowywana przez czas nieokreślony." + dm: + one: "Osobista historia czatu jest zachowywana przez %{count} dzień." + few: "Osobista historia czatu jest zachowywana przez %{count} dni." + many: "Osobista historia czatu jest zachowywana przez %{count} dni." + other: "Osobista historia czatu jest zachowywana przez %{count} dni." flags: off_topic: "Ta wiadomość nie jest związana z bieżącą dyskusją określoną w tytule kanału i prawdopodobnie powinna zostać przeniesiona w inne miejsce." inappropriate: "Ta wiadomość zawiera treści, które rozsądna osoba mogłaby uznać za obraźliwe, krzywdzące lub stanowiące naruszenie naszych wytycznych dotyczących społeczności." @@ -450,6 +522,37 @@ pl_PL: symbols: "Symbolika" search_placeholder: "Szukaj według nazwy emoji i aliasu..." no_results: "Brak wyników" + thread: + title: "Tytuł" + view_thread: Zobacz wątek + default_title: "Wątek" + replies: + one: "%{count} odpowiedź" + few: "%{count} odpowiedzi" + many: "%{count} odpowiedzi" + other: "%{count} odpowiedzi" + label: Wątek + close: "Zamknij wątek" + original_message: + started_by: "Rozpoczęty przez" + settings: "Ustawienia" + last_reply: "ostatnia odpowiedź" + notifications: + regular: + title: "Normalny" + description: "Otrzymasz powiadomienie, jeśli ktoś wspomni Twoją @nazwę w tym wątku." + tracking: + title: "Śledzona" + description: "Liczba nowych odpowiedzi w tym wątku zostanie wyświetlona na liście wątków i na kanale. Otrzymasz powiadomienie, jeśli ktoś wspomni Twoją @nazwę w tym wątku." + participants_other_count: + one: "+%{count}" + few: "+%{count}" + many: "+%{count}" + other: "+%{count}" + threads: + open: "Otwórz wątek" + list: "Trwające dyskusje" + none: "Nie uczestniczysz w żadnych wątkach na tym kanale." draft_channel_screen: header: "Nowa wiadomość" cancel: "Anuluj" @@ -494,9 +597,8 @@ pl_PL: transcript: view: "Zobacz transkrypcję poprzednich wiadomości" types: - reviewable_chat_message: - title: "Oznaczona wiadomość czatu" - flagged_by: "Oflagowany przez" + chat_reviewable_message: + title: "Oflagowana wiadomość na czacie" keyboard_shortcuts_help: chat: title: "Czat" @@ -509,6 +611,7 @@ pl_PL: composer_code: "%{shortcut} Kod (tylko kompozytor)" drawer_open: "%{shortcut} Otwórz szufladę czatu" drawer_close: "%{shortcut} Zamknij szufladę czatu" + mark_all_channels_read: "%{shortcut} Zaznacz wszystkie kanały jako przeczytane" topic_statuses: chat: help: "Czat jest włączony dla tego tematu" @@ -529,3 +632,7 @@ pl_PL: few: "Powiadomienia czatu — %{count} nieprzeczytane powiadomienia" many: "Powiadomienia czatu — %{count} nieprzeczytanych powiadomień" other: "Powiadomienia czatu — %{count} nieprzeczytanych powiadomień" + styleguide: + sections: + chat: + title: Czat diff --git a/plugins/chat/config/locales/client.pt.yml b/plugins/chat/config/locales/client.pt.yml index f1a94c7b36e..809506a5304 100644 --- a/plugins/chat/config/locales/client.pt.yml +++ b/plugins/chat/config/locales/client.pt.yml @@ -55,7 +55,6 @@ pt: close: "Fechar" collapse: "Colapsar gaveta de Chat" confirm_flag: "Tem certeza de que deseja sinalizar a mensagem de %{username}?" - deleted: "Uma mensagem foi apagada. [view]" hidden: "Uma mensagem foi ocultada. [view]" delete: "Eliminar" edited: "editado" @@ -66,14 +65,15 @@ pt: direct_message: "Você também pode iniciar um Chat pessoal com um ou mais utilizadores." email_frequency: never: "Nunca" + header_indicator_preference: + never: "Nunca" flag: "Denunciar" join: "Entrar" + last_visit: "última visita" + summarization: + summarize: "Resumir" mention_warning: dismiss: "marcar Visto" - groups: - users_limit: - one: "%{count} usuário" - other: "%{count} usuários" only_chat_push_notifications: title: "Enviar notificações push apenas para chat" reply: "Responder" @@ -89,7 +89,6 @@ pt: restore: "Restaurar mensagem apagada" save: "Guardar" select: "Selecionar" - silence: "Silenciar utilizador" return_to_list: "Voltar à lista de canais" scroll_to_bottom: "Rolar até o final" scroll_to_new_messages: "Ver novas mensagens" @@ -122,14 +121,16 @@ pt: about: Sobre members: Membros settings: Configurações - channel_edit_name_modal: - title: Editar nome + new_message_modal: + no_items: "Sem itens" + channel_edit_name_slug_modal: input_placeholder: Adicionar um nome - description: Dê um breve nome descritivo ao seu canal + slug: Slug do canal (opcional) direct_message_creator: title: Nova Mensagem prefix: "Para:" create_channel: + slug: "Slug do canal (opcional)" title: "Novo canal" type: "Tipo" types: @@ -138,11 +139,16 @@ pt: composer: italic_text: "texto em itálico" bold_text: "texto em negrito" + send: "Enviar" notification_levels: never: "Nunca" settings: + auto_join_users_info: "Verificar de hora em hora quais os utilizadores que estiveram activos nos últimos 3 meses. Adicione-os a este canal se tiverem acesso à categoria %{category} ." + auto_join_users_info_no_category: "Verificar de hora em hora quais os utilizadores que estiveram activos nos últimos 3 meses. Adicione-os a este canal se tiverem acesso à categoria seleccionada." follow: "Entrar" followed: "Juntou-se" + threading_enabled: "Ativado" + threading_disabled: "Desativado" notifications: "Notificações" preview: "Pré-visualização" save: "Guardar" @@ -174,6 +180,21 @@ pt: activities: "Atividades" flags: "Sinalizações" symbols: "Símbolos" + thread: + title: "Título" + replies: + one: "%{count} resposta" + other: "%{count} respostas" + settings: "Configurações" + last_reply: "última resposta" + notifications: + regular: + title: "Normal" + tracking: + title: "A Seguir" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Nova Mensagem" cancel: "Cancelar" @@ -185,7 +206,3 @@ pt: 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 index 80851cce379..e1605f44375 100644 --- a/plugins/chat/config/locales/client.pt_BR.yml +++ b/plugins/chat/config/locales/client.pt_BR.yml @@ -15,6 +15,7 @@ pt_BR: actions: chat_channel_status_change: "Status do canal de chat alterado" chat_channel_delete: "Canal de chat excluído" + chat_auto_remove_membership: "Assinaturas removidas automaticamente dos canais" api: scopes: descriptions: @@ -39,8 +40,8 @@ pt_BR: 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." + one: "Você está movendo %{count} mensagem. Selecione o canal de destino. Uma mensagem de placeholder será criada no canal %{channelTitle} para indicar que esta mensagem foi movida. Note que as respostas não serão movidas junto para o novo canal, e mensagens no antigo canal não serão mais exibidas como respostas às mensagens movidas." + other: "Você está movendo %{count} mensagens. Selecione o canal de destino. Uma mensagem de placeholder será criada no canal %{channelTitle} para indicar que estas mensagens foram movidas. Note que as respostas não serão movidas junto para o novo canal, e mensagens no antigo canal não serão mais exibidas como respostas às mensagens movidas." confirm_move: "Mover mensagens" channel_settings: title: "Definições do canal" @@ -71,7 +72,7 @@ pt_BR: instructions: "Fechar o canal impede que usuários(as) não funcionários(as) 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.

    " + instructions: "

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

    Tem certeza que quer excluir permanentemente o canal? Para confirmar, escreva o nome do canal no campo 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." @@ -81,8 +82,8 @@ pt_BR: click_to_join: "Clique aqui para visualizar os canais disponíveis." close: "Fechar" collapse: "Recolher gaveta de chat" + expand: "Expandir 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" @@ -97,6 +98,11 @@ pt_BR: never: "Nunca" title: "Notificações por e-mail" when_away: "Só quando estiver ausente" + header_indicator_preference: + title: "Mostrar indicador de atividade no cabeçalho" + all_new: "Todas as Novas Mensagens" + dm_and_mentions: "Mensagens Diretas e Menções" + never: "Nunca" enable: "Ativar chat" flag: "Sinalizar" emoji: "Inserir emoji" @@ -106,53 +112,51 @@ pt_BR: in_reply_to: "Em resposta a" heading: "Chat" join: "Participar" - new_messages: "novas mensagens" + last_visit: "último acesso" + summarization: + title: "Resumir mensagens" + description: "Selecione uma opção abaixo para resumir a conversa enviada durante o período desejado." + summarize: "Resumir" + since: + one: "Última hora" + other: "Últimas %{count} horas" mention_warning: dismiss: "ignorar" - cannot_see: - one: "%{username} não pode acessar este canal e não recebeu notificação." - other: "%{username} e %{others} não podem acessar este canal e não receberam notificação." + cannot_see: "%{username} não pode acessar este canal e não foi notificado." + cannot_see_multiple: + one: "%{username} e %{count} outro usuário não podem acessar este canal e não foram notificados" + other: "%{username} e %{count} outros usuários não podem acessar este canal e não foram notificados" invitations_sent: one: "Convite enviado" other: "Convites enviados" invite: "convidar para canal" - without_membership: - one: "%{username} não entrou neste canal." - other: "%{username} e %{others} não entraram neste canal." - group_mentions_disabled: - one: "%{group_name} não permite menções" - other: "%{group_name} e %{others} não permitem menções" - too_many_members: - one: "%{group_name} tem membros demais. Ninguém foi notificado" - other: "%{group_name} e %{others} têm membros demais. Ninguém foi notificado" - warning_multiple: - one: "mais %{count}" - other: "mais %{count}" + without_membership: "%{username} não entrou neste canal." + without_membership_multiple: + one: "%{username} e %{count} outro usuário não se juntaram a este canal" + other: "%{username} e %{count} outros usuários não se juntaram a este canal" + group_mentions_disabled: "%{group_name} não permite menções." + group_mentions_disabled_multiple: + one: "%{group_name} e %{count} outro grupo não permitem menções" + other: "%{group_name} e %{count} outros grupos não permitem menções" + too_many_members: "%{group_name} tem membros demais. Ninguém foi notificado." + too_many_members_multiple: + one: "%{group_name} e %{count} outro grupo têm membros demais. Ninguém foi notificado." + other: "%{group_name} e %{count} outros grupos têm membros demais. Ninguém foi notificado." groups: header: some: "Alguns usuários não serão notificados" all: "Ninguém será notificado" - unreachable: - one: "@%{group} não permite menções" - other: "@%{group} e @%{group_2} não permitem menções" - unreachable_multiple: "@%{group} e outros %{count} não permitem menções" - too_many_members: - one: "Mencionar @%{group} excede o %{notification_limit} de %{limit}" - other: "Mencionar ambos @%{group} ou @%{group_2} excede o %{notification_limit} de %{limit}" - too_many_members_multiple: "Esses %{count} grupos excedem o %{notification_limit} de %{limit}" - users_limit: - one: "%{count} usuário" - other: "%{count} usuários" - notification_limit: "limite de notificações" - too_many_mentions: "Esta mensagem excede o %{notification_limit} de %{limit}" - mentions_limit: - one: "%{count} menção" - other: "%{count} menções" + unreachable_1: "@%{group} não permite menções." + unreachable_2: "@%{group1} e @%{group2} não permitem menções" + unreachable_multiple: + one: "@%{group} e %{count} outro grupo não permitem menções" + other: "@%{group} e %{count} outros grupos não permitem menções" 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." + kicked_from_channel: "Você não pode mais acessar este 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" @@ -164,10 +168,16 @@ pt_BR: 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_channel: "Converse em %{channelName}" + placeholder_thread: "Converse no tópico" + placeholder_users: "Conversar com %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Canal arquivado, não é possível enviar novas mensagens no momento." + closed: "Canal fechado, não é possível enviar novas mensagens no momento." + read_only: "Canal somente para leitura, não é possível enviar novas mensagens no momento." placeholder_silenced: "Você não pode enviar mensagens neste momento." - placeholder_start_conversation: Iniciar uma conversa com %{usernames} + placeholder_start_conversation: "Iniciar uma conversa com ..." + placeholder_start_conversation_users: "Iniciar uma conversa com %{commaSeparatedUsernames}" remove_upload: "Remover arquivo" react: "Reagir com emoji" reply: "Responder" @@ -183,8 +193,8 @@ pt_BR: restore: "Restaurar mensagem excluída" save: "Salvar" select: "Selecionar" - silence: "Silenciar usuário(a)" return_to_list: "Retornar para lista de canais" + return_to_threads_list: "Retornar às discussões em andamento" scroll_to_bottom: "Rolar para a parte inferior" scroll_to_new_messages: "Ver novas mensagens" sound: @@ -239,10 +249,10 @@ pt_BR: about: Sobre members: Membros settings: Definições - channel_edit_name_modal: - title: Editar Nome - input_placeholder: Adicionar um Nome - description: Dê um nome breve e descritivo ao seu canal + new_message_modal: + no_items: "Nenhum item" + channel_edit_name_slug_modal: + name: Nome do canal channel_edit_description_modal: title: Editar descrição input_placeholder: Adicione uma descrição @@ -252,9 +262,6 @@ pt_BR: 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 @@ -264,18 +271,10 @@ pt_BR: create_channel: auto_join_users: public_category_warning: "%{category} é uma categoria pública. Deseja adicionar automaticamente todos os(as) usuários(as) recentemente ativos(as) a este canal?" - warning_groups: - one: Adicionar automaticamente %{members_count} usuários(as) de %{group}? - other: Adicionar automaticamente %{members_count} usuários(as) de %{group} e %{group_2}? - warning_multiple_groups: Adicionar automaticamente %{members_count} usuários(as) 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(as) usuários(as) em %{hint} terão acesso a este canal de acordo com as configurações de segurança - other: Os(as) usuários(as) em %{hint} e %{hint_2} terão acesso a este canal de acordo com as configurações de segurança - hint_multiple_groups: Os(as) usuários(as) 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" @@ -288,15 +287,13 @@ pt_BR: type: "Mensagem de chat" reactions: only_you: "Você reagiu com :%{emoji}:" - and_others: "Você, %{usernames} reagiram 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}:" + single_user: "%{username} reagiu com :%{emoji}:" composer: toggle_toolbar: "Ativar/desativar barra de ferramentas" italic_text: "texto enfatizado" bold_text: "texto forte" code_text: "texto do código" + send: "Enviar" quote: original_channel: 'Originalmente enviado em %{channel}' copy_success: "Citação do chat copiada para área de transferência" @@ -307,15 +304,16 @@ pt_BR: settings: channel_wide_mentions_label: "Permitir menções @all e @here" channel_wide_mentions_description: "Permitir que os usuários notifiquem todos os membros de #%{channel} com @all ou apenas aqueles que estão ativos no momento com @here" + channel_threading_label: "Discussões" auto_join_users_label: "Adicionar usuários(as) automaticamente" - auto_join_users_info: "Verifique a cada hora quais usuários(as) estiveram ativos(as) nos últimos três meses e, se tiverem acesso à categria %{category}, adicione a este canal." - enable_auto_join_users: "Adicionar automaticamente todos os usuários ativos recentemente" auto_join_users_warning: "Todos(as) os(as) usuários(as) 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" + threading_enabled: "Ativado" + threading_disabled: "Desativado(a)" muted_on: "Ligado" muted_off: "Desligado" notifications: "Notificações" @@ -324,9 +322,10 @@ pt_BR: saved: "Salvou" unfollow: "Sair" admin_title: "Administrador(a)" - retention_info: "O histórico do canal será salvo por %{days} dias." admin: title: "Chat" + export_messages: + title: "Exportar mensagens do chat" direct_messages: title: "Chat pessoal" new: "Criar um chat pessoal" @@ -389,9 +388,6 @@ pt_BR: 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." 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." @@ -413,6 +409,27 @@ pt_BR: symbols: "Símbolos" search_placeholder: "Pesquisar por nome de emoji e codinomes..." no_results: "Nenhum resultado" + thread: + title: "Título" + default_title: "Tópico" + replies: + one: "%{count} resposta" + other: "%{count} respostas" + label: Tópico + original_message: + started_by: "Iniciado por" + settings: "Definições" + last_reply: "última resposta" + notifications: + regular: + title: "Normal" + tracking: + title: "Monitorando" + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Tópico Aberto" draft_channel_screen: header: "Novas mensagens" cancel: "Cancelar" @@ -457,9 +474,8 @@ pt_BR: transcript: view: "Ver transcrição de mensagens anteriores" types: - reviewable_chat_message: + chat_reviewable_message: title: "Mensagem de chat sinalizada" - flagged_by: "Sinalizada por" keyboard_shortcuts_help: chat: title: "Chat" @@ -472,6 +488,7 @@ pt_BR: composer_code: "%{shortcut} Código (somente compositor)" drawer_open: "%{shortcut} Abrir a gaveta do chat" drawer_close: "%{shortcut} Fechar a gaveta do chat" + mark_all_channels_read: "%{shortcut} Marcar todos os canais como lidos" topic_statuses: chat: help: "O chat está ativado para este tópico" @@ -490,3 +507,7 @@ pt_BR: 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" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.ro.yml b/plugins/chat/config/locales/client.ro.yml index 8de377d70c6..6c5d2e91d3e 100644 --- a/plugins/chat/config/locales/client.ro.yml +++ b/plugins/chat/config/locales/client.ro.yml @@ -6,6 +6,11 @@ ro: js: + admin: + logs: + staff_actions: + actions: + chat_auto_remove_membership: "Membrii sunt eliminați automat din canale" chat: create: "Creează" cancel: "Anulare" @@ -14,21 +19,33 @@ ro: add: "Adaugă" join: "Alătură-te" leave: "Părăsește" + channel_delete: + instructions: "

    Șterge canalul %{name} și istoricul de chat. Toate mesajele și datele asociate, cum ar fi reacțiile și încărcările, vor fi șterse permanent. Dacă doriți să păstrați istoricul canalului și să-l decomisionați, s-ar putea să doriți să arhivați canalul în schimb.

    Sunteți sigur că doriți să ștergeți permanent canalul? Pentru a confirma, tastați numele canalului în caseta de mai jos.

    " close: "Închide sondajul" + expand: "Extinde sertarul de chat" delete: "Șterge" muted: "silențios" joined: "înscris" email_frequency: never: "Niciodată" + header_indicator_preference: + title: "Afișați indicatorul de activitate în antet" + all_new: "Toate mesajele noi" + dm_and_mentions: "Mesaje directe și mențiuni" + never: "Niciodată" flag: "Marchează cu marcaj de avertizare" join: "Alătură-te" + last_visit: "ultima vizită" + summarization: + title: "Rezumați mesajele" + description: "Selectați o opțiune de mai jos pentru a rezuma conversația trimisă în intervalul de timp dorit." + summarize: "Rezumat" mention_warning: dismiss: "renunță" - groups: - users_limit: - one: "%{count} utilizator" - few: "%{count} utilizatori" - other: "%{count} utilizatori" + cannot_see: "%{username} nu poate accesa acest canal și nu a fost notificat." + without_membership: "%{username} nu s-a alăturat acestui canal." + group_mentions_disabled: "%{group_name} nu permite mențiuni." + too_many_members: "%{group_name} are prea mulți membri. Nimeni nu a fost anunțat." reply: "Răspunde" edit: "Modifică" rebake_message: "Reconstruieşte HTML" @@ -69,11 +86,13 @@ ro: composer: italic_text: "text italic" bold_text: "text aldin" + send: "Trimite" notification_levels: never: "Niciodată" settings: follow: "Alătură-te" followed: "Înscris" + threading_enabled: "Activat" notifications: "Notificări" preview: "Previzualizează" save: "Salvare" @@ -101,6 +120,19 @@ ro: emoji_picker: objects: "Obiecte" flags: "Marcaje de avertizare" + thread: + title: "Titlu" + replies: + one: "%{count} răspuns" + few: "%{count} răspunsuri" + other: "%{count} răspunsuri" + settings: "Opțiuni" + last_reply: "Ultimul răspuns" + notifications: + regular: + title: "Normal" + tracking: + title: "Urmărire" draft_channel_screen: header: "Mesaj nou" cancel: "Anulare" @@ -112,7 +144,3 @@ ro: 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 index 11390e67359..6d7039ae2c9 100644 --- a/plugins/chat/config/locales/client.ru.yml +++ b/plugins/chat/config/locales/client.ru.yml @@ -38,11 +38,6 @@ ru: 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: "Настройки канала" @@ -73,7 +68,6 @@ ru: instructions: "Закрытие канала; запрет пользователям, не являющихся сотрудниками, отправлять новые или редактировать существующие сообщения. Действительно закрыть этот канал?" channel_delete: title: "Удалить канал" - instructions: "

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

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

    " confirm: "Я понимаю последствия, удалить канал" confirm_channel_name: "Введите название канала" process_started: "Процесс удаления канала запущен. Это окно закроется в ближайшее время, вы больше не увидите удалённый канал." @@ -84,7 +78,6 @@ ru: close: "Закрыть" collapse: "Свернуть чат" confirm_flag: "Действительно пожаловаться на сообщение пользователя %{username}?" - deleted: "Сообщение было удалено. [view]" hidden: "Сообщение было скрыто. [view]" delete: "Удалить" edited: "Отредактировано" @@ -99,6 +92,8 @@ ru: never: "Никогда" title: "Настройка почтовых уведомлений" when_away: "Если вы находитесь офлайн" + header_indicator_preference: + never: "Никогда" enable: "Включить чат" flag: "Пожаловаться" emoji: "Вставить эмодзи" @@ -108,68 +103,21 @@ ru: in_reply_to: "В ответ на" heading: "Чат" join: "Подписаться" - new_messages: "Новые сообщения" + last_visit: "последнее посещение" + summarization: + summarize: "Сводка" mention_warning: dismiss: "Отклонить" - cannot_see: - one: "%{username} и еще %{others} не имеют доступа к этому каналу и не были уведомлены." - few: "%{username} и еще %{others} не имеют доступа к этому каналу и не были уведомлены." - many: "%{username} и еще %{others} не имеют доступа к этому каналу и не были уведомлены." - other: "%{username} и еще %{others} не имеют доступа к этому каналу и не были уведомлены." invitations_sent: one: "Приглашение отправлено" few: "Приглашения отправлены" many: "Приглашений отправлены" other: "Приглашений отправлены" invite: "Пригласить в канал" - without_membership: - one: "%{username} и еще %{others} не являются участниками канала." - few: "%{username} и еще %{others} не являются участниками канала." - many: "%{username} и еще %{others} не являются участниками канала." - other: "%{username} и еще %{others} не являются участниками канала." - group_mentions_disabled: - one: "Группа «%{group_name}» и еще %{others} не разрешают упоминания" - few: "Группа «%{group_name}» и еще %{others} не разрешают упоминания" - many: "Группа «%{group_name}» и еще %{others} не разрешают упоминания" - other: "Группа «%{group_name}» и еще %{others} не разрешают упоминания" - too_many_members: - one: "В группе «%{group_name}» и еще %{others} слишком много участников. Никто не был уведомлен." - few: "В группе «%{group_name}» и еще %{others} слишком много участников. Никто не был уведомлен." - many: "В группе «%{group_name}» и еще %{others} слишком много участников. Никто не был уведомлен." - other: "В группе «%{group_name}» и еще %{others} слишком много участников. Никто не был уведомлен." - warning_multiple: - one: "еще %{count}" - few: "еще %{count}" - many: "еще %{count}" - other: "еще %{count}" groups: header: some: "Некоторые пользователи не будут уведомлены" all: "Никто не будет уведомлен" - unreachable: - one: "Группа «@%{group}» не разрешает упоминания" - few: "Группы «@%{group}» и «@%{group_2}» не разрешают упоминания" - many: "Группы «@%{group}» и «@%{group_2}» не разрешают упоминания" - other: "Группы «@%{group}» и «@%{group_2}» не разрешают упоминания" - unreachable_multiple: "Группа «@%{group}» и еще %{count} не разрешают упоминания" - too_many_members: - one: "Упоминание группы «@%{group}» превышает %{notification_limit} (%{limit})" - few: "Упоминание групп «@%{group}» и «%{group_2}» превышает %{notification_limit} (%{limit})" - many: "Упоминание групп «@%{group}» и «%{group_2}» превышает %{notification_limit} (%{limit})" - other: "Упоминание групп «@%{group}» и «%{group_2}» превышает %{notification_limit} (%{limit})" - too_many_members_multiple: "Группы (%{count}) превышают %{notification_limit} (%{limit})" - users_limit: - one: "%{count} пользователь" - few: "%{count} пользователя" - many: "%{count} пользователей" - other: "%{count} пользователя" - notification_limit: "лимит уведомлений" - too_many_mentions: "Сообщение превышает %{notification_limit} (%{limit})" - mentions_limit: - one: "%{count} упоминание" - few: "%{count} упоминания" - many: "%{count} упоминаний" - other: "%{count} упоминания" aria_roles: header: "Заголовок чата" composer: "Редактор чата" @@ -186,10 +134,9 @@ ru: close_full_page: "Закрыть полноэкранный чат" open_message: "Открыть сообщение в чате" placeholder_self: "Напишите что-нибудь" - placeholder_others: "Чат с %{messageRecipient}" - placeholder_new_message_disallowed: "Канал %{status}, в данный момент вы не можете отправлять новые сообщения." + placeholder_users: "Чат с %{commaSeparatedNames}" placeholder_silenced: "В настоящее время вы не можете отправлять сообщения." - placeholder_start_conversation: Начать беседу с пользователем %{usernames} + placeholder_start_conversation_users: "Начать беседу с пользователем %{commaSeparatedUsernames}" remove_upload: "Удалить файл" react: "Реакция с помощью эмодзи" reply: "Ответить" @@ -205,7 +152,6 @@ ru: restore: "Восстановить удаленное сообщение" save: "Сохранить" select: "Выбрать" - silence: "Заморозить пользователя" return_to_list: "Вернуться к списку каналов" scroll_to_bottom: "Прокрутка вниз" scroll_to_new_messages: "Новые сообщения" @@ -261,6 +207,10 @@ ru: about: Информация members: Участники settings: Настройки + new_message_modal: + no_items: "Нет компонентов" + channel_edit_name_slug_modal: + name: Название канала channel_edit_description_modal: title: Изменить описание input_placeholder: Добавить описание @@ -270,9 +220,6 @@ ru: prefix: "Кому:" no_results: Нет результатов selected_user_title: "Отменить выбор пользователя %{username}" - channel_selector: - title: "Перейти на канал" - no_channels: "Нет каналов, соответствующих вашему запросу" channel: no_memberships: На этом канале нет участников no_memberships_found: Участники не найдены @@ -284,22 +231,10 @@ ru: 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: "Название канала" @@ -312,15 +247,13 @@ ru: 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}:" + single_user: "Пользователи %{username} отреагировали при помощи эмодзи %{emoji}:" composer: toggle_toolbar: "Переключить панель инструментов" italic_text: "текст, выделенный курсивом" bold_text: "Жирный" code_text: "Код" + send: "Отправить" quote: original_channel: 'Первоначально отправлено в %{channel}' copy_success: "Цитата из чата скопирована в буфер обмена" @@ -332,14 +265,14 @@ ru: channel_wide_mentions_label: "Разрешить упоминания @all и @here" channel_wide_mentions_description: "Разрешить пользователям уведомлять всех в канале #%{channel} (@all) и только активных участников (@here)" auto_join_users_label: "Автоматически добавлять пользователей" - auto_join_users_info: "Каждый час проверять, какие пользователи были активны за последние три месяца, и добавлять их в этот канал, если у них есть доступ к категории «%{category}»." - enable_auto_join_users: "Автоматически добавлять всех активных пользователей" auto_join_users_warning: "Любой пользователь, не являющийся участником этого канала, но имеющий доступ к разделу %{category}, будет автоматически подключён. Продолжить?" desktop_notification_level: "Уведомления на рабочем столе" follow: "Подписаться" followed: "Подписан" mobile_notification_level: "Мобильные push-уведомления" mute: "Отключить канал" + threading_enabled: "Включён" + threading_disabled: "Отключен" muted_on: "Включено" muted_off: "Выключено" notifications: "Уведомления" @@ -348,7 +281,6 @@ ru: saved: "Сохранено" unfollow: "Отписаться" admin_title: "Администрирование" - retention_info: "История чата будет храниться %{days} сут." admin: title: "Чат" direct_messages: @@ -421,9 +353,6 @@ ru: few: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователя" many: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" other: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" - retention_reminders: - public: "История канала хранится %{days} дней." - dm: "История личного чата хранится %{days} дней." flags: off_topic: "Это сообщение не имеет отношения к текущему обсуждению, как указано в названии канала, и, вероятно, его следует переместить в другое место." inappropriate: "Это сообщение содержит контент, который обоснованно можно считать оскорбительным или нарушающим рекомендации для сообщества." @@ -445,6 +374,25 @@ ru: symbols: "Символы" search_placeholder: "Поиск по названию эмодзи и псевдониму…" no_results: "Нет результатов" + thread: + title: "Название" + replies: + one: "%{count} ответ" + few: "%{count} ответа" + many: "%{count} ответ" + other: "%{count} ответ" + settings: "Настройки" + last_reply: "последний ответ" + notifications: + regular: + title: "Обычный" + tracking: + title: "В отслеживаемых" + participants_other_count: + one: "+%{count}" + few: "+%{count}" + many: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Новое сообщение" cancel: "Отмена" @@ -489,9 +437,8 @@ ru: transcript: view: "Просмотр предыдущих сообщений" types: - reviewable_chat_message: + chat_reviewable_message: title: "Сообщение на премодерации" - flagged_by: "Кто пожаловался" keyboard_shortcuts_help: chat: title: "Чат" @@ -524,3 +471,7 @@ ru: few: "Уведомления чата —%{count} непрочитанных уведомления" many: "Уведомления чата —%{count} непрочитанных уведомлений" other: "Уведомления чата —%{count} непрочитанных уведомлений" + styleguide: + sections: + chat: + title: Чат diff --git a/plugins/chat/config/locales/client.sk.yml b/plugins/chat/config/locales/client.sk.yml index 8eb983411a1..060dc3a4b97 100644 --- a/plugins/chat/config/locales/client.sk.yml +++ b/plugins/chat/config/locales/client.sk.yml @@ -6,6 +6,11 @@ sk: js: + admin: + logs: + staff_actions: + actions: + chat_auto_remove_membership: "Automatické odstránenie členstiev z kanálov" chat: create: "Vytvoriť" cancel: "Zrušiť" @@ -14,29 +19,53 @@ sk: add: "Pridať" join: "Pridať sa" leave: "Opustiť" + channel_delete: + instructions: "

    Odstráni kanál %{name} a históriu chatu. Všetky správy a súvisiace údaje, ako sú reakcie a médiá, budú natrvalo vymazané. Ak chcete zachovať históriu kanála a vyradiť ho z prevádzky, môžete namiesto toho kanál archivovať.

    Ste si istí, že chcete kanál natrvalo odstrániť? Pre potvrdenie zadajte názov kanála do poľa nižšie.

    " close: "Zavrieť" + expand: "Rozbaliť chat" delete: "Odstrániť" muted: "ignorovaní" joined: "vytvorený" email_frequency: never: "Nikdy" + header_indicator_preference: + title: "Zobrazenie indikátora aktivity v záhlaví" + all_new: "Všetky nové správy" + dm_and_mentions: "Súkromné správy a zmienky" + never: "Nikdy" flag: "Označenie" join: "Pridať sa" + last_visit: "posledná návševa" + summarization: + title: "Zhrnutie správ" + description: "Ak chcete zhrnúť konverzáciu odoslanú počas požadovaného časového obdobia, vyberte nižšie uvedenú možnosť." + summarize: "Zhrnúť" mention_warning: dismiss: "zahodiť" + cannot_see: "%{username} nemá prístup k tomuto kanálu a nebol upozornený." groups: - users_limit: - one: "%{count} používateľ" - few: "%{count} používatelia" - many: "%{count} používateľov" - other: "%{count} používateľov" + unreachable_1: "@%{group} nepovoľuje označovanie." + unreachable_2: "@%{group1} a @%{group2} nepovoľujú oznčovanie." + kicked_from_channel: "K tomuto kanálu už nemáte prístup." + placeholder_channel: "Chat v %{channelName}" + placeholder_thread: "Chatovať vo vlákne" + placeholder_users: "Chatovať s %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Kanál je archivovaný, nie je možné posielať nové správy." + closed: "Kanál je zatvorený, nie je možné posielať nové správy." + read_only: "Kanál je len na čítanie, nie je možné posielať nové správy." + placeholder_start_conversation: "Začnite konverzáciu s ..." + placeholder_start_conversation_users: "Začnite konverzáciu s %{commaSeparatedUsernames}" reply: "Odpoveď" edit: "Upraviť" rebake_message: "Pregenerovať HTML" bookmark_message: "Záložka" save: "Uložiť" + return_to_threads_list: "Návrat k prebiehajúcim diskusiám" sounds: none: "Žiadny" + upload_to_channel: "Nahrať do %{title}" + upload_to_thread: "Nahrať do vlákna" exit: "späť" channel_status: closed: "Zatvorené" @@ -58,28 +87,61 @@ sk: about: O stránke members: Členovia settings: Nastavenia + new_message_modal: + title: Odoslať správu + add_user_long: shift + klik alebo shift + enterPridať @%{username} + add_user_short: Pridať používateľa + open_channel: Otvoriť kanál + default_search_placeholder: "#a-channel, @somebody alebo čokoľvek." + default_channel_search_placeholder: "#a-channel" + default_user_search_placeholder: "@niekto" + user_search_placeholder: "...pridať ďalších používateľov" + disabled_user: "má vypnutý chat" + no_items: "Žiadne položky" + channel_edit_name_slug_modal: + title: Upraviť kanál + input_placeholder: Pridať meno + name: Názov kanála + slug: URL adresa kanála (voliteľné) direct_message_creator: title: Nová správa prefix: "Komu:" create_channel: + threading: + label: "Povoliť vytváranie podvlákien" + slug: "URL adresa kanála (voliteľné)" type: "Typ" types: category: "Kategória" topic: "Témy" + reactions: + you_and_single_user: "Vy a %{username} ste reagovali s :%{emoji}:" + you_and_multiple_users: "Vy, %{commaSeparatedUsernames} a %{username} ste reagovali s :%{emoji}:" + single_user: "%{username} reagoval s :%{emoji}:" composer: italic_text: "zdôraznený text" bold_text: "výrazný text" + send: "Odoslať" notification_levels: never: "Nikdy" settings: + channel_threading_label: "Vláknenie" follow: "Pridať sa" followed: "Vytvorený" + threading_enabled: "Povolené" + threading_disabled: "Vypnuté" notifications: "Upozornenia" preview: "Ukážka" save: "Uložiť" saved: "Uložené" unfollow: "Opustiť" admin_title: "Admin" + admin: + export_messages: + title: "Exportovať správy z chatu" + description: "Export je v súčasnosti obmedzený na 10000 najnovších správ za posledných 6 mesiacov." + create_export: "Vytvoriť export" + export_has_started: "Export sa začal. Keď bude hotový, budeme Vás informovať cez súkromnú správu." incoming_webhooks: back: "Späť" description: "Popis" @@ -98,9 +160,38 @@ sk: title: "Presuň na novú tému" existing_topic: title: "Presuň do existujúcej témy." + retention_reminders: + public_none: "História kanálov sa uchováva na neurčito." + dm_none: "História osobných chatov sa uchováva na neurčito." emoji_picker: objects: "Objekty" flags: "Označenia" + thread: + title: "Názov" + view_thread: Zobraziť vlákno + default_title: "Vlákno" + replies: + one: "%{count} odpoveď" + few: "%{count} odpovede" + many: "%{count} odpovedí" + other: "%{count} odpovedí" + label: Vlákno + close: "Uzavrieť vlákno" + original_message: + started_by: "Začal" + settings: "Nastavenia" + last_reply: "posledná odpoveď" + notifications: + regular: + title: "Normálne" + description: "Budete upozornení, ak niekto v tomto vlákne uvedie vaše @meno." + tracking: + title: "Pozorovať" + description: "Počet nových odpovedí na toto vlákno sa zobrazí v zozname vlákien a v kanáli. Budete upozornení, ak niekto v tomto vlákne spomenie vaše @meno." + threads: + open: "Otvoriť vlákno" + list: "Prebiehajúca diskusia" + none: "Nezúčastňujete sa na žiadnych vláknach v tomto kanáli." draft_channel_screen: header: "Nová správa" cancel: "Zrušiť" @@ -110,3 +201,15 @@ sk: fields: message: label: Správa + review: + types: + chat_reviewable_message: + title: "Označená správa" + keyboard_shortcuts_help: + chat: + keyboard_shortcuts: + mark_all_channels_read: "%{shortcut} Označiť všetky kanály ako prečítané" + styleguide: + sections: + chat: + title: Chat diff --git a/plugins/chat/config/locales/client.sl.yml b/plugins/chat/config/locales/client.sl.yml index bea08857986..c354770f945 100644 --- a/plugins/chat/config/locales/client.sl.yml +++ b/plugins/chat/config/locales/client.sl.yml @@ -21,16 +21,13 @@ sl: joined: "pridružen" email_frequency: never: "Nikoli" + header_indicator_preference: + never: "Nikoli" flag: "Prijavi" join: "Pridruži se" + last_visit: "zadnji obisk" mention_warning: dismiss: "opusti" - groups: - users_limit: - one: "%{count} uporabnik" - two: "%{count} uporabnika" - few: "%{count} uporabniki" - other: "%{count} uporabnikov" reply: "Odgovori" edit: "Uredi" rebake_message: "Obnovi HTML" @@ -75,6 +72,8 @@ sl: settings: follow: "Pridruži se" followed: "Pridružen" + threading_enabled: "Vključeno" + threading_disabled: "Onemogočeno" notifications: "Obvestila" preview: "Predogled" save: "Shrani" @@ -106,6 +105,25 @@ sl: activities: "Dejavnosti" flags: "Zastave" symbols: "Simboli" + thread: + title: "Naziv" + replies: + one: "%{count} odgovor" + two: "%{count} odgovora" + few: "%{count} odgovori" + other: "%{count} odgovorov" + settings: "Nastavitve" + last_reply: "zadnji odgovor" + notifications: + regular: + title: "Običajna" + tracking: + title: "Sledim" + participants_other_count: + one: "+%{count}" + two: "+%{count}" + few: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "Novo zasebno sporočilo" cancel: "Prekliči" @@ -117,7 +135,3 @@ sl: 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 index 1bf58248323..94d9d7e4891 100644 --- a/plugins/chat/config/locales/client.sq.yml +++ b/plugins/chat/config/locales/client.sq.yml @@ -17,13 +17,12 @@ sq: joined: "anëtarësuar" email_frequency: never: "Asnjëherë" + header_indicator_preference: + never: "Asnjëherë" flag: "Sinjalizoni" + last_visit: "vizita e fundit" mention_warning: dismiss: "hiqe" - groups: - users_limit: - one: "%{count} anëtar" - other: "%{count} anëtarë" reply: "Përgjigju" edit: "Redakto" rebake_message: "Rindërtoni HTML" @@ -65,6 +64,7 @@ sq: never: "Asnjëherë" settings: followed: "Anëtarësuar" + threading_enabled: "Aktivizuar" notifications: "Njoftimet" preview: "Parashikimi" save: "Ruaj" @@ -90,6 +90,18 @@ sq: title: "Transfero tek një Temë tjetër" emoji_picker: flags: "Sinjalizime" + thread: + title: "Titulli" + replies: + one: "%{count} përgjigje" + other: "%{count} përgjigje" + settings: "Rregullimet" + last_reply: "përgjigja e fundit" + notifications: + regular: + title: "Normal" + tracking: + title: "Në gjurmim" draft_channel_screen: header: "Mesazh i ri" cancel: "Anulo" diff --git a/plugins/chat/config/locales/client.sr.yml b/plugins/chat/config/locales/client.sr.yml index 888b9273bc5..0c9447d766a 100644 --- a/plugins/chat/config/locales/client.sr.yml +++ b/plugins/chat/config/locales/client.sr.yml @@ -19,14 +19,12 @@ sr: joined: "pridružen" email_frequency: never: "Nikad" + header_indicator_preference: + never: "Nikad" flag: "Označi Zastavom" + last_visit: "poslednja poseta" mention_warning: dismiss: "одбаци" - groups: - users_limit: - one: "{count} korisnik" - few: "%{count} korisnika" - other: "%{count} korisnika" reply: "Odgovori" edit: "Izmeni" rebake_message: "Popravi HTML" @@ -92,6 +90,15 @@ sr: title: "Prebaci u Postojeću Temu" emoji_picker: flags: "Zastave" + thread: + title: "Naslov" + settings: "Podešavanja" + last_reply: "poslednji odgovor" + notifications: + regular: + title: "Normalno" + tracking: + title: "Praćeno" draft_channel_screen: header: "Nova privatna poruka" cancel: "Odustani" diff --git a/plugins/chat/config/locales/client.sv.yml b/plugins/chat/config/locales/client.sv.yml index 9d8454fa73e..f4c08459d40 100644 --- a/plugins/chat/config/locales/client.sv.yml +++ b/plugins/chat/config/locales/client.sv.yml @@ -15,6 +15,7 @@ sv: actions: chat_channel_status_change: "Chattkanalens status har ändrats" chat_channel_delete: "Chattkanal raderad" + chat_auto_remove_membership: "Medlemskap tas automatiskt bort från kanaler" api: scopes: descriptions: @@ -39,8 +40,8 @@ sv: 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." + 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. Observera att svarskedjor inte kommer att bevaras i den nya kanalen, och meddelanden i den gamla kanalen kommer inte längre att visas som svar på flyttade meddelanden." + 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. Observera att svarskedjor inte kommer att bevaras i den nya kanalen, och meddelanden i den gamla kanalen kommer inte längre att visas som svar på flyttade meddelanden." confirm_move: "Flytta meddelanden" channel_settings: title: "Kanalinställningar" @@ -71,7 +72,7 @@ sv: 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.

    " + instructions: "

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

    Är du säker på att du vill ta bort kanalen permanent? Bekräfta genom att skriva in kanalens namn 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." @@ -81,8 +82,11 @@ sv: click_to_join: "Klicka här för att se tillgängliga kanaler." close: "Stäng" collapse: "Komprimera chattruta" + expand: "Expandera chattlådan" confirm_flag: "Är du säker på att du vill flagga %{username}:s meddelande?" - deleted: "Ett meddelande raderades. [view]" + deleted: + one: "Ett meddelande raderades. [view]" + other: "%{count} meddelanden raderades. [visa alla]" hidden: "Ett meddelande doldes. [view]" delete: "Radera" edited: "redigerad" @@ -97,6 +101,11 @@ sv: never: "Aldrig" title: "E-postaviseringar" when_away: "Endast när du är borta" + header_indicator_preference: + title: "Visa aktivitetsindikator i sidhuvudet" + all_new: "Alla nya meddelanden" + dm_and_mentions: "Direktmeddelanden och omnämnanden" + never: "Aldrig" enable: "Aktivera chatt" flag: "Flagga" emoji: "Infoga emoji" @@ -106,53 +115,111 @@ sv: in_reply_to: "Som svar på" heading: "Chatt" join: "Gå med" - new_messages: "nya meddelanden" + last_visit: "senaste besök" + summarization: + title: "Sammanfatta meddelanden" + description: "Välj ett alternativ nedan för att sammanfatta samtalet som skickades under den önskade tidsramen." + summarize: "Sammanfatta" + since: + one: "Senaste timmen" + other: "Senaste %{count} timmarna" mention_warning: dismiss: "avfärda" - cannot_see: - one: "%{username} kan inte komma åt den här kanalen och har inte aviserats." - other: "%{username} och %{others} kan inte komma åt den här kanalen och har inte aviserats." + cannot_see: "%{username} kan inte komma åt den här kanalen och har inte aviserats." + cannot_see_multiple: + one: "%{username} och %{count} annan användare kan inte komma åt den här kanalen och har inte aviserats." + other: "%{username} och %{count} andra användare kan inte komma åt den här kanalen och har inte aviserats." invitations_sent: one: "Inbjudan skickad" other: "Inbjudningar skickade" invite: "Bjud in till kanal" - without_membership: - one: "%{username} har inte gått med i den här kanalen." - other: "%{username} och %{others} har inte gått med i den här kanalen." - group_mentions_disabled: - one: "%{group_name} tillåter inte omnämnanden" - other: "%{group_name} och %{others} tillåter inte omnämnanden" - too_many_members: - one: "%{group_name} har för många medlemmar. Ingen aviserades" - other: "%{group_name} och %{others} har för många medlemmar. Ingen aviserades" - warning_multiple: - one: "%{count} annan" - other: "%{count} andra" + without_membership: "%{username} har inte gått med i den här kanalen." + without_membership_multiple: + one: "%{username} och %{count} annan användare har inte gått med i den här kanalen." + other: "%{username} och %{count} andra användare har inte gått med i den här kanalen." + group_mentions_disabled: "%{group_name} tillåter inte omnämnanden." + group_mentions_disabled_multiple: + one: "%{group_name} och ytterligare %{count} grupp tillåter inte omnämnanden." + other: "%{group_name} och ytterligare %{count} grupper tillåter inte omnämnanden." + too_many_members: "%{group_name} har för många medlemmar. Ingen aviserades." + too_many_members_multiple: + one: "%{group_name} och %{count} annan grupp har för många medlemmar. Ingen aviserades." + other: "%{group_name} och %{count} andra grupper har för många medlemmar. Ingen aviserades." groups: header: some: "Vissa användare kommer inte att aviseras" all: "Ingen kommer att aviseras" - unreachable: - one: "@%{group} tillåter inte omnämnanden" - other: "@%{group} och @%{group_2} tillåter inte omnämnanden" - unreachable_multiple: "@%{group} och ytterligare %{count} tillåter inte omnämnanden" - too_many_members: - one: "Att nämna @%{group} överstiger %{notification_limit} av %{limit}" - other: "Att nämna både @%{group} eller @%{group_2} överstiger %{notification_limit} av %{limit}" - too_many_members_multiple: "Dessa %{count} grupper överstiger %{notification_limit} av %{limit}" - users_limit: - one: "%{count} användare" - other: "%{count} användare" - notification_limit: "aviseringsgräns" - too_many_mentions: "Detta meddelande överstiger %{notification_limit} av %{limit}" - mentions_limit: - one: "%{count} omnämnande" - other: "%{count} omnämnanden" + unreachable_1: "@%{group} tillåter inte omnämnanden." + unreachable_2: "@%{group1} och @%{group2} tillåter inte omnämnanden." + unreachable_multiple: + one: "@%{group} och ytterligare %{count} grupp tillåter inte omnämnanden." + other: "@%{group} och ytterligare %{count} grupper tillåter inte omnämnanden." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Att nämna @{group1} överstiger aviseringsgränsen som är # användare.} + other {Att nämna @{group1} överstiger aviseringsgränsen som är # användare.} + } + } + false { + { notificationLimit, plural, + one {Att nämna @{group1} överstiger aviseringsgränsen som är # användare.} + other {Att nämna @{group1} överstiger aviseringsgränsen som är # användare.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Att nämna @{group1} och @{group2} överstiger aviseringsgränsen som är # användare.} + other {Att nämna @{group1} och @{group2} överstiger aviseringsgränsen som är # användare.} + } + } + false { + { notificationLimit, plural, + one {Att nämna @{group1} och @{group2} överstiger aviseringsgränsen som är # användare.} + other {Att nämna @{group1} och @{group2} överstiger aviseringsgränsen som är # användare.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Att nämna dessa {groupCount} grupper överstiger aviseringsgränsen som är # användare.} + other {Att nämna dessa {groupCount} grupper överstiger aviseringsgränsen som är # användare.} + } + } + false { + { notificationLimit, plural, + one {Att nämna dessa {groupCount} grupper överstiger aviseringsgränsen som är # användare.} + other {Att nämna dessa {groupCount} grupper överstiger aviseringsgränsen som är # användare.} + } + } + other {} + } + } + } + too_many_mentions: + one: "Detta meddelande överstiger aviseringsgränsen, som är %{count} omnämnande." + other: "Detta meddelande överstiger aviseringsgränsen, som är %{count} omnämnanden." + too_many_mentions_admin: + one: 'Det här meddelandet överstiger aviseringsgränsen som är %{count} omnämnande.' + other: 'Det här meddelandet överstiger aviseringsgränsen som är %{count} omnämnanden.' 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." + kicked_from_channel: "Du har inte längre tillgång till den här kanalen." 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" @@ -164,10 +231,16 @@ sv: 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_channel: "Chatta i %{channelName}" + placeholder_thread: "Chatta i tråden" + placeholder_users: "Chatta med %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Kanalen är arkiverad, och du kan inte skicka nya meddelanden just nu." + closed: "Kanalen är stängd, och du kan inte skicka nya meddelanden just nu." + read_only: "Kanalen är lässkyddad, och 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} + placeholder_start_conversation: "Inled ett samtal med ..." + placeholder_start_conversation_users: "Inled ett samtal med %{commaSeparatedUsernames}" remove_upload: "Ta bort fil" react: "Reagera med emoji" reply: "Svara" @@ -183,8 +256,11 @@ sv: restore: "Återställ raderat meddelande" save: "Spara" select: "Välj" - silence: "Tysta användare" return_to_list: "Återgå till listan över kanaler" + return_to_threads_list: "Gå tillbaka till pågående diskussioner" + unread_threads_count: + one: "Du har %{count} oläst diskussion" + other: "Du har %{count} olästa diskussioner" scroll_to_bottom: "Scrolla till botten" scroll_to_new_messages: "Se nya meddelanden" sound: @@ -196,6 +272,8 @@ sv: title: "chatt" title_capitalized: "Chatt" upload: "Bifoga en fil" + upload_to_channel: "Ladda upp till %{title}" + upload_to_thread: "Ladda upp till tråd" uploaded_files: one: "%{count} fil" other: "%{count} filer" @@ -239,10 +317,23 @@ sv: about: Om members: Medlemmar settings: Inställningar - channel_edit_name_modal: - title: Redigera namn + new_message_modal: + title: Skicka meddelande + add_user_long: skift + klicka eller skift + enterLägg till @%{username} + add_user_short: Lägg till användare + open_channel: Öppna kanal + default_search_placeholder: "#a-kanal, @någon eller något" + default_channel_search_placeholder: "#en-kanal" + default_user_search_placeholder: "@någon" + user_search_placeholder: "...lägg till fler användare" + disabled_user: "har inaktiverat chatten" + no_items: "Inga objekt" + channel_edit_name_slug_modal: + title: Redigera kanal input_placeholder: Lägg till ett namn - description: Ge din kanal ett kort beskrivande namn + slug_description: Ett dynamiskt kanalfält används i URL:en istället för kanalnamnet + name: Kanalnamn + slug: Dynamiskt kanalfält (valfritt) channel_edit_description_modal: title: Redigera beskrivning input_placeholder: Lägg till en beskrivning @@ -252,9 +343,6 @@ sv: 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 @@ -262,23 +350,44 @@ sv: one: "%{count} medlem" other: "%{count} medlemmar" create_channel: + threading: + label: "Aktivera trådning" 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? + warning_1_group: + one: "Vill du automatiskt lägga till %{count} användare från %{group}?" + other: "Vill du automatiskt lägga till %{count} användare från %{group}?" + warning_2_groups: + one: "Vill du automatiskt lägga till %{count} användare från %{group1} och %{group2}?" + other: "Vill du automatiskt lägga till %{count} användare från %{group1} och %{group2}?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {Vill du automatiskt lägga till {userCount} användare från {groupName} och ytterligare {groupCount} grupp?} + other {Vill du automatiskt lägga till {userCount} användare från {groupName} och ytterligare {groupCount} grupp?} + } + } + other { + { userCount, plural, + one {Vill du automatiskt lägga till {userCount} användare från {groupName} och ytterligare {groupCount} grupper?} + other {Vill du automatiskt lägga till {userCount} användare från {groupName} och ytterligare {groupCount} grupper?} + } + } + } 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 + hint_1_group: 'Användare i %{group} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningar' + hint_2_groups: 'Användare i %{group1} och %{group2} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningar' + hint_multiple_groups: + one: 'Användare i %{group} och ytterligare %{count} grupp kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna' + other: 'Användare i %{group} och ytterligare %{count} grupper kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna' create: "Skapa kanal" description: "Beskrivning (valfritt)" name: "Kanalnamn" + slug: "Dynamiskt kanalfält (valfritt)" title: "Ny kanal" type: "Typ" types: @@ -288,15 +397,22 @@ sv: 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}:" + you_and_single_user: "Du och %{username} reagerade med :%{emoji}:" + you_and_multiple_users: "Du, %{commaSeparatedUsernames} och %{username} reagerade med :%{emoji}:" + you_multiple_users_and_more: + one: "Du, %{commaSeparatedUsernames} och %{count} till reagerade med :%{emoji}:" + other: "Du, %{commaSeparatedUsernames} och %{count} till reagerade med :%{emoji}:" + single_user: "%{username} reagerade med :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} och %{username} reagerade med :%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} och ytterligare %{count} reagerade med :%{emoji}:" + other: "%{commaSeparatedUsernames} och ytterligare %{count} reagerade med :%{emoji}:" composer: toggle_toolbar: "Växla verktygsfält" italic_text: "kursiv text" bold_text: "fet text" code_text: "kod text" + send: "Skicka" quote: original_channel: 'Ursprungligen skickad i %{channel}' copy_success: "Chattcitat kopierat till urklipp" @@ -307,15 +423,19 @@ sv: settings: channel_wide_mentions_label: "Tillåt @all och @here omnämnanden" channel_wide_mentions_description: "Tillåt användare att avisera alla #%{channel}-medlemmar med @all eller bara de som är aktiva för tillfället med @here" + channel_threading_label: "Trådning" + channel_threading_description: "När trådning är aktiverat kommer svar på ett chattmeddelande att skapa ett separat samtal som existerar parallellt med huvudkanalen." auto_join_users_label: "Lägg till användare automatiskt" - auto_join_users_info: "Kontrollera varje timme vilka användare som har varit aktiva under de senaste 3 månaderna och, om de har tillgång till kategorin %{category}, lägg till dem i den här kanalen." - enable_auto_join_users: "Lägg automatiskt till alla nyligen aktiva användare" + auto_join_users_info: "Kontrollera varje timme vilka användare som har varit aktiva under de senaste 3 månaderna. Lägg till dem till den här kanalen om de har tillgång till kategorin %{category}." + auto_join_users_info_no_category: "Kontrollera varje timme vilka användare som har varit aktiva under de senaste 3 månaderna. Lägg till dem till den här kanalen om de har tillgång till vald kategori." 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" + threading_enabled: "Aktiverad" + threading_disabled: "Inaktiverad" muted_on: "På" muted_off: "Av" notifications: "Aviseringar" @@ -324,9 +444,13 @@ sv: saved: "Sparad" unfollow: "Lämna" admin_title: "Admin" - retention_info: "Chatthistoriken sparas i %{days} dagar." admin: title: "Chatt" + export_messages: + title: "Exportera chattmeddelanden" + description: "Exporten är för närvarande begränsad till de 10 000 senaste meddelanden under de senaste 6 månaderna." + create_export: "Skapa export" + export_has_started: "Exporten har börjat. Du får ett PM när det är klart." direct_messages: title: "Personlig chatt" new: "Skapa en personlig chatt" @@ -390,8 +514,14 @@ sv: 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." + public_none: "Kanalhistoriken bevaras på obestämd tid." + public: + one: "Kanalhistoriken bevaras %{count} dag." + other: "Kanalhistoriken bevaras %{count} dagar." + dm_none: "Personlig chatthistorik bevaras på obestämd tid." + dm: + one: "Personlig chatthistorik bevaras %{count} dag." + other: "Personlig chatthistorik bevaras %{count} dagar." 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." @@ -413,6 +543,33 @@ sv: symbols: "Symboler" search_placeholder: "Sök efter emojinamn och alias..." no_results: "Inga resultat" + thread: + title: "Rubrik" + view_thread: Visa tråd + default_title: "Tråd" + replies: + one: "%{count} svar" + other: "%{count} svar" + label: Tråd + close: "Stäng tråd" + original_message: + started_by: "Startad av" + settings: "Webbplatsinställningar" + last_reply: "senaste svar" + notifications: + regular: + title: "Normal" + description: "Du aviseras om någon nämner ditt @namn i den här tråden." + tracking: + title: "Följer" + description: "Ett antal nya svar för denna tråd kommer att visas i trådlistan och kanalen. Du aviseras om någon nämner ditt @namn i denna tråd." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Öppna tråd" + list: "Pågående diskussioner" + none: "Du deltar inte i några trådar i den här kanalen." draft_channel_screen: header: "Nytt meddelande" cancel: "Avbryt" @@ -457,9 +614,8 @@ sv: transcript: view: "Visa tidigare meddelandens avskrift" types: - reviewable_chat_message: + chat_reviewable_message: title: "Flaggat chattmeddelande" - flagged_by: "Flaggat av" keyboard_shortcuts_help: chat: title: "Chatt" @@ -472,6 +628,7 @@ sv: composer_code: "%{shortcut} Kod (endast kompositör)" drawer_open: "%{shortcut} Öppna chattmenyn" drawer_close: "%{shortcut} Stäng chattmenyn" + mark_all_channels_read: "%{shortcut} Markera alla kanaler som lästa" topic_statuses: chat: help: "Chatt är aktiverat för detta ämne" @@ -490,3 +647,7 @@ sv: chat_notifications_with_unread: one: "Chattaviseringar - %{count} oläst avisering" other: "Chattaviseringar - %{count} olästa aviseringar" + styleguide: + sections: + chat: + title: Chatt diff --git a/plugins/chat/config/locales/client.sw.yml b/plugins/chat/config/locales/client.sw.yml index eafb4beb24e..784299bcd79 100644 --- a/plugins/chat/config/locales/client.sw.yml +++ b/plugins/chat/config/locales/client.sw.yml @@ -20,14 +20,13 @@ sw: joined: "alijiunga" email_frequency: never: "Kamwe" + header_indicator_preference: + never: "Kamwe" flag: "Bendera" join: "Jiunge" + last_visit: "Mara ya mwisho imetembelewa" mention_warning: dismiss: "ondosha..." - groups: - users_limit: - one: "Mtumiaji mmoja" - other: "%{count} watumiaji" reply: "Jibu" edit: "Hariri" rebake_message: "Tengeneza upya HTML" @@ -67,11 +66,14 @@ sw: composer: italic_text: "Maneno yaliyo tiliwa mkazo" bold_text: "Maneno yaliyokolezwa" + send: "Tuma" notification_levels: never: "Kamwe" settings: follow: "Jiunge" followed: "Alijiunga" + threading_enabled: "Imeruhusiwa" + threading_disabled: "Imezuiwa" notifications: "Taarifa" preview: "Kihakiki" save: "Hifadhi" @@ -99,6 +101,18 @@ sw: emoji_picker: objects: "Vitu" flags: "Bendera" + thread: + title: "Kichwa cha Habari" + replies: + one: "%{count} jibu" + other: "%{count} majibu" + settings: "Mipangilio" + last_reply: "jibu la mwisho" + notifications: + regular: + title: "Kawaida" + tracking: + title: "Fuatilia" draft_channel_screen: header: "Ujumbe Mpya" cancel: "Ghairi" diff --git a/plugins/chat/config/locales/client.te.yml b/plugins/chat/config/locales/client.te.yml index 8740839c870..efd4f209866 100644 --- a/plugins/chat/config/locales/client.te.yml +++ b/plugins/chat/config/locales/client.te.yml @@ -71,5 +71,11 @@ te: title: "ఇప్పటికే ఉన్న విషయానికి జరుపు" emoji_picker: flags: "కేతనాలు" + thread: + title: "శీర్షిక" + settings: "అమరికలు" + notifications: + tracking: + title: "గమనిస్తున్నారు" draft_channel_screen: cancel: "రద్దుచేయి" diff --git a/plugins/chat/config/locales/client.th.yml b/plugins/chat/config/locales/client.th.yml index 738d06e551f..23ecb93f0cb 100644 --- a/plugins/chat/config/locales/client.th.yml +++ b/plugins/chat/config/locales/client.th.yml @@ -21,13 +21,13 @@ th: joined: "สมัครสมาชิกเมื่อ" email_frequency: never: "ไม่เคย" + header_indicator_preference: + never: "ไม่เคย" flag: "ธง" join: "เข้าร่วม" + last_visit: "เยี่ยมชมครั้งล่าสุด" mention_warning: dismiss: "ซ่อน" - groups: - users_limit: - other: "%{count} ผู้ใช้" reply: "ตอบ" edit: "แก้ไข" bookmark_message: "บุ๊คมาร์ค" @@ -67,11 +67,13 @@ th: composer: italic_text: "ตัวอักษรเอียง" bold_text: "ตัวอักษรหนา" + send: "ส่ง" notification_levels: never: "ไม่เคย" settings: follow: "เข้าร่วม" followed: "สมัครสมาชิกเมื่อ" + threading_disabled: "ปิดใช้งานแล้ว" notifications: "การแจ้งเตือน" preview: "แสดงตัวอย่าง" save: "บันทึก" @@ -103,6 +105,17 @@ th: activities: "กิจกรรม" flags: "ธง" symbols: "สัญลักษณ์" + thread: + title: "ชื่อเรื่อง" + replies: + other: "%{count} ตอบ" + settings: "การตั้งค่า" + last_reply: "การตอบล่าสุด" + notifications: + regular: + title: "ปกติ" + tracking: + title: "ติดตาม" draft_channel_screen: header: "สร้างข้อความใหม่" cancel: "ยกเลิก" @@ -114,7 +127,3 @@ th: 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 index 91c50c2c40e..c8f770cfbba 100644 --- a/plugins/chat/config/locales/client.tr_TR.yml +++ b/plugins/chat/config/locales/client.tr_TR.yml @@ -15,6 +15,7 @@ tr_TR: actions: chat_channel_status_change: "Sohbet kanalı durumu değişti" chat_channel_delete: "Sohbet kanalı silindi" + chat_auto_remove_membership: "Üyelikler kanallardan otomatik olarak kaldırıldı" api: scopes: descriptions: @@ -39,8 +40,8 @@ tr_TR: move_to_channel: title: "Mesajları kanala taşı" instructions: - one: "%{count} mesajı taşıyorsunuz. Bir hedef kanal seçin. Bu mesajın taşındığını belirtmek için %{channelTitle} adlı kanalda bir yer tutucu mesaj oluşturulacak." - other: "%{count} mesajı taşıyorsunuz. Bir hedef kanal seçin. Bu mesajın taşındığını belirtmek için %{channelTitle} adlı kanalda bir yer tutucu mesaj oluşturulacak." + one: "%{count} mesajı taşıyorsunuz. Bir hedef kanal seçin. Bu mesajın taşındığını belirtmek için %{channelTitle} kanalında bir yer tutucu mesaj oluşturulacak. Yanıt zincirlerinin yeni kanalda korunmayacağını ve eski kanaldaki iletilerin artık taşınan iletilere yanıt veriyormuş gibi görünmeyeceğini unutmayın." + other: "%{count} mesajı taşıyorsunuz. Bir hedef kanal seçin. Bu mesajın taşındığını belirtmek için %{channelTitle} kanalında bir yer tutucu mesaj oluşturulacak. Yanıt zincirlerinin yeni kanalda korunmayacağını ve eski kanaldaki iletilerin artık taşınan iletilere yanıt veriyormuş gibi görünmeyeceğini unutmayın." confirm_move: "Mesajları Taşı" channel_settings: title: "Kanal ayarları" @@ -71,7 +72,7 @@ tr_TR: instructions: "Kanalın kapatılması, personel olmayan kullanıcıların yeni mesaj göndermesini veya mevcut mesajları düzenlemesini engeller. Bu kanalı kapatmak istediğinizden emin misiniz?" channel_delete: title: "Kanalı Sil" - instructions: "

    %{name} adlı kanalı ve sohbet geçmişini siler. Tepkiler ve yüklemeler gibi tüm mesajlar ve ilgili veriler kalıcı olarak silinir. Kanal geçmişini korumak ve kullanımdan kaldırmak istiyorsanız bunun yerine kanalı arşivlemek isteyebilirsiniz.

    Kanalı kalıcı olarak silmek istediğinizden emin misiniz? Onaylamak için aşağıdaki kutuya kanalın adını yazın.

    " + instructions: "

    %{name} kanalını ve sohbet geçmişini siler. Tepkiler ve yüklemeler gibi tüm mesajlar ve ilgili veriler kalıcı olarak silinecektir. Kanal geçmişini korumak ve devre dışı bırakmak istiyorsanız, bunun yerine kanalı arşivlemek isteyebilirsiniz.

    Kanalı kalıcı olarak silmek istediğinizden emin misiniz? Onaylamak için aşağıdaki kutuya kanalın adını yazın.

    " confirm: "Sonuçları anlıyorum, kanalı sil" confirm_channel_name: "Kanal adı girin" process_started: "Kanalı silme işlemi başladı. Bu mod kısa bir süre sonra kapanacak, silinen kanalı artık hiçbir yerde görmeyeceksiniz." @@ -81,8 +82,11 @@ tr_TR: click_to_join: "Mevcut kanalları görüntülemek için buraya tıklayın." close: "Kapat" collapse: "Sohbet Çekmecesini Daralt" + expand: "Sohbet Çekmecesini Genişlet" confirm_flag: "%{username} adlı kullanıcının mesajına bayrak eklemek istediğinizden emin misiniz?" - deleted: "Bir mesaj silindi. [görüntüleyin]" + deleted: + one: "Bir mesaj silindi. [görüntüleyin]" + other: "%{count} mesaj silindi. [tümünü görüntüle]" hidden: "Bir mesaj gizlendi. [görüntüleyin]" delete: "Sil" edited: "düzenlendi" @@ -97,6 +101,11 @@ tr_TR: never: "Asla" title: "E-posta Bildirimleri" when_away: "Sadece uzaktayken" + header_indicator_preference: + title: "Etkinlik göstergesini başlıkta göster" + all_new: "Tüm Yeni Mesajlar" + dm_and_mentions: "Direkt Mesajlar ve Bahsetmeler" + never: "Asla" enable: "Sohbeti etkinleştir" flag: "Bayrak ekle" emoji: "Emoji ekle" @@ -106,53 +115,111 @@ tr_TR: in_reply_to: "Şuna yanıt olarak:" heading: "Sohbet" join: "Katıl" - new_messages: "yeni mesajlar" + last_visit: "son ziyaret" + summarization: + title: "Mesajları özetle" + description: "İstediğiniz zaman diliminde gönderilen görüşmeyi özetlemek için aşağıdaki seçeneklerden birini belirleyin." + summarize: "Özetle" + since: + one: "Son bir saat" + other: "Son %{count} saat" mention_warning: dismiss: "kapat" - cannot_see: - one: "%{username} bu kanala erişemiyor ve bildirim almadı." - other: "%{username} ve %{others} bu kanala erişemiyor ve bildirim almadı." + cannot_see: "%{username} bu kanala erişemiyor ve bilgilendirilmedi." + cannot_see_multiple: + one: "%{username} ve diğer %{count} kullanıcı bu kanala erişemiyor ve bilgilendirilmediler." + other: "%{username} ve diğer %{count} kullanıcı bu kanala erişemiyor ve bilgilendirilmediler." invitations_sent: one: "Davet gönderildi" other: "Davet gönderildi" invite: "Kanala davet et" - without_membership: - one: "%{username} bu kanala katılmadı." - other: "%{username} ve %{others} bu kanala katılmadı." - group_mentions_disabled: - one: "%{group_name} bahsetmeye izin vermiyor" - other: "%{group_name} ve %{others} bahsetmeye izin vermiyor" - too_many_members: - one: "%{group_name} çok fazla üyeye sahip. Kimseye bildirim gönderilmedi" - other: "%{group_name} ve %{others} çok fazla üyeye sahip. Kimseye bildirim gönderilmedi" - warning_multiple: - one: "%{count} tane daha" - other: "%{count} tane daha" + without_membership: "%{username} bu kanala katılmadı." + without_membership_multiple: + one: "%{username} ve diğer %{count} kullanıcı bu kanala katılmadı." + other: "%{username} ve diğer %{count} kullanıcı bu kanala katılmadı." + group_mentions_disabled: "%{group_name} bahsetmeye izin vermiyor." + group_mentions_disabled_multiple: + one: "%{group_name} ve diğer %{count} grup bahsetmeye izin vermiyor." + other: "%{group_name} ve diğer %{count} grup bahsetmeye izin vermiyor." + too_many_members: "%{group_name} çok fazla üyeye sahip. Kimseye bildirim gönderilmedi." + too_many_members_multiple: + one: "%{group_name} ve diğer %{count} grubun çok fazla üyesi var. Kimse bilgilendirilmedi." + other: "%{group_name} ve diğer %{count} grubun çok fazla üyesi var. Kimse bilgilendirilmedi." groups: header: some: "Bazı kullanıcılara bildirim gönderilmeyecek" all: "Kimseye bildirim gönderilmeyecek" - unreachable: - one: "@%{group} bahsetmeye izin vermiyor" - other: "@%{group} ve @%{group_2} bahsetmeye izin vermiyor" - unreachable_multiple: "@%{group} ve %{count} grup daha bahsetmeye izin vermiyor" - too_many_members: - one: "@%{group} adlı gruptan bahsetmek %{limit} %{notification_limit} değerini aşıyor" - other: "Hem @%{group} hem de @%{group_2} adlı gruplardan bahsetmek %{limit} %{notification_limit} değerini aşıyor" - too_many_members_multiple: "Bu %{count} grup %{limit} %{notification_limit} değerini aşıyor." - users_limit: - one: "%{count} kullanıcı" - other: "%{count} kullanıcı" - notification_limit: "bildirim limiti" - too_many_mentions: "Bu mesaj, %{limit} %{notification_limit} değerini aşıyor" - mentions_limit: - one: "%{count} bahsetme" - other: "%{count} bahsetme" + unreachable_1: "@%{group} bahsetmeye izin vermiyor." + unreachable_2: "@%{group1} ve @%{group2} bahsetmeye izin vermiyor." + unreachable_multiple: + one: "@%{group} ve diğer %{count} grup bahsetmeye izin vermiyor." + other: "@%{group} ve diğer %{count} grup bahsetmeye izin vermiyor." + too_many_members_MF: | + { groupCount, plural, + =1 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {@{group1} adlı gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {@{group1} adlı gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + false { + { notificationLimit, plural, + one {@{group1} adlı gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {@{group1} adlı gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + other {} + } + } + =2 { + { isAdmin, select, + true { + { notificationLimit, plural, + one {@{group1} ve @{group2} adlı gruplardan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {@{group1} ve @{group2} adlı gruplardan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + false { + { notificationLimit, plural, + one {@{group1} ve @{group2} adlı gruplardan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {@{group1} ve @{group2} adlı gruplardan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + other {} + } + } + other { + { isAdmin, select, + true { + { notificationLimit, plural, + one {Bu {groupCount} gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {Bu {groupCount} gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + false { + { notificationLimit, plural, + one {Bu {groupCount} gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + other {Bu {groupCount} gruptan bahsetmek # kullanıcının bildirim limitini aşıyor.} + } + } + other {} + } + } + } + too_many_mentions: + one: "Bu mesaj, %{count} bahsetme bildirim sınırını aşıyor." + other: "Bu mesaj, %{count} bahsetme bildirim sınırını aşıyor." + too_many_mentions_admin: + one: 'Bu mesaj, %{count} bahsin bildirim sınırını aşıyor.' + other: 'Bu mesaj, %{count} bahsin bildirim sınırını aşıyor.' aria_roles: header: "Sohbet başlığı" composer: "Sohbet oluşturucu" channels_list: "Sohbet kanalları listesi" no_public_channels: "Hiçbir kanala katılmadınız." + kicked_from_channel: "Artık bu kanala erişemezsiniz." only_chat_push_notifications: title: "Yalnızca sohbet anlık bildirimleri gönder" description: "Tüm sohbet dışı anlık bildirimlerinin gönderilmesini engelle" @@ -164,10 +231,16 @@ tr_TR: close_full_page: "Tam ekran sohbeti kapat" open_message: "Mesajı sohbette aç" placeholder_self: "Bir şeyler not edin" - placeholder_others: "%{messageRecipient} ile sohbet et" - placeholder_new_message_disallowed: "Kanal %{status}, şu anda yeni mesaj gönderemezsiniz." + placeholder_channel: "%{channelName} kanalında sohbet et" + placeholder_thread: "Konuda sohbet et" + placeholder_users: "%{commaSeparatedNames} ile sohbet et" + placeholder_new_message_disallowed: + archived: "Kanal arşivlendi, şu anda yeni mesaj gönderemezsiniz." + closed: "Kanal kapalı, şu anda yeni mesaj gönderemezsiniz." + read_only: "Kanal salt okunurdur, şu anda yeni mesaj gönderemezsiniz." placeholder_silenced: "Şu anda mesaj gönderemezsiniz." - placeholder_start_conversation: '%{usernames} ile bir konuşma başlat' + placeholder_start_conversation: "Şu kişi ile bir sohbet başlatın:" + placeholder_start_conversation_users: "%{commaSeparatedUsernames} ile bir konuşma başlat" remove_upload: "Dosyayı kaldır" react: "Emoji ile tepki ver" reply: "Yanıtla" @@ -183,8 +256,11 @@ tr_TR: restore: "Silinen mesajı geri yükle" save: "Kaydet" select: "Seç" - silence: "Kullanıcıyı sustur" return_to_list: "Kanal listesine dön" + return_to_threads_list: "Devam eden tartışmalara dön" + unread_threads_count: + one: "Henüz okumadığınız %{count} tartışma var" + other: "Henüz okumadığınız %{count} tartışma var" scroll_to_bottom: "En alta kaydır" scroll_to_new_messages: "Yeni mesajlara bak" sound: @@ -196,6 +272,8 @@ tr_TR: title: "sohbet" title_capitalized: "Sohbet" upload: "Dosya ekle" + upload_to_channel: "%{title} başlığına yükle" + upload_to_thread: "Konuya yükle" uploaded_files: one: "%{count} dosya" other: "%{count} dosya" @@ -239,10 +317,23 @@ tr_TR: about: Hakkında members: Üyeler settings: Ayarlar - channel_edit_name_modal: - title: İsmi düzenle - input_placeholder: İsim ekle - description: Kanalınıza kısa ve açıklayıcı bir isim verin + new_message_modal: + title: Mesaj gönder + add_user_long: shift + tıklama veya shift + enter@%{username} ekleyin + add_user_short: Kullanıcı ekleyin + open_channel: Kanal açın + default_search_placeholder: "#bir-kanal, @biri veya herhangi bir şey" + default_channel_search_placeholder: "#bir-kanal" + default_user_search_placeholder: "@biri" + user_search_placeholder: "...daha fazla kullanıcı ekleyin" + disabled_user: "sohbeti devre dışı bıraktı" + no_items: "Öge yok" + channel_edit_name_slug_modal: + title: Kanalı düzenle + input_placeholder: Bir isim ekle + slug_description: URL'de kanal adı yerine bir kanal bilgisi kullanılmış + name: Kanal adı + slug: Kanal slug (isteğe bağlı) channel_edit_description_modal: title: Açıklamayı düzenle input_placeholder: Açıklama ekle @@ -252,9 +343,6 @@ tr_TR: prefix: "Kime:" no_results: Sonuç yok selected_user_title: "%{username} seçimini kaldır" - channel_selector: - title: "Kanala atla" - no_channels: "Aramanızla eşleşen kanal yok" channel: no_memberships: Bu kanalda üye yok no_memberships_found: Üye bulunamadı @@ -262,23 +350,44 @@ tr_TR: one: "%{count} üye" other: "%{count} üye" create_channel: + threading: + label: "İleti dizisine izin ver" auto_join_users: public_category_warning: "%{category} herkese açık bir kategori. Son zamanlarda aktif olan tüm kullanıcılar bu kanala otomatik olarak eklensin mi?" - warning_groups: - one: '%{group} grubundaki %{members_count} kullanıcı otomatik olarak eklensin mi?' - other: '%{group} ve %{group_2} gruplarındaki %{members_count} kullanıcı otomatik olarak eklensin mi?' - warning_multiple_groups: '%{group_1} ve %{count} diğer gruptaki %{members_count} kullanıcı otomatik olarak eklensin mi?' + warning_1_group: + one: "%{group} grubundan %{count} kullanıcı otomatik olarak eklensin mi?" + other: "%{group} grubundan %{count} kullanıcı otomatik olarak eklensin mi?" + warning_2_groups: + one: "%{group1} ve %{group2} gruplarından %{count} kullanıcı otomatik olarak eklensin mi?" + other: "%{group1} ve %{group2} gruplarından %{count} kullanıcı otomatik olarak eklensin mi?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {{groupName} ve {groupCount} gruptan daha {userCount} kullanıcı otomatik olarak eklensin mi?} + other {{groupName} ve {groupCount} gruptan daha {userCount} kullanıcı otomatik olarak eklensin mi?} + } + } + other { + { userCount, plural, + one {{groupName} ve {groupCount} gruptan daha {userCount} kullanıcı otomatik olarak eklensin mi?} + other {{groupName} ve {groupCount} gruptan daha {userCount} kullanıcı otomatik olarak eklensin mi?} + } + } + } choose_category: label: "Kategori seçin" none: "birini seçin..." default_hint: %{category} güvenlik ayarlarını ziyaret ederek erişimi yönetin - hint_groups: - one: '%{hint} ögesindeki kullanıcılar güvenlik ayarlarına göre bu kanala erişebilirler' - other: '%{hint} ve %{hint_2} ögesindeki kullanıcılar güvenlik ayarlarına göre bu kanala erişebilirler' - hint_multiple_groups: '%{hint_1} ve %{count} diğer gruptaki kullanıcılar güvenlik ayarlarına göre bu kanala erişebilirler' + hint_1_group: '%{group} içindeki kullanıcılar güvenlik ayarlarına göre bu kanala erişebilecek' + hint_2_groups: '%{group1} ve %{group2} içindeki kullanıcıların güvenlik ayarları uyarınca bu kanala erişimi olacak' + hint_multiple_groups: + one: '%{group} ve diğer %{count} gruptaki kullanıcılar, güvenlik ayarlarına göre bu kanala erişebilecek' + other: '%{group} ve diğer %{count} gruptaki kullanıcılar, güvenlik ayarlarına göre bu kanala erişebilecek' create: "Kanal oluştur" description: "Açıklama (isteğe bağlı)" name: "Kanal adı" + slug: "Kanal slug (isteğe bağlı)" title: "Yeni kanal" type: "Tür" types: @@ -288,15 +397,22 @@ tr_TR: type: "Sohbet mesajı" reactions: only_you: ":%{emoji}: ile tepki verdiniz" - and_others: "Siz, %{usernames} :%{emoji}: ile tepki verdiniz" - only_others: "%{usernames} :%{emoji}: ile tepki verdi" - others_and_more: "%{usernames} ve %{more} kişi daha :%{emoji}: ile tepki verdi" - you_others_and_more: "Siz, %{usernames} ve %{more} kişi daha :%{emoji}: ile tepki verdiniz" + you_and_single_user: "Siz ve %{username}, :%{emoji} ile tepki verdiniz:" + you_and_multiple_users: "Siz, %{commaSeparatedUsernames} ve %{username}, :%{emoji} ile tepki gösterdiniz:" + you_multiple_users_and_more: + one: "Siz, %{commaSeparatedUsernames} ve %{count} kişi daha :%{emoji}: ile tepki verdiniz" + other: "Siz, %{commaSeparatedUsernames} ve %{count} kişi daha :%{emoji}: ile tepki verdiniz" + single_user: "%{username} :%{emoji}: ile tepki verdi" + multiple_users: "%{commaSeparatedUsernames} ve %{username}, :%{emoji} ile tepki gösterdi:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} ve %{count} kişi daha :%{emoji}: ile tepki verdi" + other: "%{commaSeparatedUsernames} ve %{count} kişi daha :%{emoji}: ile tepki verdi" composer: toggle_toolbar: "Araç çubuğunu aç/kapat" italic_text: "vurgulanan yazı" bold_text: "güçlü metin" code_text: "kod metni" + send: "Gönder" quote: original_channel: 'İlk olarak %{channel} adlı kanalda gönderildi' copy_success: "Sohbet alıntısı panoya kopyalandı" @@ -307,15 +423,19 @@ tr_TR: settings: channel_wide_mentions_label: "@all ve @here bahsetmelerine izin ver" channel_wide_mentions_description: "Kullanıcıların @all ile #%{channel} kanalının tüm üyelerine veya @here ile yalnızca o anda aktif olanlara bildirim göndermelerine izin verin" + channel_threading_label: "İleti dizisi" + channel_threading_description: "İleti dizisi etkinleştirildiğinde, bir sohbet mesajına verilen yanıtlar, ana kanalın yanında yer alacak ayrı bir konuşma oluşturur." auto_join_users_label: "Kullanıcıları otomatik olarak ekle" - auto_join_users_info: "Son 3 ayda hangi kullanıcıların aktif olduğunu saatlik olarak kontrol edin ve %{category} adlı kategoriye erişimleri varsa bu kanala ekleyin." - enable_auto_join_users: "Son zamanlarda aktif olan tüm kullanıcıları otomatik olarak ekle" + auto_join_users_info: "Son 3 ayda hangi kullanıcıların aktif olduğunu saatlik olarak kontrol edin. %{category} kategorisine erişimleri varsa onları bu kanala ekleyin." + auto_join_users_info_no_category: "Son 3 ayda hangi kullanıcıların aktif olduğunu saatlik olarak kontrol edin. Seçilen kategoriye erişimleri varsa onları bu kanala ekleyin." auto_join_users_warning: "Bu kanala üye olmayan ve %{category} adlı kategoriye erişimi olan her kullanıcı katılacak. Emin misiniz?" desktop_notification_level: "Masaüstü bildirimleri" follow: "Katıl" followed: "Katıldı" mobile_notification_level: "Mobil anlık bildirimleri" mute: "Kanalı sessize al" + threading_enabled: "Etkin mi" + threading_disabled: "Devre Dışı" muted_on: "Açık" muted_off: "Kapalı" notifications: "Bildirimler" @@ -324,9 +444,13 @@ tr_TR: saved: "Kaydedildi" unfollow: "Ayrıl" admin_title: "Yönetici" - retention_info: "Sohbet geçmişi %{days} gün boyunca kaydedilecek." admin: title: "Sohbet" + export_messages: + title: "Sohbet mesajlarını dışa aktar" + description: "Dışa aktarma şu anda son 6 aydaki en son 10.000 mesajla sınırlıdır." + create_export: "Dışa aktarma oluştur" + export_has_started: "Dışa aktarım başladı. Hazır olduğunda bir PM alacaksınız." direct_messages: title: "Kişisel sohbet" new: "Kişisel sohbet oluşturun" @@ -390,8 +514,14 @@ tr_TR: one: "%{commaSeparatedUsernames} ve %{count} kişi daha yazıyor" other: "%{commaSeparatedUsernames} ve %{count} kişi daha yazıyor" retention_reminders: - public: "Kanal geçmişi %{days} gün boyunca saklanır." - dm: "Kişisel sohbet geçmişi %{days} gün boyunca saklanır." + public_none: "Kanal geçmişi süresiz olarak saklanır." + public: + one: "Kanal geçmişi %{count} gün boyunca saklanır." + other: "Kanal geçmişi %{count} gün boyunca saklanır." + dm_none: "Kişisel sohbet geçmişi süresiz olarak saklanır." + dm: + one: "Kişisel sohbet geçmişi %{count} gün boyunca saklanır." + other: "Kişisel sohbet geçmişi %{count} gün boyunca saklanır." flags: off_topic: "Bu mesaj, kanal başlığı tarafından tanımlanan mevcut tartışma ile ilgili değildir ve muhtemelen başka bir yere taşınmalıdır." inappropriate: "Bu mesaj, makul bir kişinin saldırgan, taciz edici veya topluluk kurallarımızı ihlal ettiğini düşüneceği içerik içeriyor." @@ -413,6 +543,33 @@ tr_TR: symbols: "Semboller" search_placeholder: "Emoji adına ve takma ada göre arama yapın..." no_results: "Sonuç yok" + thread: + title: "Başlık" + view_thread: Konuyu görüntüle + default_title: "Konu" + replies: + one: "%{count} yanıt" + other: "%{count} yanıt" + label: Konu + close: "Konuyu Kapat" + original_message: + started_by: "Başlatan" + settings: "Ayarlar" + last_reply: "son yanıt" + notifications: + regular: + title: "Normal" + description: "Birisi bu başlıkta sizden bahsederse bilgilendirileceksiniz." + tracking: + title: "Takip Ediliyor" + description: "Bu konudaki yeni yanıtların sayısı konu listesinde ve kanalda gösterilecektir. Birisi bu ileti dizisinde sizden bahsederse bilgilendirileceksiniz." + participants_other_count: + one: "+%{count}" + other: "+%{count}" + threads: + open: "Konu Aç" + list: "Devam eden tartışmalar" + none: "Bu kanaldaki herhangi bir ileti dizisine katılmamışsınız." draft_channel_screen: header: "Yeni Mesaj" cancel: "İptal et" @@ -457,9 +614,8 @@ tr_TR: transcript: view: "Önceki mesajların dökümünü görüntüle" types: - reviewable_chat_message: + chat_reviewable_message: title: "Bayrak Eklenen Sohbet Mesajı" - flagged_by: "Bayrak Ekleyen Kişi" keyboard_shortcuts_help: chat: title: "Sohbet" @@ -472,6 +628,7 @@ tr_TR: composer_code: "%{shortcut} Kod (yalnızca besteci)" drawer_open: "%{shortcut} Sohbet çekmecesini aç" drawer_close: "%{shortcut} Sohbet çekmecesini kapat" + mark_all_channels_read: "%{shortcut} Tüm kanalları okundu olarak işaretle" topic_statuses: chat: help: "Bu konu için sohbet etkinleştirildi" @@ -490,3 +647,7 @@ tr_TR: chat_notifications_with_unread: one: "Sohbet bildirimleri - %{count} okunmamış bildirim" other: "Sohbet bildirimleri - %{count} okunmamış bildirim" + styleguide: + sections: + chat: + title: Sohbet diff --git a/plugins/chat/config/locales/client.uk.yml b/plugins/chat/config/locales/client.uk.yml index 5aaf311cea6..f562d6e10c7 100644 --- a/plugins/chat/config/locales/client.uk.yml +++ b/plugins/chat/config/locales/client.uk.yml @@ -15,6 +15,7 @@ uk: actions: chat_channel_status_change: "Статус каналу чату змінено" chat_channel_delete: "Канал чату видалено" + chat_auto_remove_membership: "Підписки автоматично видаляються з каналів" api: scopes: descriptions: @@ -39,10 +40,10 @@ uk: move_to_channel: title: "Перенести повідомлення в канал" instructions: - one: "Ви переміщуєте %{count} повідомлення. Виберіть канал призначення. Буде створено повідомлення в каналі %{channelTitle} про те, що це повідомлення було переміщено." - few: "Ви переміщуєте %{count} повідомлення. Виберіть канал призначення. Буде створено повідомлення в каналі %{channelTitle} про те, що ці повідомлення були переміщені." - many: "Ви переміщуєте %{count} повідомлень. Виберіть канал призначення. Буде створено повідомлення в каналі %{channelTitle} про те, що ці повідомлення були переміщені." - other: "Ви переміщуєте %{count} повідомлень. Виберіть канал призначення. Буде створено повідомлення в каналі %{channelTitle} про те, що ці повідомлення були переміщені." + one: "Ви переміщуєте повідомлення %{count} . Виберіть канал призначення. У каналі %{channelTitle} буде створено повідомлення-заповнювач, яке вказуватиме на те, що це повідомлення було переміщено. Зверніть увагу, що ланцюжки відповідей не будуть збережені в новому каналі, а повідомлення в старому каналі більше не будуть відображатися як відповіді на переміщені повідомлення." + few: "Ви переміщуєте %{count} повідомлень. Виберіть канал призначення. У каналі %{channelTitle} будуть створені повідомлення-заповнювачі, які вказуватимуть на те, що ці повідомлення було переміщено. Зверніть увагу, що ланцюжки відповідей не будуть збережені в новому каналі, а повідомлення в старому каналі більше не будуть відображатися як відповіді на переміщені повідомлення." + many: "Ви переміщуєте %{count} повідомлень. Виберіть канал призначення. У каналі %{channelTitle} будуть створені повідомлення-заповнювачі, які вказуватимуть на те, що ці повідомлення було переміщено. Зверніть увагу, що ланцюжки відповідей не будуть збережені в новому каналі, а повідомлення в старому каналі більше не будуть відображатися як відповіді на переміщені повідомлення." + other: "Ви переміщуєте %{count} повідомлень. Виберіть канал призначення. У каналі %{channelTitle} будуть створені повідомлення-заповнювачі, які вказуватимуть на те, що ці повідомлення було переміщено. Зверніть увагу, що ланцюжки відповідей не будуть збережені в новому каналі, а повідомлення в старому каналі більше не будуть відображатися як відповіді на переміщені повідомлення." confirm_move: "Перемістити повідомлення" channel_settings: title: "Налаштування каналу" @@ -73,7 +74,7 @@ uk: instructions: "Закриття каналу забороняє стороннім користувачам надсилати нові повідомлення або редагувати наявні повідомлення. Ви впевнені, що хочете закрити цей канал?" channel_delete: title: "Видалити канал" - instructions: "

    Видаляє %{name} канал і історію чату. Усі повідомлення та пов'язані з ними дані, такі як реакції та завантаження, будуть остаточно видалені. Якщо ви хочете зберегти історію каналу та вивести її з експлуатації, ви можете заархівувати канал замість цього.

    Ви впевнені, що хочете назавжди видалити канал? Для підтвердження введіть назву каналу в поле нижче.

    " + instructions: "

    Видаляє канал %{name} і історію чату. Усі повідомлення та пов’язані з ними дані, як-от реакції та завантаження, буде остаточно видалено. Якщо ви хочете зберегти історію каналу та вивести його з експлуатації, ви можете натомість заархівувати канал.

    Ви впевнені, що бажаєте остаточно видалити канал? Для підтвердження введіть назву каналу в поле нижче.

    " confirm: "Я розумію наслідки, видаліть канал" confirm_channel_name: "Введіть назву каналу" process_started: "Процес видалення каналу розпочався. Це спливаюче вікно закриється найближчим часом, ви більше не побачите видалений канал." @@ -83,8 +84,13 @@ uk: click_to_join: "Натисніть тут, щоб переглянути доступні канали." close: "Закрити" collapse: "Згорнути вікно чату" + expand: "Розгорнути вікно чату" confirm_flag: "Ви впевнені, що хочете позначити прапором повідомлення %{username}?" - deleted: "Повідомлення було видалено. [view]" + deleted: + one: "Повідомлення було видалено. [view]" + few: "%{count} повідомлення видалено. [view all]" + many: "%{count} повідомлень видалено. [view all]" + other: "%{count} повідомлень видалено. [view all]" hidden: "Повідомлення було приховано. [view]" delete: "Видалити" edited: "відредагований" @@ -99,6 +105,11 @@ uk: never: "Ніколи" title: "Сповіщення електронною поштою" when_away: "Тільки коли офлайн" + header_indicator_preference: + title: "Показувати індикатор активності в заголовку" + all_new: "Усі нові повідомлення" + dm_and_mentions: "Прямі повідомлення та згадки" + never: "Ніколи" enable: "Увімкнути чат" flag: "Поскаржитися" emoji: "Вставити емодзі" @@ -108,68 +119,70 @@ uk: in_reply_to: "У відповідь на" heading: "Чат" join: "Приєднатися" - new_messages: "нові повідомлення" + last_visit: "останнє відвідування" + summarization: + title: "Підсумкове повідомлення" + description: "Виберіть опцію нижче, щоб підсумувати розмову, надіслану протягом визначеного періоду." + summarize: "Підсумок" + since: + one: "Остання година" + few: "Останні %{count} години" + many: "Останні %{count} годин" + other: "Останні %{count} годин" mention_warning: dismiss: "відкласти" - cannot_see: - one: "%{username} не має доступу до цього каналу і не був сповіщений про це." - few: "%{username} і ще %{others} інших не мають доступу до цього каналу і не були сповіщені про це." - many: "%{username} і ще %{others} інших не мають доступу до цього каналу і не були сповіщені про це." - other: "%{username} і ще %{others} інших не мають доступу до цього каналу і не були сповіщені про це." + cannot_see: "%{username} не має доступу до цього каналу і не отримав сповіщення." + cannot_see_multiple: + one: "%{username} і %{count} інший користувач не мають доступу до цього каналу та не отримали сповіщення." + few: "%{username} і %{count} інші користувачі не мають доступу до цього каналу та не отримали сповіщення." + many: "%{username} та %{count} інших користувачів не мають доступу до цього каналу та не отримали сповіщення." + other: "%{username} та %{count} інших користувачів не мають доступу до цього каналу та не отримали сповіщення." invite: "Запросити до каналу" - without_membership: - one: "%{username} не приєднався до цього каналу." - few: "%{username} та ще %{others} інших не приєдналися до цього каналу." - many: "%{username} та ще %{others} інших не приєдналися до цього каналу." - other: "%{username} та ще %{others} інших не приєдналися до цього каналу." - group_mentions_disabled: - one: "%{group_name} не дозволяє згадувати" - few: "%{group_name} та %{others} інших не дозволяє згадувати" - many: "%{group_name} та %{others} інших не дозволяє згадувати" - other: "%{group_name} та %{others} інших не дозволяє згадувати" - too_many_members: - one: "%{group_name} має занадто багато членів. Нікого не було повідомлено" - few: "%{group_name} і %{others} інших мають занадто багато членів. Нікого не було повідомлено" - many: "%{group_name} і %{others} інших не дозволяє згадувати" - other: "%{group_name} і %{others} мають забагато учасників. Нікого не повідомили" - warning_multiple: - one: "%{count} інший" - few: "%{count} інших" - many: "%{count} інших" - other: "%{count} інших" + without_membership: "%{username} не приєднався до цього каналу." + without_membership_multiple: + one: "%{username} і ще %{count} користувач не приєдналися до цього каналу." + few: "%{username} і ще %{count} користувачі не приєдналися до цього каналу." + many: "%{username} і ще %{count} користувачів не приєдналися до цього каналу." + other: "%{username} і ще %{count} користувачів не приєдналися до цього каналу." + group_mentions_disabled: "%{group_name} не дозволяє згадки." + group_mentions_disabled_multiple: + one: "%{group_name} та %{count} інша група забороняють згадки." + few: "%{group_name} та %{count} інші групи забороняють згадки." + many: "%{group_name} та %{count} інших груп забороняють згадки." + other: "%{group_name} та %{count} інших груп забороняють згадки." + too_many_members: "%{group_name} має занадто багато учасників. Ніхто не був повідомлений." + too_many_members_multiple: + one: "%{group_name} та %{count} інша група мають занадто багато членів. Ніхто не був сповіщений." + few: "%{group_name} та %{count} інші групи мають занадто багато членів. Ніхто не був сповіщений." + many: "%{group_name} та %{count} інших груп мають занадто багато членів. Ніхто не був сповіщений." + other: "%{group_name} та %{count} інших груп мають занадто багато членів. Ніхто не був сповіщений." groups: header: some: "Деякі користувачі не отримають сповіщення" all: "Ніхто не буде повідомлений" - unreachable: - one: "@%{group} не дозволяє згадувати" - few: "@%{group} та @%{group_2} не дозволяють згадувати" - many: "@%{group} та @%{group_2} не дозволяють згадувати" - other: "@%{group} та @%{group_2} не дозволяють згадувати" - unreachable_multiple: "@%{group} та %{count} інші не дозволяють згадувати" - too_many_members: - one: "Згадка @%{group} перевищує %{notification_limit} із %{limit}" - few: "Згадка @%{group} чи @%{group_2} перевищує %{notification_limit} із %{limit}" - many: "Згадка @%{group} чи @%{group_2} перевищує %{notification_limit} із %{limit}" - other: "Згадка @%{group} чи @%{group_2} перевищує %{notification_limit} із %{limit}" - too_many_members_multiple: "Ці %{count} групи перевищили %{notification_limit} з %{limit}" - users_limit: - one: "%{count} користувач" - few: "%{count} користувача" - many: "%{count} користувача" - other: "%{count} користувача" - notification_limit: "ліміт сповіщень" - too_many_mentions: "Це повідомлення перевищує %{notification_limit} з %{limit}" - mentions_limit: - one: "%{count} згадка" - few: "%{count} згадки" - many: "%{count} згадок" - other: "%{count} згадок" + unreachable_1: "@%{group} не дозволяє згадки." + unreachable_2: "@%{group1} і @%{group2} не дозволяють згадувати." + unreachable_multiple: + one: "@%{group} та %{count} інша група забороняють згадки." + few: "@%{group} та %{count} інші групи забороняють згадки." + many: "@%{group} та %{count} інших груп забороняють згадки." + other: "@%{group} та %{count} інших груп забороняють згадки." + too_many_mentions: + one: "Це повідомлення перевищує ліміт сповіщень у %{count} згадку." + few: "Це повідомлення перевищує ліміт сповіщень у %{count} згадки." + many: "Це повідомлення перевищує ліміт сповіщень у %{count} згадок." + other: "Це повідомлення перевищує ліміт сповіщень у %{count} згадок." + too_many_mentions_admin: + one: 'Це повідомлення перевищує обмеження сповіщень із %{count} згадок.' + few: 'Це повідомлення перевищує обмеження сповіщень із %{count} згадок.' + many: 'Це повідомлення перевищує обмеження сповіщень із %{count} згадок.' + other: 'Це повідомлення перевищує обмеження сповіщень із %{count} згадок.' aria_roles: header: "Заголовок чату" composer: "Редактор чату" channels_list: "Список каналів чату" no_public_channels: "Ви не приєдналися до жодного каналу." + kicked_from_channel: "Ви більше не можете отримати доступ до цього каналу." only_chat_push_notifications: title: "Надсилати лише push-сповіщення в чаті" description: "Заблокувати всі push-сповіщення, не пов’язані з чатом" @@ -181,10 +194,16 @@ uk: close_full_page: "Закрити повноекранний чат" open_message: "Відкрити повідомлення в чаті" placeholder_self: "Запишіть щось" - placeholder_others: "Чат з %{messageRecipient}" - placeholder_new_message_disallowed: "Канал %{status}, зараз ви не можете надсилати нові повідомлення." + placeholder_channel: "Чат в %{channelName}" + placeholder_thread: "Чат в гілці" + placeholder_users: "Чат з %{commaSeparatedNames}" + placeholder_new_message_disallowed: + archived: "Канал заархівований, зараз ви не можете надсилати нові повідомлення." + closed: "Канал закритий, зараз ви не можете надсилати нові повідомлення." + read_only: "Канал доступний лише для читання, зараз ви не можете надсилати нові повідомлення." placeholder_silenced: "В даний момент ви не можете надсилати повідомлення." - placeholder_start_conversation: Почати розмову з %{usernames} + placeholder_start_conversation: "Почніть розмову з..." + placeholder_start_conversation_users: "Почніть розмову з %{commaSeparatedUsernames}" remove_upload: "Видалити файл" react: "Реагувати з emoji" reply: "Відповідь" @@ -200,8 +219,13 @@ uk: restore: "Відновити видалене повідомлення" save: "Зберегти" select: "Вибрати" - silence: "Заблокувати користувача" return_to_list: "Повернутися до списку каналів" + return_to_threads_list: "Повернутися до поточних дискусій" + unread_threads_count: + one: "У вас %{count} непрочитане обговорення" + few: "У вас %{count} непрочитаних обговорень" + many: "У вас %{count} непрочитані обговорення" + other: "У вас %{count} непрочитані обговорення" scroll_to_bottom: "Прокрутити вниз" scroll_to_new_messages: "Переглянути нові повідомлення" sound: @@ -213,6 +237,8 @@ uk: title: "чат" title_capitalized: "Чат" upload: "Прикріпити файл" + upload_to_channel: "Завантажити до %{title}" + upload_to_thread: "Завантажити в гілку" uploaded_files: one: "%{count} файл" few: "%{count} файли" @@ -258,10 +284,23 @@ uk: about: Про members: Учасники settings: Налаштування - channel_edit_name_modal: - title: Редагувати назву + new_message_modal: + title: Надіслати повідомлення + add_user_long: shift + click or shift + enterДодати @%{username} + add_user_short: Додати користувача + open_channel: Відкрити канал + default_search_placeholder: "#канал, @хтось або щось" + default_channel_search_placeholder: "#канал" + default_user_search_placeholder: "@хтось" + user_search_placeholder: "...додати більше користувачів" + disabled_user: "вимкнув чат" + no_items: "Немає елементів" + channel_edit_name_slug_modal: + title: Редагувати канал input_placeholder: Додайте назву - description: Дайте своєму каналу коротку описову назву + slug_description: Замість назви каналу в URL-адресі використовується slug каналу + name: Назва каналу + slug: Slug каналу (опціонально) channel_edit_description_modal: title: Редагувати опис input_placeholder: Додати опис @@ -271,9 +310,6 @@ uk: prefix: "До:" no_results: Немає результатів selected_user_title: "Зніміть позначку з %{username}" - channel_selector: - title: "Перейти на канал" - no_channels: "Немає каналів, які відповідають вашому запиту" channel: no_memberships: Цей канал не має учасників no_memberships_found: Учасників не знайдено @@ -283,20 +319,69 @@ uk: many: "%{count} учасників" other: "%{count} учасників" create_channel: + threading: + label: "Увімкнути потоки/ланцюжки" auto_join_users: public_category_warning: "%{category} є публічною категорією. Автоматично додавати всіх нещодавно активних користувачів до цього каналу?" - warning_groups: - one: Автоматично додати %{members_count} користувача з %{group}? - few: Автоматично додати %{members_count} користувачі з %{group}? - many: Автоматично додати %{members_count} користувачів з %{group}? - other: Автоматично додати %{members_count} користувачів з %{group} та %{group_2}? - warning_multiple_groups: Автоматично додавати %{members_count} користувачів з %{group_1} і %{count} інших? + warning_1_group: + one: "Автоматично додати %{count} користувача з %{group}?" + few: "Автоматично додати %{count} користувачі із %{group}?" + many: "Автоматично додати %{count} користувачів із %{group}?" + other: "Автоматично додати %{count} користувачів із %{group}?" + warning_2_groups: + one: "Автоматично додати %{count} користувача з %{group1} та %{group2}?" + few: "Автоматично додати %{count} користувачі з %{group1} та %{group2}?" + many: "Автоматично додати %{count} користувачів з %{group1} та %{group2}?" + other: "Автоматично додати %{count} користувачів з %{group1} та %{group2}?" + warning_multiple_groups_MF: | + { groupCount, plural, + one { + { userCount, plural, + one {Автоматично додавати {userCount} користувача з {groupName} і {groupCount} інших груп?} + few {Автоматично додавати {userCount} користувачі з {groupName} і {groupCount} інших груп?} + many {Автоматично додавати {userCount} користувачів з {groupName} і {groupCount} інших груп?} + other {Автоматично додавати {userCount} користувачів з {groupName} і {groupCount} інших груп?} + } + } + few { + { userCount, plural, + one {Автоматично додавати {userCount} користувача із {groupName} і {groupCount} інші групи?} + few {Автоматично додавати {userCount} користувачі з {groupName} і {groupCount} інші групи?} + many {Автоматично додавати {userCount} користувачів з {groupName} і {groupCount} інші групи?} + other {Автоматично додавати {userCount} користувачів з {groupName} і {groupCount} інші групи?} + } + } + many { + { userCount, plural, + one {Автоматично додавати {userCount} користувача із {groupName} і {groupCount} інших груп?} + few {Автоматично додавати {userCount} користувачі із {groupName} і {groupCount} інших груп?} + many {Автоматично додавати {userCount} користувачів із {groupName} і {groupCount} інших груп?} + other {Автоматично додавати {userCount} користувачів із {groupName} і {groupCount} інших груп?} + } + } + other { + { userCount, plural, + one {Автоматично додавати {userCount} користувача із {groupName} і {groupCount} інших груп?} + few {Автоматично додавати {userCount} користувачі із {groupName} і {groupCount} інших груп?} + many {Автоматично додавати {userCount} користувачів із {groupName} і {groupCount} інших груп?} + other {Автоматично додавати {userCount} користувачів із {groupName} і {groupCount} інших груп?} + } + } + } choose_category: label: "Оберіть категорію" none: "виберіть..." + hint_1_group: 'Користувачі в %{group} матимуть доступ до цього каналу відповідно до налаштувань безпеки' + hint_2_groups: 'Користувачі в %{group1} і %{group2} матимуть доступ до цього каналу відповідно до параметрів безпеки' + hint_multiple_groups: + one: 'Користувачі в %{group} і %{count} іншій групі матимуть доступ до цього каналу відповідно до налаштувань безпеки' + few: 'Користувачі в %{group} і %{count} інших групах матимуть доступ до цього каналу відповідно до налаштувань безпеки' + many: 'Користувачі в %{group} і %{count} інших групах матимуть доступ до цього каналу відповідно до налаштувань безпеки' + other: 'Користувачі в %{group} і %{count} інших групах матимуть доступ до цього каналу відповідно до налаштувань безпеки' create: "Створити канал" description: "Опис (необов'язково)" name: "Назва каналу" + slug: "Slug каналу (опціонально)" title: "Новий канал" type: "Тип" types: @@ -306,15 +391,26 @@ uk: 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}:" + you_and_single_user: "Ви та %{username} відреагували :%{emoji}:" + you_and_multiple_users: "Ви, %{commaSeparatedUsernames} і %{username} відреагували :%{emoji}:" + you_multiple_users_and_more: + one: "Ви, %{commaSeparatedUsernames} та %{count} інший відреагували:%{emoji}:" + few: "Ви, %{commaSeparatedUsernames} та %{count} інших відреагували:%{emoji}:" + many: "Ви, %{commaSeparatedUsernames} та %{count} інших відреагували:%{emoji}:" + other: "Ви, %{commaSeparatedUsernames} та %{count} інших відреагували:%{emoji}:" + single_user: "%{username} відреагував:%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} і %{username} відреагували:%{emoji}:" + multiple_users_and_more: + one: "%{commaSeparatedUsernames} і ще %{count} відреагували :%{emoji}:" + few: "%{commaSeparatedUsernames} і ще %{count} відреагували :%{emoji}:" + many: "%{commaSeparatedUsernames} і ще %{count} відреагували :%{emoji}:" + other: "%{commaSeparatedUsernames} і ще %{count} відреагували :%{emoji}:" composer: toggle_toolbar: "Перемикання панелі інструментів" italic_text: "виділення тексту курсивом" bold_text: "Сильне виділення тексту" code_text: "текст коду" + send: "Надіслати" quote: original_channel: 'Спочатку надіслано в %{channel}' copy_success: "Цитату чату скопійовано в буфер обміну" @@ -325,15 +421,19 @@ uk: settings: channel_wide_mentions_label: "Дозволити згадки @all та @here" channel_wide_mentions_description: "Дозволити користувачам сповіщати всіх учасників #%{channel} за допомогою @all або лише тих, хто активний на даний момент, за допомогою @here" + channel_threading_label: "Потоки/ланцюжки" + channel_threading_description: "Якщо ланцюжки увімкнено, відповіді на повідомлення чату створюватимуть окрему бесіду, яка існуватиме поряд з основним каналом." auto_join_users_label: "Автоматично додавати користувачів" - auto_join_users_info: "Щогодини перевіряйте, які користувачі були активними протягом останніх 3 місяців, і, якщо вони мають доступ до категорії %{category} , додайте їх до цього каналу." - enable_auto_join_users: "Автоматично додавати всіх нещодавно активних користувачів" + auto_join_users_info: "Перевіряйте погодинно, які користувачі були активними протягом останніх 3 місяців. Додайте їх до цього каналу, якщо вони мають доступ до категорії %{category}." + auto_join_users_info_no_category: "Погодинно перевіряйте, які користувачі були активними протягом останніх 3 місяців. Додайте їх до цього каналу, якщо вони мають доступ до обраної категорії." auto_join_users_warning: "Кожен користувач, який не є учасником цього каналу і має доступ до категорії %{category} , приєднається до нього. Ви впевнені?" desktop_notification_level: "Сповіщення на робочому столі" follow: "Приєднатися" followed: "Приєднався(лась)" mobile_notification_level: "Мобільні push-сповіщення" mute: "Вимкнути канал" + threading_enabled: "Включено" + threading_disabled: "Вимкнено" muted_on: "Увімк" muted_off: "Вимк." notifications: "Сповіщення" @@ -342,7 +442,12 @@ uk: saved: "Збережено" unfollow: "Покинути" admin_title: "Адміністратор" - retention_info: "Історія чату зберігатиметься %{days} днів." + admin: + export_messages: + title: "Експорт повідомлень чату" + description: "Наразі експорт обмежено до 10 000 повідомлень за останні 6 місяців." + create_export: "Створити експорт" + export_has_started: "Експорт почався. Ви отримаєте ПП коли він буде готовий." incoming_webhooks: back: "Назад" description: "Опис" @@ -388,8 +493,18 @@ uk: many: "%{commaSeparatedUsernames} та %{count} інших набирають текст" other: "%{commaSeparatedUsernames} та %{count} інших набирають текст" retention_reminders: - public: "Історія каналу зберігається %{days} днів." - dm: "Особиста історія чату зберігається %{days} днів." + public_none: "Історія каналу зберігається нескінченно довго." + public: + one: "Історія каналу зберігається протягом %{count} дня." + few: "Історія каналу зберігається протягом %{count} днів." + many: "Історія каналу зберігається протягом %{count} днів." + other: "Історія каналу зберігається протягом %{count} днів." + dm_none: "Історія особистого чату зберігається безстроково." + dm: + one: "Особиста історія чату зберігається %{count} день." + few: "Особиста історія чату зберігається %{count} дні." + many: "Особиста історія чату зберігається %{count} днів." + other: "Особиста історія чату зберігається %{count} днів." flags: off_topic: "Це повідомлення не має відношення до поточної дискусії як визначено назвою каналів, і, ймовірно, має бути переміщене в інше місце." inappropriate: "Це повідомлення містить вміст, який розумна людина вважає образливим або порушенням наших правил спільноти." @@ -411,6 +526,37 @@ uk: symbols: "Атрибутика" search_placeholder: "Пошук за назвою та псевдонімом emoji..." no_results: "Немає результатів" + thread: + title: "Назва" + view_thread: Відкрити діалог + default_title: "Нитка" + replies: + one: "%{count} відповідь" + few: "%{count} відповіді" + many: "%{count} відповідь" + other: "%{count} відповідь" + label: Нитка + close: "Закрити тему" + original_message: + started_by: "Розпочато" + settings: "Налаштування" + last_reply: "остання відповідь" + notifications: + regular: + title: "Нормальний" + description: "Ви отримаєте сповіщення, якщо хтось згадає ваш @name у цій темі." + tracking: + title: "Стежити" + description: "Кількість нових відповідей для цього ланцюжка буде показано в списку ланцюжків і на каналі. Ви отримаєте сповіщення, якщо хтось згадає ваше @name у цій темі." + participants_other_count: + one: "+%{count}" + few: "+%{count}" + many: "+%{count}" + other: "+%{count}" + threads: + open: "Відкрийте тему" + list: "Поточні обговорення" + none: "Ви не берете участі в жодній темі на цьому каналі." draft_channel_screen: header: "Нове повідомлення" cancel: "Скасувати" @@ -455,9 +601,8 @@ uk: transcript: view: "Показати попередні повідомлення" types: - reviewable_chat_message: - title: "Повідомлення чату на перевірці" - flagged_by: "Позначено" + chat_reviewable_message: + title: "Позначене повідомлення чату" keyboard_shortcuts_help: chat: title: "Чат" @@ -470,6 +615,7 @@ uk: composer_code: "%{shortcut} Код (тільки в редакторі)" drawer_open: "%{shortcut} Відкрити панель чату" drawer_close: "%{shortcut} Закрийте панель чату" + mark_all_channels_read: "%{shortcut} Позначити всі канали як прочитані" topic_statuses: chat: help: "Чат увімкнено для цієї теми" @@ -486,3 +632,7 @@ uk: few: "Повідомлення чату – %{count} непрочитані повідомлення" many: "Повідомлення чату – %{count} непрочитаних повідомлень" other: "Повідомлення чату – %{count} непрочитаних повідомлень" + styleguide: + sections: + chat: + title: Чат diff --git a/plugins/chat/config/locales/client.ur.yml b/plugins/chat/config/locales/client.ur.yml index 3f7f16269e6..b9c56cc85b1 100644 --- a/plugins/chat/config/locales/client.ur.yml +++ b/plugins/chat/config/locales/client.ur.yml @@ -21,14 +21,15 @@ ur: joined: "شمولیت اختیار کی" email_frequency: never: "کبھی نہیں" + header_indicator_preference: + never: "کبھی نہیں" flag: "فلَیگ" join: "شمولیت اختیار کریں" + last_visit: "آخری وزٹ" + summarization: + summarize: "خلاصہ" mention_warning: dismiss: "بر خاست کریں" - groups: - users_limit: - one: "%{count} صارف" - other: "%{count} صارفین" reply: "جواب" edit: "ترمیم کریں" rebake_message: "HTML دوبارہ بِلڈ کریں" @@ -58,6 +59,8 @@ ur: about: بارے میں members: ممبران settings: ترتیبات + new_message_modal: + no_items: "کوئی اشیاء نہیں" direct_message_creator: title: نیا پیغام prefix: "کے لئے:" @@ -69,11 +72,13 @@ ur: composer: italic_text: "زور دیا گیا ٹَیکسٹ" bold_text: "گہرا ٹَیکسٹ" + send: "بھیجیں" notification_levels: never: "کبھی نہیں" settings: follow: "شمولیت اختیار کریں" followed: "شمولیت اختیار کی" + threading_disabled: "غیر فعال" notifications: "اطلاعات" preview: "پیشگی دیکھیں" save: "محفوظ کریں" @@ -105,6 +110,21 @@ ur: activities: "سرگرمیاں" flags: "فلَیگز" symbols: "علامات" + thread: + title: "عنوان" + replies: + one: "%{count} جواب" + other: "%{count} جوابات" + settings: "ترتیبات" + last_reply: "آخری جواب" + notifications: + regular: + title: "عمومی" + tracking: + title: "ٹریک کیا جا رہا" + participants_other_count: + one: "+%{count}" + other: "+%{count}" draft_channel_screen: header: "نیا پیغام" cancel: "منسوخ" @@ -116,7 +136,3 @@ ur: 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 index 4c1039356df..f90d06e3a69 100644 --- a/plugins/chat/config/locales/client.vi.yml +++ b/plugins/chat/config/locales/client.vi.yml @@ -21,34 +21,19 @@ vi: joined: "đã tham gia" email_frequency: never: "Không bao giờ" + header_indicator_preference: + never: "Không bao giờ" flag: "Gắn cờ" join: "Tham gia" + last_visit: "lần thăm cuối" + summarization: + summarize: "Tóm tắt" mention_warning: dismiss: "bỏ qua" - cannot_see: - other: "%{username} và %{others} không thể truy cập kênh này và không được thông báo." - without_membership: - other: "%{username} và %{others} chưa tham gia kênh này." - group_mentions_disabled: - other: "%{group_name} và %{others} không cho phép đề cập" - too_many_members: - other: "%{group_name} và %{others} có quá nhiều thành viên. Không ai được thông báo" - warning_multiple: - other: "%{count} khác" groups: header: some: "Một số người dùng sẽ không được thông báo" all: "Sẽ không ai được thông báo" - unreachable: - other: "@%{group} và @%{group_2} không cho phép đề cập" - unreachable_multiple: "@%{group} và %{count} người khác không cho phép đề cập" - too_many_members_multiple: "%{count} nhóm vượt quá %{notification_limit} của %{limit}" - users_limit: - other: "%{count} người dùng" - notification_limit: "giới hạn thông báo" - too_many_mentions: "Tin nhắn này vượt quá %{notification_limit} của %{limit}" - mentions_limit: - other: "%{count} đề cập" reply: "Trả lời" edit: "Sửa" rebake_message: "Tạo lại HTML" @@ -78,6 +63,8 @@ vi: about: Giới thiệu members: Các thành viên settings: Cài đặt + new_message_modal: + no_items: "Không có mẫu nào" direct_message_creator: title: Tin nhắn mới prefix: "Tới:" @@ -89,11 +76,13 @@ vi: composer: italic_text: "văn bản nhấn mạnh" bold_text: "chữ in đậm" + send: "Gửi" notification_levels: never: "Không bao giờ" settings: follow: "Tham gia" followed: "Đã tham gia" + threading_disabled: "Vô hiệu hóa" notifications: "Thông báo" preview: "Xem trước" save: "Lưu lại" @@ -127,6 +116,19 @@ vi: activities: "Hoạt động" flags: "Dấu cờ - Flags" symbols: "Ký hiệu" + thread: + title: "Tiêu đề" + replies: + other: "%{count} trả lời" + settings: "Cài đặt" + last_reply: "trả lời cuối cùng" + notifications: + regular: + title: "Bình thường" + tracking: + title: "Đang theo dõi" + participants_other_count: + other: "+%{count}" draft_channel_screen: header: "Tin nhắn mới" cancel: "Huỷ" @@ -138,7 +140,3 @@ vi: 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 index 31738d4702d..8ff476da7f0 100644 --- a/plugins/chat/config/locales/client.zh_CN.yml +++ b/plugins/chat/config/locales/client.zh_CN.yml @@ -15,6 +15,7 @@ zh_CN: actions: chat_channel_status_change: "聊天频道状态已更改" chat_channel_delete: "聊天频道已删除" + chat_auto_remove_membership: "从频道自动移除成员" api: scopes: descriptions: @@ -39,7 +40,7 @@ zh_CN: move_to_channel: title: "将消息移至频道" instructions: - other: "您正在移动 %{count} 条消息。选择一个目标频道。 将在%{channelTitle}频道中创建一条占位符消息,以表明这些消息已被移动。" + other: "您正在移动 %{count} 条消息。选择目标频道。将在 %{channelTitle} 频道中创建占位符消息,以表明这些消息已被移动。请注意,新频道中不会保留回复链,旧频道中的消息将不再显示为回复任何移动的消息。" confirm_move: "移动消息" channel_settings: title: "频道设置" @@ -80,8 +81,10 @@ zh_CN: click_to_join: "点击此处查看可用频道。" close: "关闭" collapse: "收起聊天抽屉" + expand: "展开聊天栏" confirm_flag: "确定要举报 %{username} 的消息吗?" - deleted: "一条消息已被删除。[查看]" + deleted: + other: "%{count} 条消息已被删除。[查看全部]" hidden: "一条消息已被隐藏。[查看]" delete: "删除" edited: "已编辑" @@ -96,6 +99,11 @@ zh_CN: never: "永不" title: "电子邮件通知" when_away: "仅在离开时" + header_indicator_preference: + title: "在标题中显示活动指示器" + all_new: "所有新消息" + dm_and_mentions: "直接消息和提及" + never: "永不" enable: "启用聊天" flag: "举报" emoji: "插入表情符号" @@ -105,43 +113,48 @@ zh_CN: in_reply_to: "回复" heading: "聊天" join: "加入" - new_messages: "新消息" + last_visit: "上次访问" + summarization: + title: "总结消息" + description: "选择以下选项,以总结在所需时间范围内发送的对话。" + summarize: "摘要" + since: + other: "最后 %{count} 小时" mention_warning: dismiss: "忽略" - cannot_see: - other: "%{username} 和 %{others} 无法访问此频道且未收到通知。" + cannot_see: "%{username} 无法访问此频道且未被通知。" + cannot_see_multiple: + other: "%{username} 和 %{count} 位其他用户无法访问此频道且未被通知。" invitations_sent: other: "已发送邀请" invite: "邀请加入频道" - without_membership: - other: "%{username} 和 %{others} 尚未加入此频道。" - group_mentions_disabled: - other: "%{group_name} 和 %{others} 不允许提及" - too_many_members: - other: "%{group_name} 和 %{others} 的成员过多。任何人都不会收到通知" - warning_multiple: - other: "其他 %{count} 个" + without_membership: "%{username} 尚未加入此频道。" + without_membership_multiple: + other: "%{username} 和其他 %{count} 位用户尚未加入此频道。" + group_mentions_disabled: "%{group_name} 不允许被提及。" + group_mentions_disabled_multiple: + other: "%{group_name} 和其他 %{count} 个群组不允许被提及." + too_many_members: "%{group_name} 的成员过多。没有人被通知。" + too_many_members_multiple: + other: "%{group_name} 和 其他%{count} 个群组的成员过多。没有人被通知。" groups: header: some: "某些用户不会收到通知" all: "任何人都不会收到通知" - unreachable: - other: "@%{group} 和 @%{group_2} 不允许提及" - unreachable_multiple: "@%{group} 和其他 %{count} 个群组不允许提及" - too_many_members: - other: "提及 @%{group}或 @%{group_2}超出 %{limit}的%{notification_limit}" - too_many_members_multiple: "这 %{count} 个群组超出了 %{limit}的%{notification_limit}" - users_limit: - other: "%{count} 位用户" - notification_limit: "通知限制" - too_many_mentions: "此消息超出 %{limit}的%{notification_limit}" - mentions_limit: - other: "%{count} 个提及" + unreachable_1: "%{group} 不允许被提及。" + unreachable_2: "@%{group1} 和 @%{group2} 不允许被提及." + unreachable_multiple: + other: "@%{group} 和其他 %{count} 个群组不允许被提及。" + too_many_mentions: + other: "此消息超过了 %{count} 次提及的通知限制。" + too_many_mentions_admin: + other: '此消息超过了 通知限制 (共 %{count} 次)。' aria_roles: header: "聊天标题" composer: "聊天输入框" channels_list: "聊天频道列表" no_public_channels: "您还没有加入任何频道。" + kicked_from_channel: "你不能再访问这个频道了。" only_chat_push_notifications: title: "只发送聊天推送通知" description: "阻止发送所有非聊天推送通知" @@ -153,10 +166,16 @@ zh_CN: close_full_page: "关闭全屏聊天" open_message: "在聊天中打开消息" placeholder_self: "做些记录" - placeholder_others: "在 %{messageRecipient} 聊天" - placeholder_new_message_disallowed: "频道的状态为%{status},您现在无法发送新消息。" + placeholder_channel: "在 %{channelName} 聊天" + placeholder_thread: "在聊天串中聊天" + placeholder_users: "在 %{commaSeparatedNames} 聊天" + placeholder_new_message_disallowed: + archived: "频道已归档,你无法在此发送新信息。" + closed: "频道已关闭,你无法在此发送新信息。" + read_only: "频道为只读,您现在不能发送新消息。" placeholder_silenced: "您目前无法发送消息。" - placeholder_start_conversation: 开始与 %{usernames} 的对话 + placeholder_start_conversation: "与...开始对话" + placeholder_start_conversation_users: "开始与 %{commaSeparatedUsernames} 的对话" remove_upload: "移除文件" react: "使用表情符号回复" reply: "回复" @@ -172,8 +191,10 @@ zh_CN: restore: "恢复已删除的消息" save: "保存" select: "选择" - silence: "将用户禁言" return_to_list: "返回频道列表" + return_to_threads_list: "返回正在进行的讨论" + unread_threads_count: + other: "你有 %{count} 个未读的讨论" scroll_to_bottom: "滚动到底部" scroll_to_new_messages: "查看新消息" sound: @@ -185,6 +206,8 @@ zh_CN: title: "聊天" title_capitalized: "聊天" upload: "附加文件" + upload_to_channel: "上传到 %{title}" + upload_to_thread: "上传到聊天串" uploaded_files: other: "%{count} 个文件" you_flagged: "您已举报此消息" @@ -227,10 +250,23 @@ zh_CN: about: 关于 members: 成员 settings: 设置 - channel_edit_name_modal: - title: 编辑名称 + new_message_modal: + title: 发送消息 + add_user_long: shift + 点击shift + enter添加 @%{username} + add_user_short: 添加用户 + open_channel: 打开频道 + default_search_placeholder: "#一个频道、@某人或任何内容" + default_channel_search_placeholder: "#一个频道" + default_user_search_placeholder: "@某人" + user_search_placeholder: "...添加更多用户" + disabled_user: "已禁用聊天功能" + no_items: "无条目" + channel_edit_name_slug_modal: + title: 编辑频道 input_placeholder: 添加一个名字 - description: 为你的频道起一个简短的描述性名称 + slug_description: 在 URL 中使用频道 slug 而不是频道名称 + name: 频道名称 + slug: 频道 slug (可选) channel_edit_description_modal: title: 编辑描述 input_placeholder: 添加描述 @@ -240,30 +276,40 @@ zh_CN: prefix: "至:" no_results: 没有结果 selected_user_title: "取消选择 %{username}" - channel_selector: - title: "跳转到频道" - no_channels: "没有频道符合您的搜索" channel: no_memberships: 此频道没有成员 no_memberships_found: 找不到成员 memberships_count: other: "%{count} 个成员" create_channel: + threading: + label: "启用聊天串" auto_join_users: public_category_warning: "%{category}是公共类别。是否自动将所有最近活跃的用户添加到此频道?" - warning_groups: - other: 自动从%{group}和%{group_2}添加 %{members_count} 个用户? - warning_multiple_groups: 自动从%{group_1}和其他 %{count} 个群组添加 %{members_count} 个用户? + warning_1_group: + other: "自动从 %{group} 添加 %{count} 用户?" + warning_2_groups: + other: "自动从%{group1}和%{group2}添加 %{count} 个用户?" + warning_multiple_groups_MF: | + { groupCount, plural, + other { + { userCount, plural, + other {是否自动添加 {userCount} 个来自 {groupName} 和 {groupCount} 个其他组的用户?} + } + } + } choose_category: label: "选择一个类别" none: "选择一个…" default_hint: 访问%{category}安全设置管理访问权限 - hint_groups: - other: 根据安全设置,%{hint}和%{hint_2}中的用户将可以访问此频道 - hint_multiple_groups: 根据安全设置,%{hint_1}和其他 %{count} 个群组中的用户将有权访问此频道 + hint_1_group: '%{group} 中的用户根据 安全设置可以访问这个频道' + hint_2_groups: '%{group1} 和 %{group2} 中的用户根据 安全设置可以访问这个频道' + hint_multiple_groups: + other: '%{group} 和 %{count} 其他组的用户根据 安全可以设置访问这个频道' create: "创建频道" description: "描述(可选)" name: "频道名称" + slug: "频道 slug (可选)" title: "新建频道" type: "类型" types: @@ -273,15 +319,20 @@ zh_CN: 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}:" + you_and_single_user: "你和 %{username} 的回应了: %{emoji}:" + you_and_multiple_users: "你、%{commaSeparatedUsernames} 和 %{username} 回应了 :%{emoji}:" + you_multiple_users_and_more: + other: "您、%{commaSeparatedUsernames} 和其他 %{count} 人回应了 :%{emoji}:" + single_user: "%{username} 回应了 :%{emoji}:" + multiple_users: "%{commaSeparatedUsernames} 和 %{username} 回应 :%{emoji}:" + multiple_users_and_more: + other: "%{commaSeparatedUsernames} 和其他 %{count} 人回应了 :%{emoji}:" composer: toggle_toolbar: "切换工具栏" italic_text: "斜体" bold_text: "粗体" code_text: "代码文本" + send: "发送" quote: original_channel: '最初在%{channel}中发送。' copy_success: "聊天引用已复制到剪贴板" @@ -292,15 +343,19 @@ zh_CN: settings: channel_wide_mentions_label: "允许 @all 和 @here 提及" channel_wide_mentions_description: "允许用户使用 @all 通知#%{channel} 的所有成员,或仅使用 @here 通知当前活跃的成员" + channel_threading_label: "串" + channel_threading_description: "启用聊天串后,对聊天消息的回复将创建一个单独的对话,该对话将与主频道并存。" auto_join_users_label: "自动添加用户" - auto_join_users_info: "每小时检查哪些用户在过去 3 个月内处于活跃状态,如果他们有权访问“%{category}”类别,则将他们添加到此频道。" - enable_auto_join_users: "自动添加所有最近活跃的用户" + auto_join_users_info: "每小时检查哪些用户在过去3个月中是活跃的。如果他们有权限进入 %{category} 类别,就把他们添加到这个频道。" + auto_join_users_info_no_category: "每小时检查哪些用户在过去 3 个月内处于活跃状态。如果他们有权访问所选类别,则将他们添加到此频道。" auto_join_users_warning: "所有不是此频道成员且有权访问“%{category}”类别的用户都将加入。确定吗?" desktop_notification_level: "桌面通知" follow: "加入" followed: "已加入" mobile_notification_level: "移动推送通知" mute: "将频道设为免打扰" + threading_enabled: "已启用" + threading_disabled: "已禁用" muted_on: "开" muted_off: "关" notifications: "通知" @@ -309,9 +364,13 @@ zh_CN: saved: "已保存" unfollow: "离开" admin_title: "管理员" - retention_info: "聊天历史记录将保存 %{days} 天。" admin: title: "聊天" + export_messages: + title: "导出聊天消息" + description: "目前只能导出过去 6 个月内的 10000 条最新消息。" + create_export: "创建导出" + export_has_started: "导出已开始。导出完成后,您将收到一条私信。" direct_messages: title: "个人聊天" new: "创建个人聊天" @@ -371,8 +430,12 @@ zh_CN: many_users: other: "%{commaSeparatedUsernames} 和其他 %{count} 人正在输入" retention_reminders: - public: "频道历史记录保留 %{days} 天。" - dm: "个人聊天记录保留 %{days} 天。" + public_none: "频道历史记录会被永久保留。" + public: + other: "频道历史记录保留 %{count} 天。" + dm_none: "个人聊天记录将会被永久保留。" + dm: + other: "个人聊天记录保留 %{count} 天。" flags: off_topic: "此消息与频道标题定义的当前讨论无关,可能应当移至其他地方。" inappropriate: "此消息包含理性的人会认为具有攻击性、辱骂性或违反我们的社区准则的内容。" @@ -394,6 +457,31 @@ zh_CN: symbols: "符号" search_placeholder: "按表情符号名称和别名搜索…" no_results: "没有结果" + thread: + title: "标题" + view_thread: 查看聊天串 + default_title: "聊天串" + replies: + other: "%{count} 个回复" + label: 聊天串 + close: "关闭聊天串" + original_message: + started_by: "开始于" + settings: "设置" + last_reply: "最后回复" + notifications: + regular: + title: "正常" + description: "您会在别人在聊天串里 @ 您或回复您时收到通知。" + tracking: + title: "跟踪" + description: "该聊天串的新回复计数将显示在聊天串列表和频道中。如果有人在此串中提及您的@name,您将会收到通知。" + participants_other_count: + other: "+%{count}" + threads: + open: "打开聊天串" + list: "正在进行的讨论" + none: "你没有参与这个频道中的任何聊天串。" draft_channel_screen: header: "新消息" cancel: "取消" @@ -438,9 +526,8 @@ zh_CN: transcript: view: "查看以前的消息副本" types: - reviewable_chat_message: + chat_reviewable_message: title: "举报的聊天消息" - flagged_by: "举报者" keyboard_shortcuts_help: chat: title: "聊天" @@ -453,6 +540,7 @@ zh_CN: composer_code: "%{shortcut} 代码(仅输入框)" drawer_open: "%{shortcut} 打开聊天抽屉" drawer_close: "%{shortcut} 关闭聊天抽屉" + mark_all_channels_read: "%{shortcut} 标记所有频道已读" topic_statuses: chat: help: "已为此话题启用聊天" @@ -470,3 +558,7 @@ zh_CN: chat_notifications: "聊天通知" chat_notifications_with_unread: other: "聊天通知 - %{count} 个未读通知" + styleguide: + sections: + chat: + title: 聊天 diff --git a/plugins/chat/config/locales/client.zh_TW.yml b/plugins/chat/config/locales/client.zh_TW.yml index 3fabfa46006..4a0320fdfdf 100644 --- a/plugins/chat/config/locales/client.zh_TW.yml +++ b/plugins/chat/config/locales/client.zh_TW.yml @@ -72,15 +72,15 @@ zh_TW: direct_message: "您還可以與一個或多個使用者開始個人聊天。" email_frequency: never: "永不" + header_indicator_preference: + never: "永不" flag: "檢舉" heading: "聊天" join: "加入" + last_visit: "上次到訪" mention_warning: dismiss: "忽略" - groups: - users_limit: - other: "%{count} 個使用者" - placeholder_start_conversation: 從 %{usernames}開始對話 + placeholder_start_conversation_users: "從 %{commaSeparatedUsernames}開始對話" remove_upload: "移除檔案" react: "用表情符號反應" reply: "回覆" @@ -95,7 +95,6 @@ zh_TW: restore: "還原已刪除的訊息" save: "保存" select: "選擇" - silence: "使用者靜音" scroll_to_bottom: "捲動到底部" scroll_to_new_messages: "查看新訊息" sound: @@ -135,6 +134,8 @@ zh_TW: about: 關於 members: 成員 settings: 設定 + new_message_modal: + no_items: "沒有物件" channel_edit_description_modal: title: 編輯描述 input_placeholder: 新增描述 @@ -143,8 +144,6 @@ zh_TW: prefix: "發至:" no_results: 沒有結果 selected_user_title: "取消選取 %{username}" - channel_selector: - title: "跳轉到頻道" create_channel: type: "類型" types: @@ -153,11 +152,14 @@ zh_TW: composer: italic_text: "斜體字" bold_text: "粗體字" + send: "發送" notification_levels: never: "永不" settings: follow: "加入" followed: "建立日期" + threading_enabled: "啟用" + threading_disabled: "停用" notifications: "通知" preview: "預覽" save: "保存" @@ -192,6 +194,17 @@ zh_TW: flags: "投訴" symbols: "象徵
    " no_results: "沒有結果" + thread: + title: "標題" + replies: + other: "%{count}個回覆" + settings: "設定" + last_reply: "最新回覆" + notifications: + regular: + title: "一般" + tracking: + title: "跟蹤" draft_channel_screen: header: "新訊息" cancel: "取消" @@ -203,10 +216,10 @@ zh_TW: fields: message: label: 訊息 - review: - types: - reviewable_chat_message: - flagged_by: "標記由" keyboard_shortcuts_help: chat: title: "聊天" + styleguide: + sections: + chat: + title: 聊天 diff --git a/plugins/chat/config/locales/server.ar.yml b/plugins/chat/config/locales/server.ar.yml index eb1401fe744..abd738363c4 100644 --- a/plugins/chat/config/locales/server.ar.yml +++ b/plugins/chat/config/locales/server.ar.yml @@ -54,16 +54,19 @@ ar: 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} من الأحرف." - message_too_long: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن %{maximum} من الأحرف." + message_too_long: + zero: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن %{count} حرف." + one: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن حرف واحد (%{count})." + two: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن حرفين (%{count})." + few: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن %{count} أحرف." + many: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن %{count} حرفًا." + other: "الرسالة طويلة جدًا، يجب ألا يزيد عدد أحرف الرسائل عن %{count} حرف." max_reactions_limit_reached: "غير مسموح بتفاعلات جديدة على هذه الرسالة." message_move_invalid_channel: "يجب أن تكون القناة المصدر والمستهدفة قناتين عامتين." message_move_no_messages_found: "لم يتم العثور على رسائل بمعرِّفات الرسائل المقدَّمة." @@ -74,13 +77,7 @@ ar: actor_disallowed_dms: "لقد اخترت منع المستخدمين من إرسال رسائل خاصة ومباشرة إليك؛ لذا لا يمكنك إنشاء رسائل مباشرة جديدة." actor_preventing_target_user_from_dm: "لقد اخترت منع %{username} من إرسال رسائل خاصة ومباشرة إليك؛ لذا لا يمكنك إنشاء رسائل مباشرة جديدة إليه." user_cannot_send_direct_messages: "عذرًا، لا يمكنك إرسال الرسائل المباشرة." - over_chat_max_direct_message_users: - zero: "يمكنك إرسال رسالة مباشرة إلى نفسك و%{count} مستخدم آخر." - one: "يمكنك إرسال رسالة مباشرة إلى نفسك فقط." - two: "يمكنك إرسال رسالة مباشرة إلى نفسك ومستخدمَين (%{count}) آخرين." - few: "يمكنك إرسال رسالة مباشرة إلى نفسك و%{count} مستخدمين آخرين." - many: "يمكنك إرسال رسالة مباشرة إلى نفسك و%{count} مستخدمًا آخر." - other: "يمكنك إرسال رسالة مباشرة إلى نفسك و%{count} مستخدم آخر." + over_chat_max_direct_message_users_allow_self: "يمكنك إرسال رسالة مباشرة إلى نفسك فقط." reviewables: message_already_handled: "شكرًا، لكننا راجعنا هذه الرسالة بالفعل وقرَّرنا أنك لست بحاجة إلى الإبلاغ عنها مرة أخرى." actions: @@ -117,11 +114,6 @@ ar: 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: @@ -132,9 +124,14 @@ ar: 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} آخرين" + single_user: "%{username}" + multi_user_truncated: + zero: "%{comma_separated_usernames} و%{count} آخرين" + one: "%{comma_separated_usernames} وواحد (%{count}) آخر" + two: "%{comma_separated_usernames} واثنان (%{count}) آخران" + few: "%{comma_separated_usernames} و%{count} آخرين" + many: "%{comma_separated_usernames} و%{count} آخرين" + other: "%{comma_separated_usernames} و%{count} آخرين" category_channel: errors: slug_contains_non_ascii_chars: "يتضمَّن حروفًا لا تنتمي إلى ترميز ASCII" diff --git a/plugins/chat/config/locales/server.be.yml b/plugins/chat/config/locales/server.be.yml index 0be1dcea5e6..74047d09386 100644 --- a/plugins/chat/config/locales/server.be.yml +++ b/plugins/chat/config/locales/server.be.yml @@ -23,9 +23,6 @@ be: title: "не згаджацца" ignore: title: "ігнараваць" - channel: - statuses: - closed: "Закрыта" reviewable_score_types: notify_user: chat_pm_body: "%{link}\n\n%{message}" diff --git a/plugins/chat/config/locales/server.bg.yml b/plugins/chat/config/locales/server.bg.yml index 7f6d844e418..30738705cd9 100644 --- a/plugins/chat/config/locales/server.bg.yml +++ b/plugins/chat/config/locales/server.bg.yml @@ -16,9 +16,8 @@ bg: ignore: title: "Игнорирай" channel: - statuses: - closed: "Затворена" - open: "Отвори" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.bs_BA.yml b/plugins/chat/config/locales/server.bs_BA.yml index 1060aaa9bce..6c550cbbea8 100644 --- a/plugins/chat/config/locales/server.bs_BA.yml +++ b/plugins/chat/config/locales/server.bs_BA.yml @@ -20,9 +20,8 @@ bs_BA: ignore: title: "Zanemari" channel: - statuses: - closed: "Zatvoreno" - open: "Otvori" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.ca.yml b/plugins/chat/config/locales/server.ca.yml index c0e7b4def35..fee12b99806 100644 --- a/plugins/chat/config/locales/server.ca.yml +++ b/plugins/chat/config/locales/server.ca.yml @@ -24,9 +24,8 @@ ca: ignore: title: "Ignora" channel: - statuses: - closed: "Tancat" - open: "Obre" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.cs.yml b/plugins/chat/config/locales/server.cs.yml index e81b47dc6dd..e402d97701f 100644 --- a/plugins/chat/config/locales/server.cs.yml +++ b/plugins/chat/config/locales/server.cs.yml @@ -22,9 +22,8 @@ cs: ignore: title: "ignorovat" channel: - statuses: - closed: "Uzavřeno" - open: "Otevřít" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.da.yml b/plugins/chat/config/locales/server.da.yml index dfc14fdf574..cb321d1feb2 100644 --- a/plugins/chat/config/locales/server.da.yml +++ b/plugins/chat/config/locales/server.da.yml @@ -34,13 +34,10 @@ da: 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})." + dm_title: + single_user: "%{username}" category_channel: errors: slug_contains_non_ascii_chars: "indeholder ikke-ascii- tegn" diff --git a/plugins/chat/config/locales/server.de.yml b/plugins/chat/config/locales/server.de.yml index a2623833d33..1346446c69e 100644 --- a/plugins/chat/config/locales/server.de.yml +++ b/plugins/chat/config/locales/server.de.yml @@ -7,6 +7,7 @@ de: site_settings: chat_enabled: "Chat-Plug-in aktivieren." + enable_public_channels: "Öffentliche Kanäle basierend auf Kategorien aktivieren." 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." @@ -54,16 +55,26 @@ de: 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" + channel_new_message_disallowed: + archived: "Der Kanal ist archiviert, es können keine neuen Nachrichten gesendet werden" + closed: "Der Kanal ist geschlossen, es können keine neuen Nachrichten gesendet werden" + read_only: "Der Kanal ist schreibgeschützt, es können keine neuen Nachrichten gesendet werden" + channel_modify_message_disallowed: + archived: "Der Kanal ist archiviert, es können keine Nachrichten bearbeitet oder gelöscht werden" + closed: "Der Kanal ist geschlossen, es können keine Nachrichten bearbeitet oder gelöscht werden" + read_only: "Der Kanal ist schreibgeschützt, 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." - message_too_long: "Nachricht ist zu lang. Nachrichten dürfen maximal %{maximum} Zeichen lang sein." + minimum_length_not_met: + one: "Die Nachricht ist zu kurz, sie muss mindestens %{count} Zeichen lang sein." + other: "Die Nachricht ist zu kurz, sie muss mindestens %{count} Zeichen lang sein." + message_too_long: + one: "Nachricht ist zu lang. Nachrichten dürfen maximal %{count} Zeichen lang sein." + other: "Nachricht ist zu lang. Nachrichten dürfen maximal %{count} Zeichen lang sein." draft_too_long: "Der Entwurf ist zu lang." 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." @@ -75,9 +86,13 @@ de: 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." + over_chat_max_direct_message_users_allow_self: "Du kannst nur eine Direktnachricht an dich selbst erstellen." over_chat_max_direct_message_users: - one: "Du kannst nur eine Direktnachricht an dich selbst erstellen." + one: "Du kannst keine Direktnachricht an mehr als %{count} anderen Benutzer erstellen." other: "Du kannst keine Direktnachricht an mehr als %{count} andere Benutzer erstellen." + original_message_not_found: "Der Vorgänger der Nachricht, auf die du antwortest, kann nicht gefunden werden oder wurde gelöscht." + thread_invalid_for_channel: "Der Thread ist nicht Teil des bereitgestellten Kanals." + thread_does_not_match_parent: "Der Thread stimmt nicht mit der übergeordneten Nachricht überein." reviewables: message_already_handled: "Danke, aber wir haben diese Nachricht bereits überprüft und festgestellt, dass sie nicht erneut markiert werden muss." actions: @@ -114,20 +129,17 @@ de: 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" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} und %{count} andere Person" + other: "%{comma_separated_usernames} und %{count} andere" category_channel: errors: slug_contains_non_ascii_chars: "enthält Nicht-ASCII-Zeichen" @@ -145,6 +157,8 @@ de: and_x_others: one: "und %{count} andere Person" other: "und %{count} andere" + summaries: + no_targets: "Im ausgewählten Zeitraum gab es keine Nachrichten." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.el.yml b/plugins/chat/config/locales/server.el.yml index 8f772e805f2..ae6a510fbcb 100644 --- a/plugins/chat/config/locales/server.el.yml +++ b/plugins/chat/config/locales/server.el.yml @@ -20,9 +20,8 @@ el: ignore: title: "Αγνόηση" channel: - statuses: - closed: "Κλειστό" - open: "Ξεκίνημα" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml index 372eda5e4a3..a8b4c143a43 100644 --- a/plugins/chat/config/locales/server.en.yml +++ b/plugins/chat/config/locales/server.en.yml @@ -1,6 +1,7 @@ en: site_settings: chat_enabled: "Enable the chat plugin." + enable_public_channels: "Enable public channels based on categories." 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." @@ -49,16 +50,26 @@ en: 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" + channel_new_message_disallowed: + archived: "The channel is archived, no new messages can be sent" + closed: "The channel is closed, no new messages can be sent" + read_only: "The channel is read only, no new messages can be sent" + channel_modify_message_disallowed: + archived: "The channel is archived, no messages can be edited or deleted" + closed: "The channel is closed, no messages can be edited or deleted" + read_only: "The channel is read only, 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." - message_too_long: "Message is too long, messages must be a maximum of %{maximum} characters." + minimum_length_not_met: + one: "Message is too short, must have a minimum of %{count} character." + other: "Message is too short, must have a minimum of %{count} characters." + message_too_long: + one: "Message is too long, messages must be a maximum of %{count} character." + other: "Message is too long, messages must be a maximum of %{count} characters." draft_too_long: "Draft is too long." 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." @@ -70,9 +81,13 @@ en: 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." + over_chat_max_direct_message_users_allow_self: "You can only create a direct message with yourself." over_chat_max_direct_message_users: - one: "You can only create a direct message with yourself." + one: "You can't create a direct message with more than %{count} other user." other: "You can't create a direct message with more than %{count} other users." + original_message_not_found: "The ancestor of the message you are replying to cannot be found or has been deleted." + thread_invalid_for_channel: "Thread is not part of the provided channel." + thread_does_not_match_parent: "Thread does not match parent message." reviewables: message_already_handled: "Thanks, but we've already reviewed this message and determined it does not need to be flagged again." actions: @@ -109,20 +124,17 @@ en: 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" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} and %{count} other" + other: "%{comma_separated_usernames} and %{count} others" category_channel: errors: @@ -147,6 +159,9 @@ en: one: "and %{count} other" other: "and %{count} others" + summaries: + no_targets: "There were no messages during the selected period." + discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.es.yml b/plugins/chat/config/locales/server.es.yml index 89e3a61c261..647460132e7 100644 --- a/plugins/chat/config/locales/server.es.yml +++ b/plugins/chat/config/locales/server.es.yml @@ -7,6 +7,7 @@ es: site_settings: chat_enabled: "Habilitar el complemento de chat." + enable_public_channels: "Habilitar canales públicos basados en categorías." 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." @@ -54,16 +55,26 @@ es: 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" + channel_new_message_disallowed: + archived: "El canal está archivado, no se pueden enviar nuevos mensajes" + closed: "El canal está cerrado, no se pueden enviar nuevos mensajes" + read_only: "El canal es de solo lectura, no se pueden enviar nuevos mensajes" + channel_modify_message_disallowed: + archived: "El canal está archivado, no se pueden editar ni eliminar mensajes" + closed: "El canal está cerrado, no se pueden editar ni eliminar mensajes" + read_only: "El canal es de solo lectura, 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." - message_too_long: "El mensaje es demasiado largo, los mensajes deben tener un máximo de %{maximum} caracteres." + minimum_length_not_met: + one: "El mensaje es demasiado corto, debe tener un mínimo de %{count} carácter." + other: "El mensaje es demasiado corto, debe tener un mínimo de %{count} caracteres." + message_too_long: + one: "El mensaje es demasiado largo, los mensajes deben tener un máximo de %{count} carácter." + other: "El mensaje es demasiado largo, los mensajes deben tener un máximo de %{count} caracteres." draft_too_long: "El borrador es demasiado largo." 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." @@ -75,9 +86,13 @@ es: 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." + over_chat_max_direct_message_users_allow_self: "Solo puedes crear un mensaje directo contigo mismo." over_chat_max_direct_message_users: - one: "Solo puedes crear un mensaje directo contigo mismo." + one: "No puedes crear un mensaje directo con más de %{count} usuario." other: "No puedes crear un mensaje directo con más de %{count} usuarios." + original_message_not_found: "El antecesor del mensaje al que estás respondiendo no se encuentra o ha sido eliminado." + thread_invalid_for_channel: "El hilo no forma parte del canal proporcionado." + thread_does_not_match_parent: "El hilo no coincide con el mensaje principal." reviewables: message_already_handled: "Gracias, pero ya hemos revisado este mensaje y hemos determinado que no es necesario marcarlo de nuevo." actions: @@ -114,20 +129,17 @@ es: 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" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} y %{count} más" + other: "%{comma_separated_usernames} y otros %{count}" category_channel: errors: slug_contains_non_ascii_chars: "contiene caracteres no ascii" @@ -145,6 +157,8 @@ es: and_x_others: one: "y %{count} otros" other: "y %{count} otros" + summaries: + no_targets: "No hubo mensajes durante el periodo seleccionado." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.et.yml b/plugins/chat/config/locales/server.et.yml index 432df77dfce..3be298b397c 100644 --- a/plugins/chat/config/locales/server.et.yml +++ b/plugins/chat/config/locales/server.et.yml @@ -20,9 +20,8 @@ et: ignore: title: "Ignoreeri" channel: - statuses: - closed: "Suletud" - open: "Ava" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.fa_IR.yml b/plugins/chat/config/locales/server.fa_IR.yml index a9f994cca76..195f79de7e7 100644 --- a/plugins/chat/config/locales/server.fa_IR.yml +++ b/plugins/chat/config/locales/server.fa_IR.yml @@ -20,7 +20,9 @@ fa_IR: deleted_chat_username: حذف شد errors: channel_exists_for_category: "یک کانال دیگر از قبل برای این دسته‌بندی و نام وجود دارد" - message_too_long: "پیام خیلی طولانی است، پیام‌ها باید حداکثر %{maximum} کاراکتر داشته باشند." + message_too_long: + one: "پیام خیلی طولانی است، پیام‌ها باید حداکثر %{count} کاراکتر داشته باشند." + other: "پیام خیلی طولانی است، پیام‌ها باید حداکثر %{count} کاراکتر داشته باشند." draft_too_long: "پیش‌نویس خیلی طولانی است." cant_update_direct_message_channel: "ویژگی پیام مستقیم کانال مانند نام و توضیحات را نمی‌توان به‌روز کرد." not_accepting_dms: "با عرض پوزش، کاربر %{username} در حال حاضر پیام نمی‌پذیرد." @@ -52,15 +54,11 @@ fa_IR: 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} نفر دیگر" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} و %{count} نفر دیگر" + other: "%{comma_separated_usernames} و %{count} نفر دیگر" bookmarkable: notification_title: "پیام در %{channel_name}" personal_chat: "گفتگوی شخصی" diff --git a/plugins/chat/config/locales/server.fi.yml b/plugins/chat/config/locales/server.fi.yml index 822200ae878..a472a015158 100644 --- a/plugins/chat/config/locales/server.fi.yml +++ b/plugins/chat/config/locales/server.fi.yml @@ -7,6 +7,7 @@ fi: site_settings: chat_enabled: "Ota chat-lisäosa käyttöön." + enable_public_channels: "Käytä julkisia kanavia kategorioiden perusteella." 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." @@ -44,16 +45,15 @@ fi: 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ä." - message_too_long: "Viesti on liian pitkä, viesteissä täytyy olla enintään %{maximum} merkkiä." + message_too_long: + one: "Viesti on liian pitkä, viesteissä täytyy olla enintään %{count} merkkiä." + other: "Viesti on liian pitkä, viesteissä täytyy olla enintään %{count} 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ä." @@ -64,9 +64,9 @@ fi: 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ä." - over_chat_max_direct_message_users: - one: "Voit luoda yksityisviestin vain itsellesi." - other: "Et voi luoda yksityisviestiä useammalle kuin %{count} muulle käyttäjälle." + over_chat_max_direct_message_users_allow_self: "Voit luoda yksityisviestin vain itsellesi." + thread_invalid_for_channel: "Ketju ei kuulu ilmoitetulle kanavalle." + thread_does_not_match_parent: "Ketju ei vastaa ylemmän tason viestiä." reviewables: message_already_handled: "Kiitos, mutta olemme jo käsitelleet tämän viestin ja todenneet, ettei sitä tarvitse liputtaa uudelleen." actions: @@ -103,20 +103,16 @@ fi: 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" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} ja %{count} muu" + other: "%{comma_separated_usernames} ja %{count} muuta" category_channel: errors: slug_contains_non_ascii_chars: "sisältää muita kuin ascii-merkkejä" diff --git a/plugins/chat/config/locales/server.fr.yml b/plugins/chat/config/locales/server.fr.yml index 76ae88a4de2..18941228f3b 100644 --- a/plugins/chat/config/locales/server.fr.yml +++ b/plugins/chat/config/locales/server.fr.yml @@ -54,16 +54,15 @@ fr: 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." - message_too_long: "Le message est trop long, les messages doivent comporter au maximum %{maximum} caractères." + message_too_long: + one: "Le message est trop long, les messages doivent comporter au maximum %{count} caractères." + other: "Le message est trop long, les messages doivent comporter au maximum %{count} 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." @@ -74,9 +73,7 @@ fr: 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." - over_chat_max_direct_message_users: - one: "Vous ne pouvez créer un message privé qu'avec vous-même." - other: "Vous ne pouvez pas créer de message privé avec plus de %{count} autres utilisateurs." + over_chat_max_direct_message_users_allow_self: "Vous ne pouvez créer un message privé qu'avec vous-même." 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: @@ -113,20 +110,16 @@ fr: 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" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} et %{count} autre participant" + other: "%{comma_separated_usernames} et %{count} autres utilisateurs" category_channel: errors: slug_contains_non_ascii_chars: "contient des caractères non ASCII" diff --git a/plugins/chat/config/locales/server.gl.yml b/plugins/chat/config/locales/server.gl.yml index c1abe6a185a..05e39da2bbf 100644 --- a/plugins/chat/config/locales/server.gl.yml +++ b/plugins/chat/config/locales/server.gl.yml @@ -24,9 +24,8 @@ gl: ignore: title: "Ignorar" channel: - statuses: - closed: "Pechado" - open: "Abrir" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.he.yml b/plugins/chat/config/locales/server.he.yml index 85772838abe..a2372479c4b 100644 --- a/plugins/chat/config/locales/server.he.yml +++ b/plugins/chat/config/locales/server.he.yml @@ -7,6 +7,7 @@ he: site_settings: chat_enabled: "הפעלת תוסף הצ׳אט." + enable_public_channels: "הפעלת ערוצים ציבוריים על בסיס קטגוריות." chat_allowed_groups: "משתמשים בקבוצות אלה יכולים לשוחח בצ׳אט. נא לשים לב שהסגל תמיד יכול לגשת לצ׳אט." chat_channel_retention_days: "הודעות הצ׳אט בערוצים הרגילים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." chat_dm_retention_days: "הודעות הצ׳אט בערוצי הצ׳אט האישיים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." @@ -54,16 +55,30 @@ he: deleted_chat_username: נמחק errors: channel_exists_for_category: "כבר קיים ערוץ לקטגוריה ולשם האלו" - channel_new_message_disallowed: "הערוץ %{status}, לא ניתן לשלוח הודעות חדשות" - channel_modify_message_disallowed: "הערוץ %{status}, לא ניתן לערוך או למחוק הודעות" + channel_new_message_disallowed: + archived: "הערוץ נמצא בארכיון, לא ניתן לשלוח הודעות חדשות" + closed: "הערוץ סגור, לא ניתן לשלוח הודעות חדשות" + read_only: "הערוץ הוא לקריאה בלבד, לא ניתן לשלוח הודעות חדשות" + channel_modify_message_disallowed: + archived: "הערוץ שמור בארכיון, אין אפשרות לערוך או למחוק הודעות" + closed: "הערוץ סגור, אין אפשרות לערוך או למחוק הודעות" + read_only: "הערוץ הוא לקריאה בלבד, אין אפשרות לערוך או למחוק הודעות" 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} תווים" - message_too_long: "ההודעה ארוכה מדי, היא חייבת להיות באורך של עד %{maximum} תווים לכל היותר." + minimum_length_not_met: + one: "ההודעה קצרה מדי, חייבת להיות באורך של תו %{count} לפחות." + two: "ההודעה קצרה מדי, חייבת להיות באורך של %{count} תווים לפחות." + many: "ההודעה קצרה מדי, חייבת להיות באורך של %{count} תווים לפחות." + other: "ההודעה קצרה מדי, חייבת להיות באורך של %{count} תווים לפחות." + message_too_long: + one: "ההודעה ארוכה מדי, היא חייבת להיות באורך של עד %{count} תווים לכל היותר." + two: "ההודעה ארוכה מדי, היא חייבת להיות באורך של עד %{count} תווים לכל היותר." + many: "ההודעה ארוכה מדי, היא חייבת להיות באורך של עד %{count} תווים לכל היותר." + other: "ההודעה ארוכה מדי, היא חייבת להיות באורך של עד %{count} תווים לכל היותר." draft_too_long: "הטיוטה ארוכה מדי." max_reactions_limit_reached: "רגשות חדשים אסורים בהודעה זו." message_move_invalid_channel: "ערוצי המקור והיעד חייבים להיות ערוצים ציבוריים." @@ -75,11 +90,15 @@ he: actor_disallowed_dms: "בחרת למנוע ממשתמשים לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות." actor_preventing_target_user_from_dm: "בחרת למנוע מ־%{username} לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות אליהם." user_cannot_send_direct_messages: "מחילה, אין לך אפשרות לשלוח הודעות ישירות." + over_chat_max_direct_message_users_allow_self: "אין לך אפשרות ליצור הודעות ישירות מול עצמך." over_chat_max_direct_message_users: - one: "אין לך אפשרות ליצור הודעות ישירות מול עצמך." + one: "אין לך אפשרות ליצור הודעות ישירות מול למעלה ממשתמש %{count} נוסף." two: "אין לך אפשרות ליצור הודעות ישירות מול למעלה מ־%{count} משתמשים נוספים." many: "אין לך אפשרות ליצור הודעות ישירות מול למעלה מ־%{count} משתמשים נוספים." other: "אין לך אפשרות ליצור הודעות ישירות מול למעלה מ־%{count} משתמשים נוספים." + original_message_not_found: "מקור ההודעה אליה ניסית להגיב לא נמצא או שנמחק." + thread_invalid_for_channel: "השרשור הוא לא חלק מהערוץ שסופק." + thread_does_not_match_parent: "השרשור לא תואם להודעת ההורה." reviewables: message_already_handled: "תודה, אבל כבר סקרנו הודעה זו וקבענו שאין צורך לסמן אותה שוב." actions: @@ -116,11 +135,6 @@ he: 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: @@ -129,9 +143,13 @@ he: 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}" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} ועוד %{count}" + two: "%{comma_separated_usernames} ועוד %{count}" + many: "%{comma_separated_usernames} ועוד %{count}" + other: "%{comma_separated_usernames} ועוד %{count}" category_channel: errors: slug_contains_non_ascii_chars: "מכיל תווים שאינם בתחום ASCII" @@ -153,6 +171,8 @@ he: two: "ו־%{count} נוספים" many: "ו־%{count} נוספים" other: "ו־%{count} נוספים" + summaries: + no_targets: "לא היו הודעות במהלך התקופה הנבחרת." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.hr.yml b/plugins/chat/config/locales/server.hr.yml index b3ecf9d0fee..b3229ed1857 100644 --- a/plugins/chat/config/locales/server.hr.yml +++ b/plugins/chat/config/locales/server.hr.yml @@ -27,9 +27,8 @@ hr: ignore: title: "Zanemari" channel: - statuses: - closed: "Zatvoreno" - open: "Otvori" + dm_title: + single_user: "%{username}" category_channel: errors: slug_contains_non_ascii_chars: "sadrži ne-ascii znakove" diff --git a/plugins/chat/config/locales/server.hu.yml b/plugins/chat/config/locales/server.hu.yml index 21e49c8edf0..db7d5545414 100644 --- a/plugins/chat/config/locales/server.hu.yml +++ b/plugins/chat/config/locales/server.hu.yml @@ -33,16 +33,15 @@ hu: 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." - message_too_long: "Az üzenet túl hosszú, az üzenetek legfeljebb %{maximum} karakterekből állhatnak." + message_too_long: + one: "Az üzenet túl hosszú, az üzenetek legfeljebb %{count} karakterekből állhatnak." + other: "Az üzenet túl hosszú, az üzenetek legfeljebb %{count} karakterekből állhatnak." draft_too_long: "A vázlat túl hosszú." 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." @@ -79,20 +78,16 @@ hu: 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ő" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} és még %{count} fő" + other: "%{comma_separated_usernames} és még %{count} fő" category_channel: errors: slug_contains_non_ascii_chars: "nem ASCII karaktereket tartalmaz" diff --git a/plugins/chat/config/locales/server.hy.yml b/plugins/chat/config/locales/server.hy.yml index 6248aff03a1..ceae459c455 100644 --- a/plugins/chat/config/locales/server.hy.yml +++ b/plugins/chat/config/locales/server.hy.yml @@ -22,9 +22,8 @@ hy: ignore: title: "Անտեսել" channel: - statuses: - closed: "Փակված" - open: "Բացել" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.id.yml b/plugins/chat/config/locales/server.id.yml index 45f2f9674c8..9a9100c1c2a 100644 --- a/plugins/chat/config/locales/server.id.yml +++ b/plugins/chat/config/locales/server.id.yml @@ -7,6 +7,7 @@ id: site_settings: chat_enabled: "Aktifkan plugin obrolan." + enable_public_channels: "Aktifkan saluran publik berdasarkan kategori." chat_allowed_groups: "Pengguna dalam grup ini dapat mengobrol. Perhatikan bahwa staf selalu dapat mengakses obrolan." chat_channel_retention_days: "Pesan obrolan di kanal reguler akan disimpan selama beberapa hari ini. Setel ke '0' untuk mempertahankan pesan selamanya." chat_dm_retention_days: "Pesan obrolan di kanal obrolan pribadi akan dipertahankan selama beberapa hari ini. Setel ke '0' untuk mempertahankan pesan selamanya." @@ -54,16 +55,24 @@ id: deleted_chat_username: dihapus errors: channel_exists_for_category: "Kanal sudah ada untuk kategori dan nama ini" - channel_new_message_disallowed: "Kanalnya %{status}, tidak ada pesan baru yang dapat dikirim" - channel_modify_message_disallowed: "Kanalnya %{status}, tidak ada pesan yang dapat diedit atau dihapus" + channel_new_message_disallowed: + archived: "Kanal diarsipkan, tidak ada pesan baru yang dapat dikirim" + closed: "Kanal ditutup, tidak ada pesan baru yang dapat dikirim" + read_only: "Kanal hanya dapat dibaca, tidak ada pesan baru yang dapat dikirim" + channel_modify_message_disallowed: + archived: "Kanal diarsipkan, tidak ada pesan yang dapat diedit atau dihapus" + closed: "Kanal ditutup, tidak ada pesan yang dapat diedit atau dihapus" + read_only: "Kanal hanya dapat dibaca, tidak ada pesan yang dapat diedit atau dihapus" user_cannot_send_message: "Anda tidak dapat mengirim pesan saat ini." rate_limit_exceeded: "Melebihi batas pesan obrolan yang dapat dikirim dalam waktu 30 detik" auto_silence_from_flags: "Pesan obrolan ditandai dengan skor yang cukup tinggi untuk membuat senyap pengguna." channel_cannot_be_archived: "Kanal tidak dapat diarsipkan saat ini, harus ditutup atau dibuka untuk diarsipkan." duplicate_message: "Anda mengirim pesan yang sama baru-baru ini." delete_channel_failed: "Hapus kanal gagal, harap coba lagi." - minimum_length_not_met: "Pesan terlalu singkat, minimal harus berisi %{minimum} karakter." - message_too_long: "Pesan terlalu panjang, pesan harus maksimal %{maximum} karakter." + minimum_length_not_met: + other: "Pesan terlalu singkat, minimal harus berisi %{count} karakter." + message_too_long: + other: "Pesan terlalu panjang, pesan harus maksimal %{count} karakter." draft_too_long: "Draf terlalu panjang." max_reactions_limit_reached: "Reaksi baru tidak diperbolehkan pada pesan ini." message_move_invalid_channel: "Kanal sumber dan tujuan harus kanal publik." @@ -75,8 +84,12 @@ id: actor_disallowed_dms: "Anda telah memilih untuk mencegah pengguna mengirimi Anda pesan langsung dan pribadi, sehingga Anda tidak dapat membuat pesan langsung baru." actor_preventing_target_user_from_dm: "Anda telah memilih untuk mencegah %{username} mengirimi Anda pesan langsung dan pribadi, sehingga Anda tidak dapat membuat pesan langsung baru untuk dia." user_cannot_send_direct_messages: "Maaf, Anda tidak dapat mengirim pesan langsung." + over_chat_max_direct_message_users_allow_self: "Anda hanya dapat membuat pesan langsung dengan diri Anda sendiri." over_chat_max_direct_message_users: other: "Anda tidak dapat membuat pesan langsung dengan lebih dari %{count} pengguna lain." + original_message_not_found: "Nenek moyang pesan yang Anda balas tidak dapat ditemukan atau telah dihapus." + thread_invalid_for_channel: "Utas bukan bagian dari saluran yang disediakan." + thread_does_not_match_parent: "Utas tidak cocok dengan pesan induk." reviewables: message_already_handled: "Terima kasih, tetapi kami telah meninjau pesan ini dan memutuskan bahwa pesan ini tidak perlu ditandai lagi." actions: @@ -113,19 +126,15 @@ id: transcript_title: "Transkrip pesan sebelumnya di %{channel_name}" transcript_body: "Untuk memberi Anda lebih banyak konteks, kami menyertakan transkrip pesan sebelumnya dalam percakapan ini (hingga sepuluh):\n\n%{transcript}" channel: - statuses: - read_only: "Hanya Baca" - archived: "Diarsipkan" - closed: "Tertutup" - open: "Buka" archive: first_post_raw: "Topik ini adalah arsip dari kanal obrolan [%{channel_name}](%{channel_url})." messages_moved: other: "@%{acting_username} memindahkan %{count} pesan ke kanal [%{channel_name}](%{first_moved_message_url})." dm_title: - single_user: "%{user}" - multi_user: "%{users}" - multi_user_truncated: "%{users} dan %{leftover} lainnya" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + other: "%{comma_separated_usernames} dan %{count} lainnya" category_channel: errors: slug_contains_non_ascii_chars: "berisi karakter non-ascii" @@ -141,6 +150,8 @@ id: other: "%{count} anggota" and_x_others: other: "dan %{count} lainnya" + summaries: + no_targets: "Tidak ada pesan selama periode yang dipilih." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.it.yml b/plugins/chat/config/locales/server.it.yml index f5db57f1f65..c069e1bc1dc 100644 --- a/plugins/chat/config/locales/server.it.yml +++ b/plugins/chat/config/locales/server.it.yml @@ -7,6 +7,7 @@ it: site_settings: chat_enabled: "Abilita il plug-in della chat." + enable_public_channels: "Abilita i canali pubblici in base alle categorie." 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." @@ -48,16 +49,23 @@ it: 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" + channel_new_message_disallowed: + archived: "Il canale è archiviato, non è possibile inviare nuovi messaggi" + closed: "Il canale è chiuso, non è possibile inviare nuovi messaggi" + read_only: "Il canale è di sola lettura, non è possibile inviare nuovi messaggi" + channel_modify_message_disallowed: + archived: "Il canale è archiviato, nessun messaggio può essere modificato o cancellato" + closed: "Il canale è chiuso, nessun messaggio può essere modificato o cancellato" + read_only: "Il canale è a sola lettura, 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." - message_too_long: "Il messaggio è troppo lungo. I messaggi devono contenere al massimo %{maximum} caratteri." + message_too_long: + one: "Il messaggio è troppo lungo. I messaggi devono contenere al massimo %{count} caratteri." + other: "Il messaggio è troppo lungo. I messaggi devono contenere al massimo %{count} caratteri." draft_too_long: "La bozza è troppo lunga." 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." @@ -69,9 +77,10 @@ it: 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." - over_chat_max_direct_message_users: - one: "Puoi creare solo un messaggio diretto a te stesso." - other: "Non puoi creare un messaggio diretto con più di altri %{count} utenti." + over_chat_max_direct_message_users_allow_self: "Puoi creare solo un messaggio diretto a te stesso." + original_message_not_found: "L'antenato del messaggio a cui stai rispondendo non è stato trovato o è stato eliminato." + thread_invalid_for_channel: "Il thread non fa parte del canale fornito." + thread_does_not_match_parent: "Il thread non corrisponde al messaggio principale." reviewables: message_already_handled: "Grazie, ma abbiamo già esaminato questo messaggio e stabilito che non è necessario contrassegnarlo di nuovo." actions: @@ -108,20 +117,17 @@ it: 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: "Archiviato" - closed: "Chiuso" - 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}" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} e %{count} altro" + other: "%{comma_separated_usernames} e altri %{count}" category_channel: errors: slug_contains_non_ascii_chars: "contiene caratteri non-ascii" @@ -139,6 +145,8 @@ it: and_x_others: one: "e %{count} altro" other: "e %{count} altri" + summaries: + no_targets: "Non ci sono stati messaggi durante il periodo selezionato." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.ja.yml b/plugins/chat/config/locales/server.ja.yml index ba98e2e4828..79c2de29fc3 100644 --- a/plugins/chat/config/locales/server.ja.yml +++ b/plugins/chat/config/locales/server.ja.yml @@ -44,16 +44,16 @@ ja: 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} 文字が必要です。" - message_too_long: "メッセージが長すぎます。メッセージは最大 %{maximum} 文字までです。" + minimum_length_not_met: + other: "メッセージが短すぎます。最低 %{count} 文字が必要です。" + message_too_long: + other: "メッセージが長すぎます。メッセージは最大 %{count} 文字までです。" max_reactions_limit_reached: "このメッセージでは、新しいリアクションは許可されていません。" message_move_invalid_channel: "移動元と移動先のチャンネルは公開チャンネルである必要があります。" message_move_no_messages_found: "指定されたメッセージ ID を持つメッセージは見つかりませんでした。" @@ -102,19 +102,14 @@ ja: transcript_title: "%{channel_name} の過去のメッセージのトランスクリプト" transcript_body: "より文脈を掴みやすいように、この会話の過去のメッセージのトランスクリプトを含めました (最大 10 件)。\n\n%{transcript}" channel: - statuses: - read_only: "より文脈を掴みやすいように、この会話の過去のメッセージのトランスクリプトを含めました (最大 10 件)。\n\n%{transcript}" - 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} 人" + single_user: "%{username}" + multi_user_truncated: + other: "%{comma_separated_usernames} および他 %{count} 人" category_channel: errors: slug_contains_non_ascii_chars: "に非 ASCII 文字が含まれます" diff --git a/plugins/chat/config/locales/server.ko.yml b/plugins/chat/config/locales/server.ko.yml index d430a901bb2..3d7c2ede5d5 100644 --- a/plugins/chat/config/locales/server.ko.yml +++ b/plugins/chat/config/locales/server.ko.yml @@ -36,11 +36,8 @@ ko: ignore: title: "무시" channel: - statuses: - read_only: "읽기 전용" - archived: "저장됨" - closed: "닫힘" - open: "열기" + dm_title: + single_user: "%{username}" category_channel: errors: slug_contains_non_ascii_chars: "비 ASCII 문자 포함" diff --git a/plugins/chat/config/locales/server.lt.yml b/plugins/chat/config/locales/server.lt.yml index d6dc1b7c1e1..d0810cc3132 100644 --- a/plugins/chat/config/locales/server.lt.yml +++ b/plugins/chat/config/locales/server.lt.yml @@ -23,9 +23,8 @@ lt: ignore: title: "Ignoruoti" channel: - statuses: - closed: "Uždaryta" - open: "Atidaryti" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.lv.yml b/plugins/chat/config/locales/server.lv.yml index 9c059cb15e0..aadf2f99b4d 100644 --- a/plugins/chat/config/locales/server.lv.yml +++ b/plugins/chat/config/locales/server.lv.yml @@ -16,9 +16,8 @@ lv: ignore: title: "Ignorēt" channel: - statuses: - closed: "Slēgts" - open: "Atvērt" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.nb_NO.yml b/plugins/chat/config/locales/server.nb_NO.yml index bc8fd472cc0..17e00928687 100644 --- a/plugins/chat/config/locales/server.nb_NO.yml +++ b/plugins/chat/config/locales/server.nb_NO.yml @@ -22,9 +22,8 @@ nb_NO: ignore: title: "Ignorer" channel: - statuses: - closed: "Lukket" - open: "Åpne" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.nl.yml b/plugins/chat/config/locales/server.nl.yml index 909e2217584..d7da7369958 100644 --- a/plugins/chat/config/locales/server.nl.yml +++ b/plugins/chat/config/locales/server.nl.yml @@ -44,16 +44,15 @@ nl: deleted_chat_username: verwijderd errors: channel_exists_for_category: "Er bestaat al een kanaal voor deze categorie en naam" - channel_new_message_disallowed: "Het kanaal is %{status}, er kunnen geen nieuwe berichten worden gestuurd" - channel_modify_message_disallowed: "Het kanaal is %{status}, er kunnen geen berichten worden bewerkt of verwijderd" user_cannot_send_message: "Je kunt op dit moment geen berichten sturen." rate_limit_exceeded: "De limiet van chatberichten die binnen 30 seconden kunnen worden gestuurd is overschreden" auto_silence_from_flags: "Chatbericht gemarkeerd met een score die hoog genoeg is om de gebruiker te dempen." channel_cannot_be_archived: "Het kanaal kan op dit moment niet worden gearchiveerd, het moet gesloten zijn of open voor archivering." duplicate_message: "Je hebt te kort geleden een identiek bericht geplaatst." delete_channel_failed: "Kanaal verwijderen mislukt, probeer het opnieuw." - minimum_length_not_met: "Bericht is te kort, moet minimaal %{minimum} tekens bevatten." - message_too_long: "Bericht is te lang, berichten mogen maximaal %{maximum} tekens lang zijn." + message_too_long: + one: "Bericht is te lang, berichten mogen maximaal %{count} tekens lang zijn." + other: "Bericht is te lang, berichten mogen maximaal %{count} tekens lang zijn." max_reactions_limit_reached: "Nieuwe reacties op dit bericht zijn niet toegestaan." message_move_invalid_channel: "Het bron- en bestemmingskanaal moeten openbare kanalen zijn." message_move_no_messages_found: "Er zijn geen berichten gevonden met de opgegeven bericht-ID's." @@ -64,9 +63,7 @@ nl: actor_disallowed_dms: "Je hebt ervoor gekozen om te voorkomen dat gebruikers je privé- en directe berichten sturen, dus je kunt geen nieuwe directe berichten maken." actor_preventing_target_user_from_dm: "Je hebt ervoor gekozen om te voorkomen dat %{username} je privé- en directe berichten stuurt, dus je kunt geen nieuwe directe berichten maken." user_cannot_send_direct_messages: "Sorry, je kunt geen directe berichten sturen." - over_chat_max_direct_message_users: - one: "Je kunt alleen een direct bericht met jezelf maken." - other: "Je kunt geen direct bericht met meer dan %{count} andere gebruikers maken." + over_chat_max_direct_message_users_allow_self: "Je kunt alleen een direct bericht met jezelf maken." reviewables: message_already_handled: "Bedankt, maar we hebben dat bericht al beoordeeld en vastgesteld dat het niet opnieuw hoeft te worden gemarkeerd." actions: @@ -103,20 +100,16 @@ nl: transcript_title: "Transcript van eerdere berichten in %{channel_name}" transcript_body: "Om je meer context te geven, hebben we een transcript van de vorige berichten opgenomen in deze conversatie (maximaal tien):\n\n%{transcript}" channel: - statuses: - read_only: "Alleen-lezen" - archived: "Gearchiveerd" - closed: "Gesloten" - open: "Open" archive: first_post_raw: "Dit topic is een archief van het chatkanaal [%{channel_name}](%{channel_url})." messages_moved: one: "@%{acting_username} heeft een bericht verplaatst naar het kanaal [%{channel_name}](%{first_moved_message_url})." other: "@%{acting_username} heeft %{count} berichten verplaatst naar het kanaal [%{channel_name}](%{first_moved_message_url})." dm_title: - single_user: "%{user}" - multi_user: "%{users}" - multi_user_truncated: "%{users} en %{leftover} anderen" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} en %{count} ander" + other: "%{comma_separated_usernames} en %{count} anderen" category_channel: errors: slug_contains_non_ascii_chars: "bevat niet-ASCII-tekens" diff --git a/plugins/chat/config/locales/server.pl_PL.yml b/plugins/chat/config/locales/server.pl_PL.yml index 9deea8b5329..68047c8ba56 100644 --- a/plugins/chat/config/locales/server.pl_PL.yml +++ b/plugins/chat/config/locales/server.pl_PL.yml @@ -54,16 +54,30 @@ pl_PL: deleted_chat_username: usunięte errors: channel_exists_for_category: "Istnieje już kanał dla tej kategorii i nazwy" - channel_new_message_disallowed: "Kanał jest %{status}, nie można wysyłać nowych wiadomości." - channel_modify_message_disallowed: "Kanał jest %{status}, żadne wiadomości nie mogą być edytowane ani usuwane" + channel_new_message_disallowed: + archived: "Kanał jest zarchiwizowany, nie można wysyłać nowych wiadomości" + closed: "Kanał jest zamknięty, nie można wysyłać nowych wiadomości" + read_only: "Kanał jest tylko do odczytu, nie można wysyłać nowych wiadomości" + channel_modify_message_disallowed: + archived: "Kanał jest zarchiwizowany, nie można edytować ani usuwać żadnych wiadomości" + closed: "Kanał jest zamknięty, nie można edytować ani usuwać żadnych wiadomości" + read_only: "Kanał jest tylko do odczytu, nie można edytować ani usuwać żadnych wiadomości" 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" auto_silence_from_flags: "Wiadomość na czacie oflagowana z wynikiem wystarczającym do wyciszenia użytkownika." channel_cannot_be_archived: "Kanał nie może być w tej chwili zarchiwizowany, musi być zamknięty lub otwarty do archiwizacji." duplicate_message: "Zbyt niedawno opublikowałeś identyczną wiadomość." delete_channel_failed: "Nie udało się usunąć kanału, spróbuj ponownie." - minimum_length_not_met: "Wiadomość jest za krótka, musi mieć co najmniej %{minimum} znaków." - message_too_long: "Wiadomość jest za długa, wiadomości mogą mieć maksymalnie %{maximum} znaków." + minimum_length_not_met: + one: "Wiadomość jest za krótka, musi mieć co najmniej %{count} znak." + few: "Wiadomość jest za krótka, musi mieć co najmniej %{count} znaki." + many: "Wiadomość jest za krótka, musi mieć co najmniej %{count} znaków." + other: "Wiadomość jest za krótka, musi mieć co najmniej %{count} znaków." + message_too_long: + one: "Wiadomość jest za długa, wiadomości mogą mieć maksymalnie %{count} znaków." + few: "Wiadomość jest za długa, wiadomości mogą mieć maksymalnie %{count} znaków." + many: "Wiadomość jest za długa, wiadomości mogą mieć maksymalnie %{count} znaków." + other: "Wiadomość jest za długa, wiadomości mogą mieć maksymalnie %{count} znaków." draft_too_long: "Wersja robocza jest za długa." max_reactions_limit_reached: "Nowe reakcje nie są dozwolone w tej wiadomości." message_move_invalid_channel: "Kanał źródłowy i docelowy muszą być kanałami publicznymi." @@ -75,11 +89,15 @@ pl_PL: actor_disallowed_dms: "Zdecydowałeś się uniemożliwić użytkownikom wysyłanie Ci wiadomości prywatnych i bezpośrednich, więc nie możesz tworzyć nowych wiadomości bezpośrednich." actor_preventing_target_user_from_dm: "Zdecydowałeś się uniemożliwić %{username} wysyłanie Ci prywatnych i bezpośrednich wiadomości, więc nie możesz tworzyć nowych bezpośrednich wiadomości do niego." user_cannot_send_direct_messages: "Przepraszamy, nie możesz wysyłać bezpośrednich wiadomości." + over_chat_max_direct_message_users_allow_self: "Możesz utworzyć bezpośrednią wiadomość tylko z samym sobą." over_chat_max_direct_message_users: - one: "Możesz utworzyć bezpośrednią wiadomość tylko z samym sobą." - few: "Nie można utworzyć wiadomości bezpośredniej z więcej niż %{count} innymi użytkownikami." - many: "Nie można utworzyć wiadomości bezpośredniej z więcej niż %{count} innymi użytkownikami." - other: "Nie można utworzyć wiadomości bezpośredniej z więcej niż %{count} innymi użytkownikami." + one: "Nie możesz utworzyć bezpośredniej wiadomości z więcej niż %{count} innym użytkownikiem." + few: "Nie możesz utworzyć bezpośredniej wiadomości z więcej niż %{count} innymi użytkownikami." + many: "Nie możesz utworzyć bezpośredniej wiadomości z więcej niż %{count} innymi użytkownikami." + other: "Nie możesz utworzyć bezpośredniej wiadomości z więcej niż %{count} innymi użytkownikami." + original_message_not_found: "Nie można znaleźć przodka wiadomości, na którą odpowiadasz, lub został on usunięty." + thread_invalid_for_channel: "Wątek nie jest częścią udostępnionego kanału." + thread_does_not_match_parent: "Wątek nie pasuje do wiadomości nadrzędnej." reviewables: message_already_handled: "Dziękujemy, ale sprawdziliśmy już tę wiadomość i ustaliliśmy, że nie trzeba jej ponownie oznaczać." actions: @@ -116,11 +134,6 @@ pl_PL: transcript_title: "Transkrypcja poprzednich wiadomości w %{channel_name}" transcript_body: "Aby dać Ci więcej kontekstu, zamieściliśmy transkrypcję poprzednich wiadomości w tej rozmowie (do dziesięciu):\n\n%{transcript}" channel: - statuses: - read_only: "Tylko do odczytu" - archived: "Zarchiwizowany" - closed: "Zamknięte" - open: "Otwórz" archive: first_post_raw: "Ten temat jest archiwum kanału czatu [%{channel_name}](%{channel_url})." messages_moved: @@ -129,9 +142,13 @@ pl_PL: many: "@%{acting_username} przeniósł %{count} wiadomości do kanału [%{channel_name}](%{first_moved_message_url})." other: "@%{acting_username} przeniósł %{count} wiadomości do kanału [%{channel_name}](%{first_moved_message_url})." dm_title: - single_user: "%{user}" - multi_user: "%{users}" - multi_user_truncated: "%{users} i %{leftover} innych" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} i %{count} inny" + few: "%{comma_separated_usernames} i %{count} inni" + many: "%{comma_separated_usernames} i %{count} innych" + other: "%{comma_separated_usernames} i %{count} innych" category_channel: errors: slug_contains_non_ascii_chars: "zawiera znaki inne niż ascii" diff --git a/plugins/chat/config/locales/server.pt.yml b/plugins/chat/config/locales/server.pt.yml index e5d909991cc..286291f3a22 100644 --- a/plugins/chat/config/locales/server.pt.yml +++ b/plugins/chat/config/locales/server.pt.yml @@ -62,9 +62,8 @@ pt: ignore: title: "Ignorar" channel: - statuses: - closed: "Fechado" - open: "Abrir" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.pt_BR.yml b/plugins/chat/config/locales/server.pt_BR.yml index 84800211492..ad65ee1774c 100644 --- a/plugins/chat/config/locales/server.pt_BR.yml +++ b/plugins/chat/config/locales/server.pt_BR.yml @@ -54,16 +54,23 @@ pt_BR: 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" + channel_new_message_disallowed: + archived: "O canal está arquivado, nenhuma mensagem nova pode ser enviada" + closed: "O canal está fechado, nenhuma nova mensagem pode ser enviada" + read_only: "O canal é somente para leitura, nenhuma nova mensagem pode ser enviada" + channel_modify_message_disallowed: + archived: "O canal está arquivado, nenhuma mensagem pode ser editada ou excluída" + closed: "O canal está fechado, nenhuma mensagem pode ser editada ou excluída" + read_only: "O canal é somente para leitura, 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(a) usuário(a)." 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." - message_too_long: "A mensagem é muito longa; as mensagens devem ter no máximo %{maximum} caracteres." + message_too_long: + one: "A mensagem é muito longa; as mensagens devem ter no máximo %{count} caracteres." + other: "A mensagem é muito longa; as mensagens devem ter no máximo %{count} caracteres." draft_too_long: "O rascunho é muito longo." 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." @@ -75,9 +82,11 @@ pt_BR: actor_disallowed_dms: "Você optou por impedir que os(as) usuários(as) 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} 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." + over_chat_max_direct_message_users_allow_self: "Você poderá criar uma mensagem direta apenas consigo mesmo(a)." over_chat_max_direct_message_users: - one: "Você poderá criar uma mensagem direta apenas consigo mesmo(a)." + one: "Você não poderá criar uma mensagem direta com mais de %{count} outro(a) usuário(a)." other: "Você não poderá criar uma mensagem direta com mais de %{count} outros(as) usuários(as)." + original_message_not_found: "A mensagem que você está respondendo não pode ser encontrada ou foi excluída." reviewables: message_already_handled: "Obrigado, mas já analisamos esta mensagem e determinamos que ela não precisa ser sinalizada novamente." actions: @@ -114,20 +123,17 @@ pt_BR: 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" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} e %{count} outro" + other: "%{comma_separated_usernames} e %{count} outros" category_channel: errors: slug_contains_non_ascii_chars: "contém caracteres não ascii" diff --git a/plugins/chat/config/locales/server.ro.yml b/plugins/chat/config/locales/server.ro.yml index f6d61b6885f..94657b439ed 100644 --- a/plugins/chat/config/locales/server.ro.yml +++ b/plugins/chat/config/locales/server.ro.yml @@ -20,9 +20,8 @@ ro: ignore: title: "Ignoră" channel: - statuses: - closed: "Închis" - open: "Deschide sondajul" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.ru.yml b/plugins/chat/config/locales/server.ru.yml index 9e7b1b7af22..f5bbbe2501d 100644 --- a/plugins/chat/config/locales/server.ru.yml +++ b/plugins/chat/config/locales/server.ru.yml @@ -44,16 +44,17 @@ ru: 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} символов." - message_too_long: "Сообщение слишком длинное, максимум символов — %{maximum}." + message_too_long: + one: "Сообщение слишком длинное, максимум символов — %{count}." + few: "Сообщение слишком длинное, максимум символов — %{count}." + many: "Сообщение слишком длинное, максимум символов — %{count}." + other: "Сообщение слишком длинное, максимум символов — %{count}." max_reactions_limit_reached: "Новые реакции на это сообщение запрещены." message_move_invalid_channel: "Исходный и целевой каналы должны быть общедоступными." message_move_no_messages_found: "Не найдено сообщений с указанными идентификаторами сообщений." @@ -64,11 +65,7 @@ ru: actor_disallowed_dms: "Вы решили запретить пользователям отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать новые прямые сообщения." actor_preventing_target_user_from_dm: "Вы решили запретить %{username} отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать для них новые прямые сообщения." user_cannot_send_direct_messages: "Вы не можете отправлять прямые сообщения." - over_chat_max_direct_message_users: - one: "Вы не можете создать прямое сообщение более чем с %{count} пользователем." - few: "Вы не можете создать прямое сообщение более чем с %{count} пользователями." - many: "Вы не можете создать прямое сообщение более чем с %{count} пользователями." - other: "Вы не можете создать прямое сообщение более чем с %{count} пользователя." + over_chat_max_direct_message_users_allow_self: "Вы не можете создать прямое сообщение более чем с %{count} пользователем." reviewables: message_already_handled: "Спасибо, но мы уже рассмотрели жалобу на это сообщение, поэтому жаловаться на него снова нет необходимости." actions: @@ -105,11 +102,6 @@ ru: 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: @@ -118,9 +110,12 @@ ru: 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}" + single_user: "%{username}" + multi_user_truncated: + one: "%{comma_separated_usernames} и ещё %{count}" + few: "%{comma_separated_usernames} и ещё %{count}" + many: "%{comma_separated_usernames} и ещё %{count}" + other: "%{comma_separated_usernames} и ещё %{count}" category_channel: errors: slug_contains_non_ascii_chars: "содержит символы не в ascii-кодировке" diff --git a/plugins/chat/config/locales/server.sk.yml b/plugins/chat/config/locales/server.sk.yml index dadd6d5049a..5b758198214 100644 --- a/plugins/chat/config/locales/server.sk.yml +++ b/plugins/chat/config/locales/server.sk.yml @@ -12,7 +12,19 @@ sk: chat: deleted_chat_username: vymazané errors: + channel_new_message_disallowed: + archived: "Kanál je archivovaný, nie je možné posielať nové správy" + closed: "Kanál je uzavretý, nie je možné posielať nové správy" + read_only: "Kanál je len na čítanie, nie je možné odosielať nové správy" + channel_modify_message_disallowed: + archived: "Kanál je archivovaný, správy nie je možné upravovať ani vymazávať" + closed: "Kanál je uzavretý, správy nie je možné upravovať ani odstraňovať" + read_only: "Kanál je len na čítanie, správy nie je možné upravovať ani vymazávať" draft_too_long: "Koncept je príliš dlhý." + over_chat_max_direct_message_users_allow_self: "Súkromnú správu môžete vytvoriť len sami so sebou." + original_message_not_found: "Tvorca správy, na ktorú odpovedáte, nemožno nájsť alebo bol vymazaný." + thread_invalid_for_channel: "Vlákno nie je súčasťou zvoleného kanála." + thread_does_not_match_parent: "Vlákno nezodpovedá nadradenej správe." reviewables: actions: agree_and_suspend: @@ -22,9 +34,11 @@ sk: disagree: title: "Nesúhlasiť" channel: - statuses: - closed: "Zatvorené" - open: "Zahájiť" + dm_title: + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + summaries: + no_targets: "Počas vybraného obdobia neboli zaznamenané žiadne správy." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.sl.yml b/plugins/chat/config/locales/server.sl.yml index 02225d59112..1e8b4840520 100644 --- a/plugins/chat/config/locales/server.sl.yml +++ b/plugins/chat/config/locales/server.sl.yml @@ -22,9 +22,8 @@ sl: ignore: title: "Prezri" channel: - statuses: - closed: "Zaprto" - open: "Odpri" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.sq.yml b/plugins/chat/config/locales/server.sq.yml index 3b2bd872fbb..0ec7a49b609 100644 --- a/plugins/chat/config/locales/server.sq.yml +++ b/plugins/chat/config/locales/server.sq.yml @@ -14,8 +14,8 @@ sq: disagree: title: "Jo dakord" channel: - statuses: - open: "Fillo" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.sr.yml b/plugins/chat/config/locales/server.sr.yml index 7da3cd0571c..b83810a0ffe 100644 --- a/plugins/chat/config/locales/server.sr.yml +++ b/plugins/chat/config/locales/server.sr.yml @@ -15,8 +15,8 @@ sr: disagree: title: "Odbaci" channel: - statuses: - open: "Otvori" + dm_title: + single_user: "%{username}" unsubscribe: chat_summary: never: Nikad diff --git a/plugins/chat/config/locales/server.sv.yml b/plugins/chat/config/locales/server.sv.yml index bd21cfa9d12..440327fa8b5 100644 --- a/plugins/chat/config/locales/server.sv.yml +++ b/plugins/chat/config/locales/server.sv.yml @@ -7,6 +7,7 @@ sv: site_settings: chat_enabled: "Aktivera chattillägget." + enable_public_channels: "Aktivera offentliga kanaler baserat på kategorier." 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." @@ -54,16 +55,26 @@ sv: deleted_chat_username: raderat 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" + channel_new_message_disallowed: + archived: "Kanalen är arkiverad, inga nya meddelanden kan skickas" + closed: "Kanalen är stängd, inga nya meddelanden kan skickas" + read_only: "Kanalen är lässkyddad, inga nya meddelanden kan skickas" + channel_modify_message_disallowed: + archived: "Kanalen är arkiverad, inga meddelanden kan redigeras eller raderas" + closed: "Kanalen är stängd, inga meddelanden kan redigeras eller raderas" + read_only: "Kanalen är lässkyddad, inga meddelanden kan redigeras eller raderas" 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." - message_too_long: "Meddelandet är för långt, måste ha högst %{maximum} tecken." + minimum_length_not_met: + one: "Meddelandet är för kort. Det måste innehålla minst %{count} tecken." + other: "Meddelandet är för kort. Det måste innehålla minst %{count} tecken." + message_too_long: + one: "Meddelandet är för långt, måste ha högst %{count} tecken." + other: "Meddelandet är för långt, måste ha högst %{count} tecken." draft_too_long: "Utkastet är för långt." 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." @@ -75,9 +86,13 @@ sv: 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." + over_chat_max_direct_message_users_allow_self: "Du kan bara skapa ett direktmeddelande med dig själv." over_chat_max_direct_message_users: - one: "Du kan bara skapa ett direktmeddelande med dig själv." + one: "Du kan inte skapa ett direktmeddelande med fler än %{count} annan användare." other: "Du kan inte skapa ett direktmeddelande med fler än %{count} andra användare." + original_message_not_found: "Det första meddelandet i meddelandekedjan som du svarar på kan inte hittas eller har tagits bort." + thread_invalid_for_channel: "Tråden är inte en del av angiven kanal." + thread_does_not_match_parent: "Tråden matchar inte överordnat meddelande." reviewables: message_already_handled: "Tack, men vi har redan granskat det här meddelandet och beslutat att det inte behöver flaggas igen." actions: @@ -114,20 +129,17 @@ sv: 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ängda" - 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" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} och %{count} andra" + other: "%{comma_separated_usernames} och %{count} andra" category_channel: errors: slug_contains_non_ascii_chars: "innehåller icke-ascii-tecken" @@ -145,6 +157,8 @@ sv: and_x_others: one: "och %{count} annan" other: "och %{count} andra" + summaries: + no_targets: "Det fanns inga meddelanden under den valda perioden." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.sw.yml b/plugins/chat/config/locales/server.sw.yml index 24aa9730477..a3c8d9a8422 100644 --- a/plugins/chat/config/locales/server.sw.yml +++ b/plugins/chat/config/locales/server.sw.yml @@ -22,9 +22,8 @@ sw: ignore: title: "Puuzia" channel: - statuses: - closed: "Imefungwa" - open: "Fungua" + dm_title: + single_user: "%{username}" reviewable_score_types: notify_user: chat_pm_body: "%{link}\n\n%{message}" diff --git a/plugins/chat/config/locales/server.te.yml b/plugins/chat/config/locales/server.te.yml index 7fd9140d31f..d456f19ceaa 100644 --- a/plugins/chat/config/locales/server.te.yml +++ b/plugins/chat/config/locales/server.te.yml @@ -13,6 +13,9 @@ te: title: "సభ్యుడిని సస్పెండు చేయి" disagree: title: "ఒప్పుకోకు" + channel: + dm_title: + single_user: "%{username}" reviewable_score_types: notify_user: 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 index 9fd7f0cdb1b..d475d7f2016 100644 --- a/plugins/chat/config/locales/server.th.yml +++ b/plugins/chat/config/locales/server.th.yml @@ -14,9 +14,8 @@ th: ignore: title: "ไม่สนใจ" channel: - statuses: - closed: "ปิด" - open: "เปิด" + dm_title: + single_user: "%{username}" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.tr_TR.yml b/plugins/chat/config/locales/server.tr_TR.yml index 249ad27d93e..263146ff2d7 100644 --- a/plugins/chat/config/locales/server.tr_TR.yml +++ b/plugins/chat/config/locales/server.tr_TR.yml @@ -7,6 +7,7 @@ tr_TR: site_settings: chat_enabled: "Sohbet eklentisini etkinleştirin." + enable_public_channels: "Kategorilere göre herkese açık kanalları etkinleştirin." chat_allowed_groups: "Bu gruplardaki kullanıcılar sohbet edebilir. Personelin sohbete her zaman erişebileceğini dikkate alın." chat_channel_retention_days: "Normal kanallardaki sohbet mesajları bu kadar gün boyunca saklanır. Mesajları sonsuza kadar saklamak için \"0\" olarak ayarlayın." chat_dm_retention_days: "Kişisel sohbet kanallarındaki sohbet mesajları bu kadar gün boyunca saklanır. Mesajları sonsuza kadar saklamak için \"0\" olarak ayarlayın." @@ -54,16 +55,26 @@ tr_TR: deleted_chat_username: silindi errors: channel_exists_for_category: "Bu kategori ve ada sahip bir kanal zaten var" - channel_new_message_disallowed: "Kanal %{status}, yeni mesaj gönderilemiyor" - channel_modify_message_disallowed: "Kanal %{status}, mesaj düzenlenemez veya silinemez" + channel_new_message_disallowed: + archived: "Kanal arşivlendi, yeni mesaj gönderilemez" + closed: "Kanal kapalı, yeni mesaj gönderilemiyor" + read_only: "Kanal salt okunur, yeni mesaj gönderilemiyor" + channel_modify_message_disallowed: + archived: "Kanal arşivlendi, hiçbir mesaj düzenlenemez veya silinemez" + closed: "Kanal kapalı, hiçbir mesaj düzenlenemez veya silinemez" + read_only: "Kanal salt okunur, hiçbir mesaj düzenlenemez veya silinemez" user_cannot_send_message: "Şu anda mesaj gönderemezsiniz." rate_limit_exceeded: "30 saniye içinde gönderilebilecek sohbet mesajı limiti aşıldı" auto_silence_from_flags: "Kullanıcıyı susturmaya yetecek kadar yüksek puanla bayrak eklenen sohbet mesajı." channel_cannot_be_archived: "Kanal şu anda arşivlenemez, arşivlemek için kapalı veya açık olmalı." duplicate_message: "Çok yakın zamanda aynı mesajı gönderdiniz." delete_channel_failed: "Kanal silinemedi, lütfen tekrar deneyin." - minimum_length_not_met: "Mesaj çok kısa, en az %{minimum} karakter içermeli." - message_too_long: "Mesaj çok uzun, mesajlar maksimum %{maximum} karakter olmalı." + minimum_length_not_met: + one: "Mesaj çok kısa, en az %{count} karakter içermeli." + other: "Mesaj çok kısa, en az %{count} karakter içermeli." + message_too_long: + one: "Mesaj çok uzun, mesajlar en çok %{count} karakter olmalı." + other: "Mesaj çok uzun, mesajlar en çok %{count} karakter olmalı." draft_too_long: "Taslak pek uzun." max_reactions_limit_reached: "Bu mesajda yeni tepkilere izin verilmiyor." message_move_invalid_channel: "Kaynak ve hedef kanal herkese açık kanallar olmalıdır." @@ -75,9 +86,13 @@ tr_TR: actor_disallowed_dms: "Kullanıcıların size özel ve doğrudan mesaj göndermesini engellemeyi seçtiniz, dolayısıyla yeni doğrudan mesaj oluşturamazsınız." actor_preventing_target_user_from_dm: "%{username} adlı kullanıcının size özel ve doğrudan mesaj göndermesini engellemeyi seçtiniz, dolayısıyla ona yeni doğrudan mesaj oluşturamazsınız." user_cannot_send_direct_messages: "Üzgünüz, doğrudan mesaj gönderemezsiniz." + over_chat_max_direct_message_users_allow_self: "Yalnızca kendinizle bir doğrudan mesaj oluşturabilirsiniz." over_chat_max_direct_message_users: - one: "Yalnızca kendinizle bir doğrudan mesaj oluşturabilirsiniz." + one: "%{count} üzeri başka kullanıcıyla doğrudan mesaj oluşturamazsınız." other: "%{count} üzeri başka kullanıcıyla doğrudan mesaj oluşturamazsınız." + original_message_not_found: "Yanıtladığınız iletinin atası bulunamıyor veya silinmiş." + thread_invalid_for_channel: "Konu, sağlanan kanalın bir parçası değildir." + thread_does_not_match_parent: "Konu ana mesajla eşleşmiyor." reviewables: message_already_handled: "Teşekkürler, ancak bu mesajı daha önce inceledik ve yeniden bayrak eklenmesine gerek olmadığına karar verdik." actions: @@ -114,20 +129,17 @@ tr_TR: transcript_title: "%{channel_name} adlı kanaldaki önceki mesajların dökümü" transcript_body: "Size daha fazla bağlam sağlamak için bu konuşmadaki önceki mesajların bir dökümünü ekledik (en fazla on):\n\n%{transcript}" channel: - statuses: - read_only: "Salt Okunur" - archived: "Arşivlendi" - closed: "Kapalı" - open: "Aç" archive: first_post_raw: "Bu konu, [%{channel_name}](%{channel_url}) sohbet kanalının bir arşividir." messages_moved: one: "@%{acting_username} bir mesajı [%{channel_name}](%{first_moved_message_url}) kanalına taşıdı." other: "@%{acting_username} , %{count} mesajı [%{channel_name}](%{first_moved_message_url}) kanalına taşıdı." dm_title: - single_user: "%{user}" - multi_user: "%{users}" - multi_user_truncated: "%{users} ve %{leftover} kişi daha" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} ve %{count} kişi daha" + other: "%{comma_separated_usernames} ve %{count} kişi daha" category_channel: errors: slug_contains_non_ascii_chars: "ascii olmayan karakterler içeriyor" @@ -145,6 +157,8 @@ tr_TR: and_x_others: one: "ve %{count} üye daha" other: "ve %{count} üye daha" + summaries: + no_targets: "Seçilen dönemde hiç mesaj yok." discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.uk.yml b/plugins/chat/config/locales/server.uk.yml index 57f751bc552..c31cdbeb151 100644 --- a/plugins/chat/config/locales/server.uk.yml +++ b/plugins/chat/config/locales/server.uk.yml @@ -54,10 +54,34 @@ uk: deleted_chat_username: видалено errors: channel_exists_for_category: "Для цієї категорії та назви вже існує канал" - channel_new_message_disallowed: "Канал %{status}, нові повідомлення не надсилаються" + channel_new_message_disallowed: + archived: "Канал заархівований, нові повідомлення не надсилаються" + closed: "Канал закрито, нові повідомлення не надсилаються" + read_only: "Канал тільки для читання, нові повідомлення не надсилаються" + channel_modify_message_disallowed: + archived: "Канал заархівовано, жодні повідомлення не можна редагувати чи видаляти" + closed: "Канал закритий, жодні повідомлення не можна редагувати чи видаляти" + read_only: "Канал доступний лише для читання, жодні повідомлення не можна редагувати чи видаляти" auto_silence_from_flags: "Повідомлення чату, позначене достатньо високим балом, щоб заблокувати користувача." + minimum_length_not_met: + one: "Повідомлення занадто коротке, повинно містити мінімум %{count} символ." + few: "Повідомлення занадто коротке, повинно містити мінімум %{count} символи." + many: "Повідомлення занадто коротке, повинно містити мінімум %{count} символів." + other: "Повідомлення занадто коротке, повинно містити мінімум %{count} символів." + message_too_long: + one: "Повідомлення занадто довге, повідомлення має бути не більше %{count} символ." + few: "Повідомлення занадто довге, повідомлення має бути не більше %{count} символи." + many: "Повідомлення занадто довге, повідомлення має бути не більше %{count} символів." + other: "Повідомлення занадто довге, повідомлення має бути не більше %{count} символів." draft_too_long: "Чернетка занадто довга." not_accepting_dms: "На жаль, %{username} в даний момент не приймає повідомлення." + over_chat_max_direct_message_users_allow_self: "Ви можете створити пряме повідомлення тільки з самим собою." + over_chat_max_direct_message_users: + one: "Ви не можете створити пряме повідомлення більше ніж з %{count} іншим користувачем." + few: "Ви не можете створити пряме повідомлення більше ніж з %{count} іншими користувачами." + many: "Ви не можете створити пряме повідомлення більше ніж з %{count} іншими користувачами." + other: "Ви не можете створити пряме повідомлення більше ніж з %{count} іншими користувачами." + original_message_not_found: "Попередника повідомлення, на яке ви відповідаєте, неможливо знайти або його було видалено." reviewables: actions: agree: @@ -86,15 +110,14 @@ uk: ignore: title: "Ігнорувати" channel: - statuses: - read_only: "Тільки для читання" - archived: "Архівовано" - closed: "Закриті" - open: "Відкрити" dm_title: - single_user: "%{user}" - multi_user: "%{users}" - multi_user_truncated: "%{users} і ще %{leftover}" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + one: "%{comma_separated_usernames} і ще %{count}" + few: "%{comma_separated_usernames} і ще %{count}" + many: "%{comma_separated_usernames} і ще %{count}" + other: "%{comma_separated_usernames} і ще %{count}" category_channel: errors: slug_contains_non_ascii_chars: "містить символи, які не є ASCII" diff --git a/plugins/chat/config/locales/server.ur.yml b/plugins/chat/config/locales/server.ur.yml index 38179a4083f..40fd1c32f20 100644 --- a/plugins/chat/config/locales/server.ur.yml +++ b/plugins/chat/config/locales/server.ur.yml @@ -24,9 +24,8 @@ ur: ignore: title: "نظر انداز کریں" channel: - statuses: - closed: "بند" - open: "کھولیں" + dm_title: + single_user: "%{username}" category_channel: errors: slug_contains_non_ascii_chars: "غیر ascii حروف پر مشتمل ہے" diff --git a/plugins/chat/config/locales/server.vi.yml b/plugins/chat/config/locales/server.vi.yml index c6a4d7c51ee..1e33286eccc 100644 --- a/plugins/chat/config/locales/server.vi.yml +++ b/plugins/chat/config/locales/server.vi.yml @@ -24,11 +24,10 @@ vi: ignore: title: "Bỏ qua" channel: - statuses: - closed: "Đã " - open: "Mở" dm_title: - multi_user_truncated: "%{users} và %{leftover} khác" + single_user: "%{username}" + multi_user_truncated: + other: "%{comma_separated_usernames} và %{count} khác" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.zh_CN.yml b/plugins/chat/config/locales/server.zh_CN.yml index ebf59dc21a9..939555d2fd6 100644 --- a/plugins/chat/config/locales/server.zh_CN.yml +++ b/plugins/chat/config/locales/server.zh_CN.yml @@ -7,6 +7,7 @@ zh_CN: site_settings: chat_enabled: "启用聊天插件。" + enable_public_channels: "根据类别启用公共频道。" chat_allowed_groups: "这些群组中的用户可以聊天。请注意,管理人员始终可以访问聊天。" chat_channel_retention_days: "常规频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" chat_dm_retention_days: "个人聊天频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" @@ -54,16 +55,24 @@ zh_CN: deleted_chat_username: 已删除 errors: channel_exists_for_category: "此类别和名称的频道已经存在" - channel_new_message_disallowed: "频道的状态为%{status},无法发送新消息" - channel_modify_message_disallowed: "频道的状态为%{status},无法编辑或删除消息" + channel_new_message_disallowed: + archived: "该频道已被存档,无法发送新消息" + closed: "该频道已关闭,无法发送新消息" + read_only: "该频道是只读的,不能发送新消息" + channel_modify_message_disallowed: + archived: "频道已关闭,无法编辑或删除任何消息" + closed: "频道已关闭,无法编辑或删除任何消息" + read_only: "该频道为只读频道,无法编辑或删除任何消息" 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} 个字符。" - message_too_long: "消息过长,最多只能包含 %{maximum} 个字符。" + minimum_length_not_met: + other: "消息太短,必须至少有 %{count} 个字符。" + message_too_long: + other: "消息过长,最多只能包含 %{count} 个字符。" draft_too_long: "草稿太长了。" max_reactions_limit_reached: "此消息不允许有新的回应。" message_move_invalid_channel: "源频道和目标频道必须是公共频道。" @@ -75,8 +84,12 @@ zh_CN: actor_disallowed_dms: "您已选择阻止用户向您发送私人和直接消息,因此您无法创建新的直接消息。" actor_preventing_target_user_from_dm: "您已选择阻止 %{username} 向您发送私人和直接消息,因此您无法创建给他们的新直接消息。" user_cannot_send_direct_messages: "抱歉,您无法发送直接消息。" + over_chat_max_direct_message_users_allow_self: "你只能创建一个与自己的直接信息。" over_chat_max_direct_message_users: other: "您无法创建与超过 %{count} 个其他用户的直接消息。" + original_message_not_found: "无法找到您要回复信息的内容,或已被删除。" + thread_invalid_for_channel: "该聊天串不是所提供频道的一部分。" + thread_does_not_match_parent: "聊天串与父消息不匹配。" reviewables: message_already_handled: "谢谢,但我们已经审核此消息,并确定它不需要被再次举报。" actions: @@ -113,19 +126,15 @@ zh_CN: 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} 人" + single_user: "%{username}" + multi_user: "%{comma_separated_usernames}" + multi_user_truncated: + other: "%{comma_separated_usernames} 和其他 %{count} 人" category_channel: errors: slug_contains_non_ascii_chars: "包含非 ASCII 字符" @@ -141,6 +150,8 @@ zh_CN: other: "%{count} 个成员" and_x_others: other: "和其他 %{count} 人" + summaries: + no_targets: "在所选时间段内没有消息。" discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/config/locales/server.zh_TW.yml b/plugins/chat/config/locales/server.zh_TW.yml index b134c030f24..f027146cdf1 100644 --- a/plugins/chat/config/locales/server.zh_TW.yml +++ b/plugins/chat/config/locales/server.zh_TW.yml @@ -24,11 +24,8 @@ zh_TW: ignore: title: "忽略" channel: - statuses: - read_only: "唯讀" - archived: "已封存" - closed: "不公開" - open: "開啟" + dm_title: + single_user: "%{username}" reviewable_score_types: notify_user: chat_pm_body: "%{link}\n\n%{message}" diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb new file mode 100644 index 00000000000..de4b47d489b --- /dev/null +++ b/plugins/chat/config/routes.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +Chat::Engine.routes.draw do + namespace :api, defaults: { format: :json } do + get "/chatables" => "chatables#index" + get "/channels" => "channels#index" + get "/channels/me" => "current_user_channels#index" + post "/channels" => "channels#create" + put "/channels/read/" => "reads#update_all" + put "/channels/:channel_id/read/:message_id" => "reads#update" + delete "/channels/:channel_id" => "channels#destroy" + put "/channels/:channel_id" => "channels#update" + get "/channels/:channel_id" => "channels#show" + put "/channels/:channel_id/status" => "channels_status#update" + get "/channels/:channel_id/messages" => "channel_messages#index" + post "/channels/:channel_id/messages/moves" => "channels_messages_moves#create" + post "/channels/:channel_id/archives" => "channels_archives#create" + get "/channels/:channel_id/memberships" => "channels_memberships#index" + delete "/channels/:channel_id/memberships/me" => "channels_current_user_membership#destroy" + post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create" + put "/channels/:channel_id/notifications-settings/me" => + "channels_current_user_notifications_settings#update" + + # Category chatables controller hints. 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 + + # Hints for JIT warnings. + get "/mentions/groups" => "hints#check_group_mentions", :format => :json + + get "/channels/:channel_id/threads" => "channel_threads#index" + put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update" + get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" + get "/channels/:channel_id/threads/:thread_id/messages" => "channel_thread_messages#index" + put "/channels/:channel_id/threads/:thread_id/read" => "thread_reads#update" + put "/channels/:channel_id/threads/:thread_id/notifications-settings/me" => + "channel_threads_current_user_notifications_settings#update" + + # TODO (martin) Remove this when we refactor the DM channel creation to happen + # via message creation in a different API controller. + post "/direct-message-channels" => "direct_messages#create" + + put "/channels/:channel_id/messages/:message_id/restore" => "channel_messages#restore" + delete "/channels/:channel_id/messages/:message_id" => "channel_messages#destroy" + + get "/channels/:channel_id/summarize" => "summaries#get_summary" + end + + namespace :admin, defaults: { format: :json, constraints: StaffConstraint.new } do + post "export/messages" => "export#export_messages" + end + + # direct_messages_controller routes + get "/direct_messages" => "direct_messages#index" + + # incoming_webhooks_controller routes + post "/hooks/:key" => "incoming_webhooks#create_message" + + # incoming_webhooks_controller routes + post "/hooks/:key/slack" => "incoming_webhooks#create_message_slack_compatible" + + # 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" + post "/enable" => "chat#enable_chat" + post "/disable" => "chat#disable_chat" + post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" + 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" + 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 "/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" + + base_c_route = "/c/:channel_title/:channel_id" + get base_c_route => "chat#respond", :as => "channel" + get "#{base_c_route}/:message_id" => "chat#respond" + + %w[info info/about info/members info/settings].each do |route| + get "#{base_c_route}/#{route}" => "chat#respond" + end + + # /channel -> /c redirects + get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") + + get "#{base_c_route}/t/:thread_id" => "chat#respond" + get "#{base_c_route}/t/:thread_id/:message_id" => "chat#respond" + + base_channel_route = "/channel/:channel_id/:channel_title" + redirect_base = "/chat/c/%{channel_title}/%{channel_id}" + + get base_channel_route, to: redirect(redirect_base) + + %w[info info/about info/members info/settings].each do |route| + get "#{base_channel_route}/#{route}", to: redirect("#{redirect_base}/#{route}") + end +end diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml index 015477dc038..e5958c82cde 100644 --- a/plugins/chat/config/settings.yml +++ b/plugins/chat/config/settings.yml @@ -2,6 +2,9 @@ chat: chat_enabled: default: true client: true + enable_public_channels: + default: true + client: true chat_allowed_groups: client: true type: group_list @@ -59,7 +62,7 @@ chat: chat_default_channel_id: default: "" client: true - validator: "ChatDefaultChannelValidator" + validator: "Chat::DefaultChannelValidator" chat_duplicate_message_sensitivity: type: float default: 0.5 @@ -85,7 +88,7 @@ chat: chat_allow_uploads: default: true client: true - validator: "ChatAllowUploadsValidator" + validator: "Chat::AllowUploadsValidator" max_chat_auto_joined_users: min: 0 default: 10000 @@ -97,7 +100,7 @@ chat: client: true allow_any: false refresh: true - validator: "DirectMessageEnabledGroupsValidator" + validator: "Chat::DirectMessageEnabledGroupsValidator" chat_message_flag_allowed_groups: default: "11" # @trust_level_1 type: group_list diff --git a/plugins/chat/db/fixtures/600_chat_channels.rb b/plugins/chat/db/fixtures/600_chat_channels.rb index 972398ba7f0..63b55a19cc3 100644 --- a/plugins/chat/db/fixtures/600_chat_channels.rb +++ b/plugins/chat/db/fixtures/600_chat_channels.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -ChatSeeder.new.execute if !Rails.env.test? +Chat::Seeder.new.execute if !Rails.env.test? diff --git a/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb b/plugins/chat/db/migrate/20220321235638_drop_chat_message_post_connections_table.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb rename to plugins/chat/db/migrate/20220321235638_drop_chat_message_post_connections_table.rb diff --git a/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb b/plugins/chat/db/migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb rename to plugins/chat/db/migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb diff --git a/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb b/plugins/chat/db/migrate/20220516142658_remove_email_statuses_table.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb rename to plugins/chat/db/migrate/20220516142658_remove_email_statuses_table.rb diff --git a/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb b/plugins/chat/db/migrate/20220518180642_remove_user_option_last_emailed_at.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb rename to plugins/chat/db/migrate/20220518180642_remove_user_option_last_emailed_at.rb diff --git a/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb b/plugins/chat/db/migrate/20220526135414_remove_corrupted_last_read_message_id.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb rename to plugins/chat/db/migrate/20220526135414_remove_corrupted_last_read_message_id.rb diff --git a/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb b/plugins/chat/db/migrate/20220531105951_drop_user_chat_channel_last_reads.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb rename to plugins/chat/db/migrate/20220531105951_drop_user_chat_channel_last_reads.rb diff --git a/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb b/plugins/chat/db/migrate/20220630074200_drop_chat_isolated_from_user_options.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb rename to plugins/chat/db/migrate/20220630074200_drop_chat_isolated_from_user_options.rb diff --git a/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb b/plugins/chat/db/migrate/20220701195731_convert_chatable_topics_to_categories.rb similarity index 100% rename from plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb rename to plugins/chat/db/migrate/20220701195731_convert_chatable_topics_to_categories.rb diff --git a/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb b/plugins/chat/db/migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb rename to plugins/chat/db/migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb diff --git a/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb b/plugins/chat/db/migrate/20221018091412_migrate_chat_channels.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb rename to plugins/chat/db/migrate/20221018091412_migrate_chat_channels.rb diff --git a/plugins/chat/db/post_migrate/20221027090832_migrate_dm_channels.rb b/plugins/chat/db/migrate/20221027090832_migrate_dm_channels.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221027090832_migrate_dm_channels.rb rename to plugins/chat/db/migrate/20221027090832_migrate_dm_channels.rb diff --git a/plugins/chat/db/post_migrate/20221104054957_backfill_channel_slugs.rb b/plugins/chat/db/migrate/20221104054957_backfill_channel_slugs.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221104054957_backfill_channel_slugs.rb rename to plugins/chat/db/migrate/20221104054957_backfill_channel_slugs.rb diff --git a/plugins/chat/db/post_migrate/20221117052348_truncate_chat_messages_over_max_length.rb b/plugins/chat/db/migrate/20221117052348_truncate_chat_messages_over_max_length.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221117052348_truncate_chat_messages_over_max_length.rb rename to plugins/chat/db/migrate/20221117052348_truncate_chat_messages_over_max_length.rb diff --git a/plugins/chat/db/post_migrate/20221117142910_delete_orphaned_channels.rb b/plugins/chat/db/migrate/20221117142910_delete_orphaned_channels.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221117142910_delete_orphaned_channels.rb rename to plugins/chat/db/migrate/20221117142910_delete_orphaned_channels.rb diff --git a/plugins/chat/db/post_migrate/20221201032830_drop_tmp_chat_slug_tables.rb b/plugins/chat/db/migrate/20221201032830_drop_tmp_chat_slug_tables.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221201032830_drop_tmp_chat_slug_tables.rb rename to plugins/chat/db/migrate/20221201032830_drop_tmp_chat_slug_tables.rb diff --git a/plugins/chat/db/post_migrate/20221202043755_update_chat_channel_message_counts.rb b/plugins/chat/db/migrate/20221202043755_update_chat_channel_message_counts.rb similarity index 100% rename from plugins/chat/db/post_migrate/20221202043755_update_chat_channel_message_counts.rb rename to plugins/chat/db/migrate/20221202043755_update_chat_channel_message_counts.rb diff --git a/plugins/chat/db/migrate/20230123020036_move_chat_uploads_to_upload_references.rb b/plugins/chat/db/migrate/20230123020036_move_chat_uploads_to_upload_references.rb new file mode 100644 index 00000000000..22dafbcff20 --- /dev/null +++ b/plugins/chat/db/migrate/20230123020036_move_chat_uploads_to_upload_references.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MoveChatUploadsToUploadReferences < ActiveRecord::Migration[7.0] + def up + execute <<~SQL + INSERT INTO upload_references(upload_id, target_type, target_id, created_at, updated_at) + SELECT chat_uploads.upload_id, 'ChatMessage', chat_uploads.chat_message_id, chat_uploads.created_at, chat_uploads.updated_at + FROM chat_uploads + INNER JOIN uploads ON uploads.id = chat_uploads.upload_id + ON CONFLICT DO NOTHING + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20230130053144_add_threading_enabled_to_chat_channels.rb b/plugins/chat/db/migrate/20230130053144_add_threading_enabled_to_chat_channels.rb new file mode 100644 index 00000000000..830259644be --- /dev/null +++ b/plugins/chat/db/migrate/20230130053144_add_threading_enabled_to_chat_channels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddThreadingEnabledToChatChannels < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :threading_enabled, :boolean, default: false, null: false + end +end diff --git a/plugins/chat/db/migrate/20230201012734_create_chat_threading_models.rb b/plugins/chat/db/migrate/20230201012734_create_chat_threading_models.rb new file mode 100644 index 00000000000..a18ad69e92a --- /dev/null +++ b/plugins/chat/db/migrate/20230201012734_create_chat_threading_models.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateChatThreadingModels < ActiveRecord::Migration[7.0] + def change + create_table :chat_threads do |t| + t.bigint :channel_id, null: false + t.bigint :original_message_id, null: false + t.bigint :original_message_user_id, null: false + t.integer :status, null: false, default: 0 + t.string :title, null: true + + t.timestamps + end + + add_index :chat_threads, :channel_id + add_index :chat_threads, :original_message_id + add_index :chat_threads, :original_message_user_id + add_index :chat_threads, :status + add_index :chat_threads, %i[channel_id status] + + add_column :chat_messages, :thread_id, :bigint, null: true + add_index :chat_messages, :thread_id + end +end diff --git a/plugins/chat/db/migrate/20230228062442_add_chat_header_indicator_preference.rb b/plugins/chat/db/migrate/20230228062442_add_chat_header_indicator_preference.rb new file mode 100644 index 00000000000..e6b5d7c38cb --- /dev/null +++ b/plugins/chat/db/migrate/20230228062442_add_chat_header_indicator_preference.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddChatHeaderIndicatorPreference < ActiveRecord::Migration[7.0] + def change + add_column :user_options, :chat_header_indicator_preference, :integer, default: 0, null: false + end +end diff --git a/plugins/chat/db/migrate/20230411012630_add_thread_not_deleted_index_chat_messages.rb b/plugins/chat/db/migrate/20230411012630_add_thread_not_deleted_index_chat_messages.rb new file mode 100644 index 00000000000..3596ea46a09 --- /dev/null +++ b/plugins/chat/db/migrate/20230411012630_add_thread_not_deleted_index_chat_messages.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddThreadNotDeletedIndexChatMessages < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + execute <<~SQL + DROP INDEX IF EXISTS idx_chat_messages_by_thread_id_not_deleted + SQL + + execute <<~SQL + CREATE INDEX CONCURRENTLY IF NOT EXISTS + idx_chat_messages_by_thread_id_not_deleted + ON chat_messages (thread_id) + WHERE deleted_at IS NULL + SQL + end + + def down + execute <<~SQL + DROP INDEX IF EXISTS idx_chat_messages_by_thread_id_not_deleted + SQL + end +end diff --git a/plugins/chat/db/migrate/20230411023246_add_chat_message_replies_count_to_chat_threads.rb b/plugins/chat/db/migrate/20230411023246_add_chat_message_replies_count_to_chat_threads.rb new file mode 100644 index 00000000000..6c2702d2876 --- /dev/null +++ b/plugins/chat/db/migrate/20230411023246_add_chat_message_replies_count_to_chat_threads.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddChatMessageRepliesCountToChatThreads < ActiveRecord::Migration[7.0] + def change + add_column :chat_threads, :replies_count, :integer, null: false, default: 0 + add_index :chat_threads, :replies_count + end +end diff --git a/plugins/chat/db/migrate/20230510142249_add_user_chat_thread_memberships.rb b/plugins/chat/db/migrate/20230510142249_add_user_chat_thread_memberships.rb new file mode 100644 index 00000000000..bb8e88ce48d --- /dev/null +++ b/plugins/chat/db/migrate/20230510142249_add_user_chat_thread_memberships.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddUserChatThreadMemberships < ActiveRecord::Migration[7.0] + def change + create_table :user_chat_thread_memberships do |t| + t.bigint :user_id, null: false + t.bigint :thread_id, null: false + t.bigint :last_read_message_id + t.integer :notification_level, default: 2, null: false # default to tracking + t.timestamps + end + + add_index :user_chat_thread_memberships, + %i[user_id thread_id], + unique: true, + name: "user_chat_thread_unique_memberships" + end +end diff --git a/plugins/chat/db/migrate/20230607091233_backfill_thread_memberships.rb b/plugins/chat/db/migrate/20230607091233_backfill_thread_memberships.rb new file mode 100644 index 00000000000..8dfedf140ab --- /dev/null +++ b/plugins/chat/db/migrate/20230607091233_backfill_thread_memberships.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class BackfillThreadMemberships < ActiveRecord::Migration[7.0] + def up + thread_tracking_notification_level = 2 + + sql = <<~SQL + INSERT INTO user_chat_thread_memberships( + user_id, + thread_id, + notification_level, + last_read_message_id, + created_at, + updated_at + ) + SELECT + thread_participant_stats.user_id, + thread_participant_stats.thread_id, + #{thread_tracking_notification_level}, + ( + SELECT id FROM chat_messages + WHERE thread_id = thread_participant_stats.thread_id + AND deleted_at IS NULL + ORDER BY created_at DESC, id DESC + LIMIT 1 + ), + NOW(), + NOW() + FROM ( + SELECT chat_messages.thread_id, chat_messages.user_id + FROM chat_messages + INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id + WHERE chat_messages.thread_id IS NOT NULL + GROUP BY chat_messages.thread_id, chat_messages.user_id + ORDER BY chat_messages.thread_id ASC, chat_messages.user_id ASC + ) AS thread_participant_stats + INNER JOIN users ON users.id = thread_participant_stats.user_id + LEFT JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = thread_participant_stats.thread_id + AND user_chat_thread_memberships.user_id = thread_participant_stats.user_id + WHERE user_chat_thread_memberships IS NULL + ORDER BY user_chat_thread_memberships.thread_id ASC + ON CONFLICT DO NOTHING; + SQL + + execute(sql) + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20230627044755_add_last_viewed_at_to_user_chat_channel_memberships.rb b/plugins/chat/db/migrate/20230627044755_add_last_viewed_at_to_user_chat_channel_memberships.rb new file mode 100644 index 00000000000..b930d356ae1 --- /dev/null +++ b/plugins/chat/db/migrate/20230627044755_add_last_viewed_at_to_user_chat_channel_memberships.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddLastViewedAtToUserChatChannelMemberships < ActiveRecord::Migration[7.0] + def change + add_column :user_chat_channel_memberships, + :last_viewed_at, + :datetime, + null: false, + default: -> { "CURRENT_TIMESTAMP" } + end +end diff --git a/plugins/chat/db/migrate/20230707025733_add_last_message_id_to_channel_and_thread.rb b/plugins/chat/db/migrate/20230707025733_add_last_message_id_to_channel_and_thread.rb new file mode 100644 index 00000000000..b8befcef009 --- /dev/null +++ b/plugins/chat/db/migrate/20230707025733_add_last_message_id_to_channel_and_thread.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddLastMessageIdToChannelAndThread < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :last_message_id, :bigint, null: true + add_column :chat_threads, :last_message_id, :bigint, null: true + + add_index :chat_channels, :last_message_id + add_index :chat_threads, :last_message_id + end +end diff --git a/plugins/chat/db/migrate/20230707082645_backfill_chat_channel_and_thread_last_message_ids.rb b/plugins/chat/db/migrate/20230707082645_backfill_chat_channel_and_thread_last_message_ids.rb new file mode 100644 index 00000000000..cba288c9b33 --- /dev/null +++ b/plugins/chat/db/migrate/20230707082645_backfill_chat_channel_and_thread_last_message_ids.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class BackfillChatChannelAndThreadLastMessageIds < ActiveRecord::Migration[7.0] + def up + execute <<-SQL + UPDATE chat_channels + SET last_message_id = ( + SELECT cm.id + FROM chat_messages cm + LEFT JOIN chat_threads ON chat_threads.original_message_id = cm.id + WHERE cm.chat_channel_id = chat_channels.id + AND cm.deleted_at IS NULL + AND (cm.thread_id IS NULL OR chat_threads.id IS NOT NULL) + ORDER BY cm.created_at DESC, cm.id DESC + LIMIT 1 + ); + SQL + + execute <<-SQL + UPDATE chat_threads + SET last_message_id = ( + SELECT cm.id + FROM chat_messages cm + WHERE cm.thread_id = chat_threads.id + AND cm.deleted_at IS NULL + ORDER BY cm.created_at DESC, cm.id DESC + LIMIT 1 + ); + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20230721025249_remove_experimental_site_setting_for_threads.rb b/plugins/chat/db/migrate/20230721025249_remove_experimental_site_setting_for_threads.rb new file mode 100644 index 00000000000..795b5bfb081 --- /dev/null +++ b/plugins/chat/db/migrate/20230721025249_remove_experimental_site_setting_for_threads.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveExperimentalSiteSettingForThreads < ActiveRecord::Migration[7.0] + def up + execute "DELETE FROM site_settings WHERE name='enable_experimental_chat_threaded_discussions'" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20230123025112_move_chat_uploads_to_upload_references_post.rb b/plugins/chat/db/post_migrate/20230123025112_move_chat_uploads_to_upload_references_post.rb new file mode 100644 index 00000000000..02ec2dfbffe --- /dev/null +++ b/plugins/chat/db/post_migrate/20230123025112_move_chat_uploads_to_upload_references_post.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MoveChatUploadsToUploadReferencesPost < ActiveRecord::Migration[7.0] + def up + execute <<~SQL + INSERT INTO upload_references(upload_id, target_type, target_id, created_at, updated_at) + SELECT chat_uploads.upload_id, 'ChatMessage', chat_uploads.chat_message_id, chat_uploads.created_at, chat_uploads.updated_at + FROM chat_uploads + INNER JOIN uploads ON uploads.id = chat_uploads.upload_id + ON CONFLICT DO NOTHING + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20230227172543_make_chat_mention_notification_id_nullable.rb b/plugins/chat/db/post_migrate/20230227172543_make_chat_mention_notification_id_nullable.rb new file mode 100644 index 00000000000..b3bbcef23be --- /dev/null +++ b/plugins/chat/db/post_migrate/20230227172543_make_chat_mention_notification_id_nullable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MakeChatMentionNotificationIdNullable < ActiveRecord::Migration[7.0] + def up + change_column_null :chat_mentions, :notification_id, true + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20230403012844_drop_chat_uploads.rb b/plugins/chat/db/post_migrate/20230403012844_drop_chat_uploads.rb new file mode 100644 index 00000000000..d36f031e425 --- /dev/null +++ b/plugins/chat/db/post_migrate/20230403012844_drop_chat_uploads.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DropChatUploads < ActiveRecord::Migration[7.0] + DROPPED_TABLES ||= %i[chat_uploads] + + 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/20230411023340_update_thread_reply_counts.rb b/plugins/chat/db/post_migrate/20230411023340_update_thread_reply_counts.rb new file mode 100644 index 00000000000..b3dc2998e8c --- /dev/null +++ b/plugins/chat/db/post_migrate/20230411023340_update_thread_reply_counts.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UpdateThreadReplyCounts < ActiveRecord::Migration[7.0] + def up + DB.exec <<~SQL + UPDATE chat_threads threads + SET replies_count = subquery.replies_count + FROM ( + SELECT COUNT(*) - 1 AS replies_count, thread_id + FROM chat_messages + WHERE chat_messages.deleted_at IS NULL AND thread_id IS NOT NULL + GROUP BY thread_id + ) subquery + WHERE threads.id = subquery.thread_id + AND subquery.replies_count != threads.replies_count + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20230710040640_backfill_chat_channel_and_thread_last_message_ids_post_migrate.rb b/plugins/chat/db/post_migrate/20230710040640_backfill_chat_channel_and_thread_last_message_ids_post_migrate.rb new file mode 100644 index 00000000000..08384e31191 --- /dev/null +++ b/plugins/chat/db/post_migrate/20230710040640_backfill_chat_channel_and_thread_last_message_ids_post_migrate.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class BackfillChatChannelAndThreadLastMessageIdsPostMigrate < ActiveRecord::Migration[7.0] + execute <<-SQL + UPDATE chat_channels + SET last_message_id = ( + SELECT cm.id + FROM chat_messages cm + LEFT JOIN chat_threads ON chat_threads.original_message_id = cm.id + WHERE cm.chat_channel_id = chat_channels.id + AND cm.deleted_at IS NULL + AND (cm.thread_id IS NULL OR chat_threads.id IS NOT NULL) + ORDER BY cm.created_at DESC, cm.id DESC + LIMIT 1 + ); + SQL + + execute <<-SQL + UPDATE chat_threads + SET last_message_id = ( + SELECT cm.id + FROM chat_messages cm + WHERE cm.thread_id = chat_threads.id + AND cm.deleted_at IS NULL + ORDER BY cm.created_at DESC, cm.id DESC + LIMIT 1 + ); + SQL +end diff --git a/plugins/chat/lib/chat/bookmark_extension.rb b/plugins/chat/lib/chat/bookmark_extension.rb new file mode 100644 index 00000000000..a4385da1b82 --- /dev/null +++ b/plugins/chat/lib/chat/bookmark_extension.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + module BookmarkExtension + extend ActiveSupport::Concern + + prepended { include TypeMappable } + + class_methods { def polymorphic_class_mapping = { "ChatMessage" => Chat::Message } } + end +end diff --git a/plugins/chat/lib/chat/category_extension.rb b/plugins/chat/lib/chat/category_extension.rb new file mode 100644 index 00000000000..185cec5e83b --- /dev/null +++ b/plugins/chat/lib/chat/category_extension.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Chat + module CategoryExtension + extend ActiveSupport::Concern + + include Chat::Chatable + + prepended do + has_one :category_channel, + as: :chatable, + class_name: "Chat::CategoryChannel", + dependent: :destroy + end + + 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 +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 00000000000..d3d7a349588 --- /dev/null +++ b/plugins/chat/lib/chat/channel_archive_service.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +## +# From time to time, site admins may choose to sunset a chat channel and archive +# the messages within. 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. +module Chat + class ChannelArchiveService + ARCHIVED_MESSAGES_PER_POST = 100 + + class ArchiveValidationError < StandardError + attr_reader :errors + + def initialize(errors: []) + super + @errors = errors + end + end + + def self.create_archive_process(chat_channel:, acting_user:, topic_params:) + return if Chat::ChannelArchive.exists?(chat_channel: chat_channel) + + # Only need to validate topic params for a new topic, not an existing one. + if topic_params[:topic_id].blank? + valid, errors = + Chat::ChannelArchiveService.validate_topic_params(Guardian.new(acting_user), topic_params) + + raise ArchiveValidationError.new(errors: errors) if !valid + end + + Chat::ChannelArchive.transaction do + chat_channel.read_only!(acting_user) + + archive = + Chat::ChannelArchive.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(Jobs::Chat::ChannelArchive, 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( + Jobs::Chat::ChannelArchive, + chat_channel_archive_id: chat_channel.chat_channel_archive.id, + ) + chat_channel.chat_channel_archive + end + + def self.validate_topic_params(guardian, topic_params) + topic_creator = + TopicCreator.new( + Discourse.system_user, + guardian, + { + title: topic_params[:topic_title], + category: topic_params[:category_id], + tags: topic_params[:tags], + import_mode: true, + }, + ) + [topic_creator.valid?, topic_creator.errors.full_messages] + 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 + return if !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( + Chat::TranscriptService.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_message: err.message) + 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, + }, + ) + + if topic_creator.valid? + chat_channel_archive.update!(destination_topic: topic_creator.create) + else + Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") + notify_archiver( + :failed_no_topic, + error_message: topic_creator.errors.full_messages.join("\n"), + ) + end + end + + if chat_channel_archive.destination_topic.present? + 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, + ), + ) + end + else + Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") + end + + if chat_channel_archive.destination_topic.present? + update_destination_topic_status + return true + end + + false + 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.new_topic? + 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) + Chat::Message.transaction do + Chat::Message.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_message: nil) + base_translation_params = { + channel_hashtag_or_name: channel_hashtag_or_name, + topic_title: chat_channel_archive.destination_topic&.title, + topic_url: chat_channel_archive.destination_topic&.url, + topic_validation_errors: result == :failed_no_topic ? error_message : nil, + } + + if result == :failed || result == :failed_no_topic + Discourse.warn_exception( + error_message, + 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) + message_translation_key = + case result + when :failed + :chat_channel_archive_failed + when :failed_no_topic + :chat_channel_archive_failed_no_topic + end + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + message_translation_key, + error_translation_params, + ) + else + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_complete, + base_translation_params, + ) + end + + Chat::Publisher.publish_archive_status( + chat_channel, + archive_status: result != :success ? :failed : :success, + 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::ChannelMembershipManager.new(chat_channel).unfollow_all_users + end + + def channel_hashtag_or_name + if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete + return "##{chat_channel.slug}::channel" + end + chat_channel_title + end + 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 00000000000..61112b5e3e3 --- /dev/null +++ b/plugins/chat/lib/chat/channel_fetcher.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +module Chat + class ChannelFetcher + MAX_PUBLIC_CHANNEL_RESULTS = 50 + + def self.structured(guardian, include_threads: false) + memberships = Chat::ChannelMembershipManager.all_for_user(guardian.user) + public_channels = secured_public_channels(guardian, status: :open, following: true) + direct_message_channels = secured_direct_message_channels(guardian.user.id, guardian) + { + public_channels: public_channels, + direct_message_channels: direct_message_channels, + memberships: memberships, + tracking: + tracking_state( + public_channels.map(&:id) + direct_message_channels.map(&:id), + guardian, + include_threads: include_threads, + ), + } + 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, exclude_dm_channels: false) + category_channel_sql = + Category + .post_create_allowed(guardian) + .joins( + "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", + ) + .select("chat_channels.id") + .to_sql + dm_channel_sql = "" + if !exclude_dm_channels + dm_channel_sql = <<~SQL + UNION + + -- secured direct message chat channels + #{ + Chat::Channel + .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 + + <<~SQL + -- secured category chat channels + #{category_channel_sql} + #{dm_channel_sql} + SQL + end + + def self.secured_public_channel_slug_lookup(guardian, slugs) + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + Chat::Channel + .with_categories + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + .where("chat_channels.slug IN (:slugs)", slugs: slugs) + .limit(1) + end + + def self.secured_public_channel_search(guardian, options = {}) + return ::Chat::Channel.none if !SiteSetting.enable_public_channels + + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + channels = Chat::Channel.includes(:last_message, chatable: [:topic_only_relative_url]) + channels = channels.includes(:chat_channel_archive) if options[:include_archives] + + channels = + channels + .with_categories + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + + channels = channels.where(status: options[:status]) if options[:status].present? + + if options[:filter].present? + category_filter = + (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") + + sql = + "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" + if options[:match_filter_on_starts_with] + filter_sql = "#{options[:filter].downcase}%" + else + filter_sql = "%#{options[:filter].downcase}%" + end + + channels = + channels.where(sql, filter: filter_sql).order( + "chat_channels.name ASC, categories.name ASC", + ) + end + + if options.key?(:slugs) + channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) + 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, options = { following: true }) + channels = + secured_public_channel_search( + guardian, + options.merge(include_archives: true, filter_on_category_name: true), + ) + + 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" || c.chatable_type == "category" } + .map(&:chatable), + preload_fields, + ) + end + + def self.secured_direct_message_channels(user_id, guardian) + secured_direct_message_channels_search(user_id, guardian, following: true) + end + + def self.secured_direct_message_channels_search(user_id, guardian, options = {}) + query = + Chat::Channel.strict_loading.includes( + last_message: [:uploads], + chatable: [{ direct_message_users: [user: :user_option] }, :users], + ) + query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status + query = query.joins(:user_chat_channel_memberships) + query = + query.joins( + "LEFT JOIN chat_messages last_message ON last_message.id = chat_channels.last_message_id", + ) + + scoped_channels = + Chat::Channel + .joins( + "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'DirectMessage'", + ) + .joins( + "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: user_id) + + if options[:user_ids] + scoped_channels = + scoped_channels.where( + "EXISTS ( + SELECT 1 + FROM direct_message_channels AS dmc + INNER JOIN direct_message_users AS dmu ON dmu.direct_message_channel_id = dmc.id + WHERE dmc.id = chat_channels.chatable_id AND dmu.user_id IN (:user_ids) + )", + user_ids: options[:user_ids], + ) + end + + if options.key?(:following) + query = + query.where( + user_chat_channel_memberships: { + user_id: user_id, + following: options[:following], + }, + ) + else + query = query.where(user_chat_channel_memberships: { user_id: user_id }) + end + + query = + query + .where(chatable_type: Chat::Channel.direct_channel_chatable_types) + .where(chat_channels: { id: scoped_channels }) + .order("last_message.created_at DESC NULLS LAST") + + channels = query.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) + channels + end + + def self.tracking_state(channel_ids, guardian, include_threads: false) + Chat::TrackingState.call( + channel_ids: channel_ids, + guardian: guardian, + include_missing_memberships: true, + include_threads: include_threads, + ).report + end + + def self.find_with_access_check(channel_id_or_slug, guardian) + base_channel_relation = Chat::Channel.includes(:chatable) + + if guardian.user.staff? + base_channel_relation = base_channel_relation.includes(:chat_channel_archive) + end + + chat_channel = base_channel_relation.find_by_id_or_slug(channel_id_or_slug) + + raise Discourse::NotFound if chat_channel.blank? + raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(chat_channel) + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/channel_hashtag_data_source.rb b/plugins/chat/lib/chat/channel_hashtag_data_source.rb new file mode 100644 index 00000000000..c5ccf8eff49 --- /dev/null +++ b/plugins/chat/lib/chat/channel_hashtag_data_source.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Chat + class ChannelHashtagDataSource + def self.enabled? + SiteSetting.enable_experimental_hashtag_autocomplete && SiteSetting.enable_public_channels + end + + def self.icon + "comment" + end + + def self.type + "channel" + end + + def self.channel_to_hashtag_item(guardian, channel) + HashtagAutocompleteService::HashtagItem.new.tap do |item| + item.text = channel.title + item.description = channel.description + item.slug = channel.slug + item.icon = icon + item.relative_url = channel.relative_url + item.type = "channel" + item.id = channel.id + end + end + + def self.lookup(guardian, slugs) + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_slug_lookup(guardian, slugs) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + end + + def self.search( + guardian, + term, + limit, + condition = HashtagAutocompleteService.search_conditions[:contains] + ) + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_search( + guardian, + filter: term, + limit: limit, + exclude_dm_channels: true, + match_filter_on_starts_with: + condition == HashtagAutocompleteService.search_conditions[:starts_with], + ) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + end + + def self.search_sort(search_results, _) + search_results.sort_by { |result| result.text.downcase } + end + + def self.search_without_term(guardian, limit) + return [] if !guardian.can_chat? + allowed_channel_ids_sql = + Chat::ChannelFetcher.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + Chat::Channel + .joins( + "INNER JOIN user_chat_channel_memberships + ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + AND user_chat_channel_memberships.user_id = #{guardian.user.id} + AND user_chat_channel_memberships.following = true", + ) + .where("chat_channels.id IN (#{allowed_channel_ids_sql})") + .order(messages_count: :desc) + .limit(limit) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + end + 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 00000000000..838456c1018 --- /dev/null +++ b/plugins/chat/lib/chat/channel_membership_manager.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipManager + def self.all_for_user(user) + Chat::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? + + Chat::UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) + end + + def follow(user) + membership = + find_for_user(user) || + Chat::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 Chat::Channel.exists?(id: channel.id, user_count_stale: true) + channel.update!(user_count_stale: true) + Jobs.enqueue_in(3.seconds, Jobs::Chat::UpdateChannelUserCount, chat_channel_id: channel.id) + end + + def unfollow_all_users + Chat::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(Jobs::Chat::AutoJoinChannelMemberships, chat_channel_id: channel.id) + end + + def enforce_automatic_user_membership(user) + Jobs.enqueue( + Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: user.id, + ends_at: user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/duplicate_message_validator.rb b/plugins/chat/lib/chat/duplicate_message_validator.rb new file mode 100644 index 00000000000..fa9175b8ed7 --- /dev/null +++ b/plugins/chat/lib/chat/duplicate_message_validator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Chat + class 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 = + 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 +end diff --git a/plugins/chat/lib/chat/engine.rb b/plugins/chat/lib/chat/engine.rb new file mode 100644 index 00000000000..38cca8ab7ba --- /dev/null +++ b/plugins/chat/lib/chat/engine.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ::Chat + HAS_CHAT_ENABLED = "has_chat_enabled" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Chat + config.autoload_paths << File.join(config.root, "lib") + 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 diff --git a/plugins/chat/lib/chat/guardian_extensions.rb b/plugins/chat/lib/chat/guardian_extensions.rb new file mode 100644 index 00000000000..c26506ebf1e --- /dev/null +++ b/plugins/chat/lib/chat/guardian_extensions.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module Chat + module 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? + return false if anonymous? + @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 + + # The only part of the thread that can be changed is the title + # so this isn't too dangerous, if we end up wanting to change + # more things in future we may want to re-evaluate to be staff-only here. + def can_edit_thread?(thread) + is_staff? || thread.original_message_user_id == @user.id + 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? + + # FIXME: This logic shouldn't be handled in guardian + 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_preview_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_join_chat_channel?(chat_channel, post_allowed_category_ids: nil) + return false if anonymous? + return false unless can_chat? + can_preview_chat_channel?(chat_channel) && + can_post_in_chatable?( + chat_channel.chatable, + post_allowed_category_ids: post_allowed_category_ids, + ) + end + + def can_post_in_chatable?(chatable, post_allowed_category_ids: nil) + case chatable + when Category + # technically when fetching channels in channel_fetcher we alread scope it to + # categories with post_create_allowed(guardian) so this is redundant but still + # valuable to have here when we're not fetching channels through channel_fetcher + if post_allowed_category_ids + return false unless chatable + return false if is_anonymous? + return true if is_admin? + post_allowed_category_ids.include?(chatable.id) + else + can_post_in_category?(chatable) + end + when Chat::DirectMessage + true + end + end + + def can_flag_chat_messages? + return false if @user.silenced? + return true if @user.staff? + + @user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map) + end + + def can_flag_in_chat_channel?(chat_channel, post_allowed_category_ids: nil) + return false if !can_modify_channel_message?(chat_channel) + + can_join_chat_channel?(chat_channel, post_allowed_category_ids: post_allowed_category_ids) + end + + def can_flag_chat_message?(chat_message) + if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user + return false + end + 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 message.deleted_by_id == current_user.id || can_see_category?(chatable) + when Chat::DirectMessage + return message.deleted_by_id == current_user.id || is_staff? + 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 +end diff --git a/plugins/chat/lib/chat/mailer.rb b/plugins/chat/lib/chat/mailer.rb new file mode 100644 index 00000000000..f6a2579af1e --- /dev/null +++ b/plugins/chat/lib/chat/mailer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Chat + class Mailer + 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 +end diff --git a/plugins/chat/lib/chat/message_bookmarkable.rb b/plugins/chat/lib/chat/message_bookmarkable.rb new file mode 100644 index 00000000000..a759aef1209 --- /dev/null +++ b/plugins/chat/lib/chat/message_bookmarkable.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Chat + class MessageBookmarkable < BaseBookmarkable + def self.model + Chat::Message + end + + def self.serializer + Chat::UserMessageBookmarkSerializer + end + + def self.preload_associations + [:chat_channel] + end + + def self.list_query(user, guardian) + accessible_channel_ids = Chat::ChannelFetcher.all_secured_channel_ids(guardian) + return if accessible_channel_ids.empty? + + joins = + ActiveRecord::Base.public_send( + :sanitize_sql_array, + [ + "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id AND chat_messages.deleted_at IS NULL AND bookmarks.bookmarkable_type = ?", + Chat::Message.polymorphic_name, + ], + ) + + user + .bookmarks_of_type(Chat::Message.polymorphic_name) + .joins(joins) + .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_join_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_join_chat_channel?(bookmark.bookmarkable.chat_channel) + end + + def self.cleanup_deleted + DB.query(<<~SQL, grace_time: 3.days.ago, bookmarkable_type: Chat::Message.polymorphic_name) + DELETE FROM bookmarks b + USING chat_messages cm + WHERE b.bookmarkable_id = cm.id + AND b.bookmarkable_type = :bookmarkable_type + AND (cm.deleted_at < :grace_time) + SQL + end + 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 00000000000..2c917cbfadb --- /dev/null +++ b/plugins/chat/lib/chat/message_creator.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true +module Chat + class MessageCreator + 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, + thread_id: nil, + staged_thread_id: nil, + user:, + content:, + staged_id: nil, + incoming_chat_webhook: nil, + upload_ids: nil, + created_at: nil + ) + @chat_channel = chat_channel + @user = user + @guardian = Guardian.new(user) + + # NOTE: We confirm this exists and the user can access it in the ChatController, + # but in future the checks should be here + @in_reply_to_id = in_reply_to_id + @content = content + @staged_id = staged_id + @incoming_chat_webhook = incoming_chat_webhook + @upload_ids = upload_ids || [] + @thread_id = thread_id + @staged_thread_id = staged_thread_id + @error = nil + + @chat_message = + Chat::Message.new( + chat_channel: @chat_channel, + user_id: @user.id, + last_editor_id: @user.id, + in_reply_to_id: @in_reply_to_id, + message: @content, + created_at: created_at, + ) + end + + def create + begin + validate_channel_status! + @chat_message.uploads = get_uploads + validate_message! + validate_reply_chain! + validate_existing_thread! + + @chat_message.thread_id = @existing_thread&.id + @chat_message.cook + @chat_message.save! + @chat_message.create_mentions + + create_chat_webhook_event + create_thread + Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all + post_process_resolved_thread + update_channel_last_message + Chat::Publisher.publish_new!( + @chat_channel, + @chat_message, + @staged_id, + staged_thread_id: @staged_thread_id, + ) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.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.#{@chat_channel.status}"), + ) + end + end + + def validate_reply_chain! + return if @in_reply_to_id.blank? + + @original_message_id = DB.query_single(<<~SQL).last + WITH RECURSIVE original_message_finder( id, in_reply_to_id ) + AS ( + -- start with the message id we want to find the parents of + SELECT id, in_reply_to_id + FROM chat_messages + WHERE id = #{@in_reply_to_id} + + UNION ALL + + -- get the chain of direct parents of the message + -- following in_reply_to_id + SELECT cm.id, cm.in_reply_to_id + FROM original_message_finder rm + JOIN chat_messages cm ON rm.in_reply_to_id = cm.id + ) + SELECT id FROM original_message_finder + + -- this makes it so only the root parent ID is returned, we can + -- exclude this to return all parents in the chain + WHERE in_reply_to_id IS NULL; + SQL + + if @original_message_id.blank? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + + @original_message = Chat::Message.with_deleted.find_by(id: @original_message_id) + if @original_message&.trashed? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + end + + def validate_existing_thread! + return if @staged_thread_id.present? && @thread_id.blank? + + return if @thread_id.blank? + @existing_thread = Chat::Thread.find(@thread_id) + + if @existing_thread.channel_id != @chat_channel.id + raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel")) + end + + reply_to_thread_mismatch = + @chat_message.in_reply_to&.thread_id && + @chat_message.in_reply_to.thread_id != @existing_thread.id + original_message_has_no_thread = @original_message && @original_message.thread_id.blank? + original_message_thread_mismatch = + @original_message && @original_message.thread_id != @existing_thread.id + if reply_to_thread_mismatch || original_message_has_no_thread || + original_message_thread_mismatch + raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent")) + end + end + + def validate_message! + return if @chat_message.valid? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + + def create_chat_webhook_event + return if @incoming_chat_webhook.blank? + Chat::WebhookEvent.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 + + def create_thread + return if @in_reply_to_id.blank? + return if @chat_message.in_thread? && !@staged_thread_id.present? + + if @original_message.thread + thread = @original_message.thread + else + thread = + Chat::Thread.create!( + original_message: @chat_message.in_reply_to, + original_message_user: @chat_message.in_reply_to.user, + channel: @chat_message.chat_channel, + ) + @chat_message.in_reply_to.thread_id = thread.id + end + + @chat_message.thread_id = thread.id + + # NOTE: We intentionally do not try to correct thread IDs within the chain + # if they are incorrect, and only set the thread ID of messages where the + # thread ID is NULL. In future we may want some sync/background job to correct + # any inconsistencies. + DB.exec(<<~SQL) + WITH RECURSIVE thread_updater AS ( + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@original_message_id} + + UNION ALL + + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id + ) + UPDATE chat_messages + SET thread_id = #{thread.id} + FROM thread_updater + WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id + SQL + + if @chat_message.chat_channel.threading_enabled + Chat::Publisher.publish_thread_created!( + @chat_message.chat_channel, + @chat_message.in_reply_to, + thread.id, + @staged_thread_id, + ) + end + end + + def resolved_thread + @existing_thread || @chat_message.thread + end + + def post_process_resolved_thread + return if resolved_thread.blank? + + resolved_thread.update!(last_message: @chat_message) + resolved_thread.increment_replies_count_cache + current_user_thread_membership = resolved_thread.add(@user) + current_user_thread_membership.update!(last_read_message_id: @chat_message.id) + + if resolved_thread.original_message_user != @user + resolved_thread.add(resolved_thread.original_message_user) + end + end + + def update_channel_last_message + return if @chat_message.thread_reply? + @chat_channel.update!(last_message: @chat_message) + end + end +end diff --git a/plugins/chat/lib/chat/message_mover.rb b/plugins/chat/lib/chat/message_mover.rb new file mode 100644 index 00000000000..2c7c86d69a1 --- /dev/null +++ b/plugins/chat/lib/chat/message_mover.rb @@ -0,0 +1,250 @@ +# 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. +# +# Reply chains are a little complex. No reply chains are preserved when moving +# messages into a new channel. Remaining messages that referenced moved ones +# have their in_reply_to_id cleared so the data makes sense. +# +# Threads are even more complex. No threads are preserved when moving messages +# into a new channel, they end up as just a flat series of messages that are +# not in a chain. If the original message of a thread and N other messages +# in that thread, then any messages left behind just get placed into a new +# thread. Message moving will be disabled in the thread UI, its too complicated +# to have end users reason about for now, and we may want a standalone +# "Move Thread" UI later on. +module Chat + class 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 + + Chat::Message.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 + update_reply_references + update_tracking_state + update_thread_references + end + + add_moved_placeholder(destination_channel, moved_messages.first) + moved_messages + end + + private + + def find_messages(message_ids, channel) + Chat::Message + .includes(thread: %i[original_message original_message_user]) + .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) + ORDER BY created_at ASC, id ASC + 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, target_type: Chat::Message.polymorphic_name) + UPDATE upload_references uref + SET target_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE uref.target_id = mm.old_chat_message_id AND uref.target_type = :target_type + 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 + # We do this so @source_messages is not nulled out, which is the + # case when using update_all here. + DB.exec(<<~SQL, source_message_ids: @source_message_ids, deleted_by_id: @acting_user.id) + UPDATE chat_messages + SET deleted_at = NOW(), deleted_by_id = :deleted_by_id + WHERE id IN (:source_message_ids) + SQL + Chat::Publisher.publish_bulk_delete!(@source_channel, @source_message_ids) + end + + def add_moved_placeholder(destination_channel, first_moved_message) + Chat::MessageCreator.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 + + def update_reply_references + DB.exec(<<~SQL, deleted_reply_to_ids: @source_message_ids) + UPDATE chat_messages + SET in_reply_to_id = NULL + WHERE in_reply_to_id IN (:deleted_reply_to_ids) + SQL + end + + def update_tracking_state + ::Chat::Action::ResetUserLastReadChannelMessage.call(@source_message_ids, @source_channel.id) + end + + def update_thread_references + threads_to_update = [] + @source_messages + .select { |message| message.in_thread? } + .each do |message_with_thread| + # If one of the messages we are moving is the original message in a thread, + # then all the remaining messages for that thread must be moved to a new one, + # otherwise they will be pointing to a thread in a different channel. + if message_with_thread.thread.original_message_id == message_with_thread.id + threads_to_update << message_with_thread.thread + end + end + + threads_to_update.each do |thread| + # NOTE: We may want to do something different with the old empty thread at some + # point when we add an explicit thread move UI, for now we can just delete it, + # since it will not contain any important data. + if thread.chat_messages.empty? + thread.destroy! + next + end + + Chat::Thread.transaction do + original_message = thread.chat_messages.first + new_thread = + Chat::Thread.create!( + original_message: original_message, + original_message_user: original_message.user, + channel: @source_channel, + ) + thread.chat_messages.update_all(thread_id: new_thread.id) + end + end + end + 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 00000000000..ae663ecb624 --- /dev/null +++ b/plugins/chat/lib/chat/message_processor.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Chat + class MessageProcessor + include ::CookedProcessorMixin + + def initialize(chat_message) + @model = chat_message + @previous_cooked = (chat_message.cooked || "").dup + @with_secure_uploads = false + @size_cache = {} + @opts = {} + + cooked = Chat::Message.cook(chat_message.message, user_id: chat_message.last_editor_id) + @doc = Loofah.html5_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 +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 00000000000..8d1f3b83f00 --- /dev/null +++ b/plugins/chat/lib/chat/message_rate_limiter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Chat + class MessageRateLimiter + 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 if silenced_for_minutes.zero? + + UserSilencer.silence( + @user, + Discourse.system_user, + silenced_till: silenced_for_minutes.minutes.from_now, + reason: I18n.t("chat.errors.rate_limit_exceeded"), + ) + end + 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 00000000000..74dcdc2c8c5 --- /dev/null +++ b/plugins/chat/lib/chat/message_reactor.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Chat + class MessageReactor + 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_join_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) + + reaction = nil + ActiveRecord::Base.transaction do + enforce_channel_membership! + reaction = create_reaction(message, react_action, emoji) + end + + publish_reaction(message, react_action, emoji) + + reaction + end + + private + + def ensure_chat_message!(message_id) + message = Chat::Message.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::ChannelMembershipManager.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.#{@chat_channel.status}", + ) + 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) + Chat::Publisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) + end + 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 00000000000..04eb7ba5e6b --- /dev/null +++ b/plugins/chat/lib/chat/message_updater.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Chat + class MessageUpdater + 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 + @chat_message.uploads = upload_info[:uploads] if upload_info[:changed] + validate_message! + @chat_message.cook + @chat_message.save! + + @chat_message.update_mentions + revision = save_revision! + + @chat_message.reload + Chat::Publisher.publish_edit!(@chat_channel, @chat_message) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) + DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) + + if @chat_message.thread.present? + Chat::Publisher.publish_thread_original_message_metadata!(@chat_message.thread) + end + 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.#{@chat_channel.status}"), + ) + end + + def validate_message! + return if @chat_message.valid? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + 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 save_revision! + @chat_message.revisions.create!( + old_message: @old_message_content, + new_message: @chat_message.message, + user_id: @user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/messages_exporter.rb b/plugins/chat/lib/chat/messages_exporter.rb new file mode 100644 index 00000000000..e2018099dde --- /dev/null +++ b/plugins/chat/lib/chat/messages_exporter.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Chat + module MessagesExporter + def chat_message_export(&block) + # perform 1 db query per month: + now = Time.now + from = Chat::Message.minimum(:created_at) + while from <= now + export(from, from + 1.month, &block) + from = from + 1.month + end + end + + def get_header(entity) + if entity === "chat_message" + %w[ + id + chat_channel_id + chat_channel_name + user_id + username + message + cooked + created_at + updated_at + deleted_at + in_reply_to_id + last_editor_id + last_editor_username + ] + else + super + end + end + + private + + def export(from, to) + Chat::Message + .unscoped + .where(created_at: from..to) + .includes(:chat_channel) + .includes(:user) + .includes(:last_editor) + .find_each do |chat_message| + yield( + [ + chat_message.id, + chat_message.chat_channel.id, + chat_message.chat_channel.name, + chat_message.user.id, + chat_message.user.username, + chat_message.message, + chat_message.cooked, + chat_message.created_at, + chat_message.updated_at, + chat_message.deleted_at, + chat_message.in_reply_to&.id, + chat_message.last_editor&.id, + chat_message.last_editor&.username, + ] + ) + end + end + end +end diff --git a/plugins/chat/lib/chat/notification_levels.rb b/plugins/chat/lib/chat/notification_levels.rb new file mode 100644 index 00000000000..3ac06d6a0fa --- /dev/null +++ b/plugins/chat/lib/chat/notification_levels.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Chat + class NotificationLevels + def self.all + @all_levels ||= Enum.new(muted: 0, normal: 1, tracking: 2, watching: 3) + end + end +end diff --git a/plugins/chat/lib/chat/notifier.rb b/plugins/chat/lib/chat/notifier.rb new file mode 100644 index 00000000000..052d222b4da --- /dev/null +++ b/plugins/chat/lib/chat/notifier.rb @@ -0,0 +1,288 @@ +# 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 Chat::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 Jobs::Chat::NotifyWatching job, +# which prevents desktop / push notifications being sent. +module Chat + class Notifier + 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:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "edit", + ) + end + + def notify_new(chat_message:, timestamp:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "new", + ) + end + end + + def initialize(chat_message, timestamp) + @chat_message = chat_message + @parsed_mentions = @chat_message.parsed_mentions + @timestamp = timestamp + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + end + + ### Public API + + def notify_new + to_notify, inaccessible, all_mentioned_user_ids = list_users_to_notify + + all_mentioned_user_ids.each do |member_id| + Chat::Publisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) + end + + notify_creator_of_inaccessible_mentions(inaccessible) + + notify_mentioned_users(to_notify) + notify_watching_users(except: all_mentioned_user_ids << @user.id) + + to_notify + end + + def notify_edit + already_notified_user_ids = + Chat::Mention + .where(chat_message: @chat_message) + .where.not(notification: nil) + .pluck(:user_id) + + to_notify, inaccessible, all_mentioned_user_ids = list_users_to_notify + needs_notification_ids = all_mentioned_user_ids - already_notified_user_ids + return if needs_notification_ids.blank? + + notify_creator_of_inaccessible_mentions(inaccessible) + notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) + + to_notify + end + + private + + def list_users_to_notify + skip_notifications = @parsed_mentions.count > SiteSetting.max_mentions_per_chat_message + + to_notify = {} + inaccessible = {} + all_mentioned_user_ids = [] + + # The order of these methods is the precedence + # between different mention types. + expand_direct_mentions(to_notify, inaccessible, all_mentioned_user_ids, skip_notifications) + if !skip_notifications + expand_group_mentions(to_notify, inaccessible, all_mentioned_user_ids) + expand_here_mention(to_notify, all_mentioned_user_ids) + expand_global_mention(to_notify, all_mentioned_user_ids) + end + + filter_users_ignoring_or_muting_creator(to_notify, inaccessible, all_mentioned_user_ids) + + [to_notify, inaccessible, all_mentioned_user_ids] + end + + def expand_global_mention(to_notify, already_covered_ids) + has_all_mention = @parsed_mentions.has_global_mention + + if has_all_mention && @chat_channel.allow_channel_wide_mentions + to_notify[:global_mentions] = @parsed_mentions + .global_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .where.not(username_lower: @user.username_lower) + .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) + has_here_mention = @parsed_mentions.has_here_mention + + if has_here_mention && @chat_channel.allow_channel_wide_mentions + to_notify[:here_mentions] = @parsed_mentions + .here_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .where.not(username_lower: @user.username_lower) + .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_members, unreachable = + users.partition { |user| user.guardian.can_join_chat_channel?(@chat_channel) } + + members, welcome_to_join = + potential_members.partition { |user| @chat_channel.joined_by?(user) } + + { + members: members || [], + welcome_to_join: welcome_to_join || [], + unreachable: unreachable || [], + } + end + + def expand_direct_mentions(to_notify, inaccessible, already_covered_ids, skip) + if skip + direct_mentions = [] + else + direct_mentions = + @parsed_mentions + .direct_mentions + .not_suspended + .where.not(username_lower: @user.username_lower) + .where.not(id: already_covered_ids) + end + + grouped = group_users_to_notify(direct_mentions) + + to_notify[:direct_mentions] = grouped[:members].map(&:id) + inaccessible[:welcome_to_join] = grouped[:welcome_to_join] + inaccessible[:unreachable] = grouped[:unreachable] + already_covered_ids.concat(to_notify[:direct_mentions]) + end + + def expand_group_mentions(to_notify, inaccessible, already_covered_ids) + return if @parsed_mentions.visible_groups.empty? + + reached_by_group = + @parsed_mentions + .group_mentions + .not_suspended + .where("user_count <= ?", SiteSetting.max_users_notified_per_group_mention) + .where.not(username_lower: @user.username_lower) + .where.not(id: already_covered_ids) + + @parsed_mentions.groups_to_mention.each { |g| to_notify[g.name.downcase] = [] } + + grouped = group_users_to_notify(reached_by_group) + grouped[:members].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 = + @parsed_mentions.parsed_group_mentions & + @parsed_mentions.groups_to_mention.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 + already_covered_ids << user.id + end + + inaccessible[:welcome_to_join] = inaccessible[:welcome_to_join].concat( + grouped[:welcome_to_join], + ) + inaccessible[:unreachable] = inaccessible[:unreachable].concat(grouped[:unreachable]) + end + + def notify_creator_of_inaccessible_mentions(inaccessible) + group_mentions_disabled = @parsed_mentions.groups_with_disabled_mentions.to_a + too_many_members = @parsed_mentions.groups_with_too_many_members.to_a + if inaccessible.values.all?(&:blank?) && group_mentions_disabled.empty? && + too_many_members.empty? + return + end + + Chat::Publisher.publish_inaccessible_mentions( + @user.id, + @chat_message, + inaccessible[:unreachable].to_a, + inaccessible[:welcome_to_join].to_a, + too_many_members, + group_mentions_disabled, + ) + 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 Jobs::Chat::NotifyMentioned job and are not prompted for + # invitation by the creator. + def filter_users_ignoring_or_muting_creator(to_notify, inaccessible, already_covered_ids) + screen_targets = already_covered_ids.concat(inaccessible[:welcome_to_join].map(&:id)) + + return if screen_targets.blank? + + screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) + to_notify.each do |key, user_ids| + to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } + end + + # :welcome_to_join contains users because it's serialized by MB. + inaccessible[:welcome_to_join] = inaccessible[:welcome_to_join].reject do |user| + screener.ignoring_or_muting_actor?(user.id) + end + + already_covered_ids.reject! do |already_covered| + screener.ignoring_or_muting_actor?(already_covered) + end + end + + def notify_mentioned_users(to_notify, already_notified_user_ids: []) + Jobs.enqueue( + Jobs::Chat::NotifyMentioned, + { + chat_message_id: @chat_message.id, + to_notify_ids_map: to_notify.as_json, + already_notified_user_ids: already_notified_user_ids, + timestamp: @timestamp, + }, + ) + end + + def notify_watching_users(except: []) + Jobs.enqueue( + Jobs::Chat::NotifyWatching, + { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, + ) + end + end +end diff --git a/plugins/chat/lib/chat/parsed_mentions.rb b/plugins/chat/lib/chat/parsed_mentions.rb new file mode 100644 index 00000000000..3b0bd69649e --- /dev/null +++ b/plugins/chat/lib/chat/parsed_mentions.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Chat + class ParsedMentions + def initialize(message) + @message = message + + mentions = parse_mentions(message) + group_mentions = parse_group_mentions(message) + + @has_global_mention = mentions.include?("@all") + @has_here_mention = mentions.include?("@here") + @parsed_direct_mentions = normalize(mentions) + @parsed_group_mentions = normalize(group_mentions) + end + + attr_accessor :has_global_mention, + :has_here_mention, + :parsed_direct_mentions, + :parsed_group_mentions + + def all_mentioned_users_ids + @all_mentioned_users_ids ||= + begin + user_ids = global_mentions.pluck(:id) + user_ids.concat(direct_mentions.pluck(:id)) + user_ids.concat(group_mentions.pluck(:id)) + user_ids.concat(here_mentions.pluck(:id)) + user_ids.uniq! + user_ids + end + end + + def count + @count ||= + begin + result = @parsed_direct_mentions.length + @parsed_group_mentions.length + result += 1 if @has_global_mention + result += 1 if @has_here_mention + result + end + end + + def global_mentions + return User.none unless @has_global_mention + channel_members.where.not(username_lower: @parsed_direct_mentions) + end + + def direct_mentions + chat_users.where(username_lower: @parsed_direct_mentions) + end + + def group_mentions + chat_users.includes(:groups).joins(:groups).where(groups: mentionable_groups) + end + + def here_mentions + return User.none unless @has_here_mention + + channel_members + .where("last_seen_at > ?", 5.minutes.ago) + .where.not(username_lower: @parsed_direct_mentions) + end + + def groups_to_mention + @groups_to_mention = mentionable_groups - groups_with_too_many_members + end + + def groups_with_disabled_mentions + @groups_with_disabled_mentions ||= visible_groups - mentionable_groups + end + + def groups_with_too_many_members + @groups_with_too_many_members ||= + mentionable_groups.where("user_count > ?", SiteSetting.max_users_notified_per_group_mention) + end + + def visible_groups + @visible_groups ||= + Group.where("LOWER(name) IN (?)", @parsed_group_mentions).visible_groups(@message.user) + end + + private + + def channel_members + chat_users.where( + user_chat_channel_memberships: { + following: true, + chat_channel_id: @message.chat_channel.id, + }, + ) + end + + def chat_users + User + .includes(:user_chat_channel_memberships, :group_users) + .distinct + .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins(:user_option) + .real + .where(user_options: { chat_enabled: true }) + end + + def mentionable_groups + @mentionable_groups ||= + Group.mentionable(@message.user, include_public: false).where(id: visible_groups.map(&:id)) + end + + def parse_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention").map(&:text) + end + + def parse_group_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention-group").map(&:text) + end + + def normalize(mentions) + mentions.reduce([]) do |memo, mention| + %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) + end + end + end +end diff --git a/plugins/chat/lib/chat/plugin_instance_extension.rb b/plugins/chat/lib/chat/plugin_instance_extension.rb new file mode 100644 index 00000000000..58c5ebc308f --- /dev/null +++ b/plugins/chat/lib/chat/plugin_instance_extension.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + module PluginInstanceExtension + def self.prepended(base) + DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) + end + + def chat + ChatPluginApiExtensions + end + + module ChatPluginApiExtensions + def self.enable_markdown_feature(name) + DiscoursePluginRegistry.chat_markdown_features << name + end + end + end +end diff --git a/plugins/chat/lib/chat/post_notification_handler.rb b/plugins/chat/lib/chat/post_notification_handler.rb new file mode 100644 index 00000000000..449d6c27a22 --- /dev/null +++ b/plugins/chat/lib/chat/post_notification_handler.rb @@ -0,0 +1,42 @@ +# 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. +module Chat + class 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 +end diff --git a/plugins/chat/lib/chat/review_queue.rb b/plugins/chat/lib/chat/review_queue.rb new file mode 100644 index 00000000000..e90d46f9fb5 --- /dev/null +++ b/plugins/chat/lib/chat/review_queue.rb @@ -0,0 +1,212 @@ +# 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 reviewable score and creates the reviewable using +# the force_review option. + +module Chat + class ReviewQueue + 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 = + Chat::ReviewableMessage.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) + Chat::Publisher.publish_delete!(chat_message.chat_channel, chat_message) + else + enforce_auto_silence_threshold(reviewable) + Chat::Publisher.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 <= Chat::ReviewableMessage.score_to_silence_user + + user = reviewable.target_created_by + return if user.admin? + 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 = + Chat::Message + .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 = + Chat::TranscriptService.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 +end diff --git a/plugins/chat/lib/chat/reviewable_extension.rb b/plugins/chat/lib/chat/reviewable_extension.rb new file mode 100644 index 00000000000..c856701dbf7 --- /dev/null +++ b/plugins/chat/lib/chat/reviewable_extension.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat + module ReviewableExtension + extend ActiveSupport::Concern + + prepended { include TypeMappable } + + class_methods do + def sti_class_mapping = { "ReviewableChatMessage" => Chat::ReviewableMessage } + def polymorphic_class_mapping = { "ChatMessage" => Chat::Message } + end + end +end diff --git a/plugins/chat/lib/chat/secure_uploads_compatibility.rb b/plugins/chat/lib/chat/secure_uploads_compatibility.rb new file mode 100644 index 00000000000..1a63f0ff743 --- /dev/null +++ b/plugins/chat/lib/chat/secure_uploads_compatibility.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Chat + class 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 +end diff --git a/plugins/chat/lib/chat/seeder.rb b/plugins/chat/lib/chat/seeder.rb new file mode 100644 index 00000000000..b6853be92bb --- /dev/null +++ b/plugins/chat/lib/chat/seeder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + class Seeder + 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::ChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/slack_compatibility.rb b/plugins/chat/lib/chat/slack_compatibility.rb new file mode 100644 index 00000000000..1c8b628668c --- /dev/null +++ b/plugins/chat/lib/chat/slack_compatibility.rb @@ -0,0 +1,62 @@ +# 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. +module Chat + class 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 +end diff --git a/plugins/chat/lib/chat/statistics.rb b/plugins/chat/lib/chat/statistics.rb new file mode 100644 index 00000000000..cc9a6b3f313 --- /dev/null +++ b/plugins/chat/lib/chat/statistics.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Chat + class Statistics + def self.about_messages + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Message.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, + :count => Chat::Message.count, + } + end + + def self.about_channels + { + :last_day => Chat::Channel.where(status: :open).where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Channel.where(status: :open).where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Channel.where(status: :open).where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Channel + .where(status: :open) + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .count, + :count => Chat::Channel.where(status: :open).count, + } + end + + def self.about_users + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).distinct.count(:user_id), + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).distinct.count(:user_id), + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).distinct.count(:user_id), + :previous_30_days => + Chat::Message + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .distinct + .count(:user_id), + :count => Chat::Message.distinct.count(:user_id), + } + end + + def self.monthly + start_of_month = Time.zone.now.beginning_of_month + { + messages: Chat::Message.where("created_at > ?", start_of_month).count, + channels: Chat::Channel.where(status: :open).where("created_at > ?", start_of_month).count, + users: Chat::Message.where("created_at > ?", start_of_month).distinct.count(:user_id), + } + end + end +end diff --git a/plugins/chat/lib/chat/steps_inspector.rb b/plugins/chat/lib/chat/steps_inspector.rb new file mode 100644 index 00000000000..2cc40a0c1f6 --- /dev/null +++ b/plugins/chat/lib/chat/steps_inspector.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module Chat + # = Chat::StepsInspector + # + # This class takes a {Service::Base::Context} object and inspects it. + # It will output a list of steps and what is their known state. + class StepsInspector + # @!visibility private + class Step + attr_reader :step, :result, :nesting_level + + delegate :name, to: :step + delegate :failure?, :success?, :error, to: :step_result, allow_nil: true + + def self.for(step, result, nesting_level: 0) + class_name = + "#{module_parent_name}::#{step.class.name.split("::").last.sub(/^(\w+)Step$/, "\\1")}" + class_name.constantize.new(step, result, nesting_level: nesting_level) + end + + def initialize(step, result, nesting_level: 0) + @step = step + @result = result + @nesting_level = nesting_level + end + + def type + self.class.name.split("::").last.downcase + end + + def emoji + "#{result_emoji}#{unexpected_result_emoji}" + end + + def steps + [self] + end + + def inspect + "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip + end + + private + + def step_result + result["result.#{type}.#{name}"] + end + + def result_emoji + return "❌" if failure? + return "✅" if success? + "" + end + + def unexpected_result_emoji + " ⚠️#{unexpected_result_text}" if step_result.try(:[], "spec.unexpected_result") + end + + def unexpected_result_text + return " <= expected to return true but got false instead" if failure? + " <= expected to return false but got true instead" + end + end + + # @!visibility private + class Model < Step + def error + return result[name].errors.inspect if step_result.invalid + step_result.exception.full_message + end + end + + # @!visibility private + class Contract < Step + def error + step_result.errors.inspect + end + end + + # @!visibility private + class Policy < Step + def error + step_result.reason + end + end + + # @!visibility private + class Transaction < Step + def steps + [self, *step.steps.map { Step.for(_1, result, nesting_level: nesting_level + 1).steps }] + end + + def inspect + "#{" " * nesting_level}[#{type}]" + end + + def step_result + nil + end + end + + attr_reader :steps, :result + + def initialize(result) + @steps = result.__steps__.map { Step.for(_1, result).steps }.flatten + @result = result + end + + # Inspect the provided result object. + # Example output: + # [1/4] [model] 'channel' ✅ + # [2/4] [contract] 'default' ✅ + # [3/4] [policy] 'check_channel_permission' ❌ + # [4/4] [step] 'change_status' + # @return [String] the steps of the result object with their state + def inspect + steps + .map + .with_index { |step, index| "[#{index + 1}/#{steps.size}] #{step.inspect}" } + .join("\n") + end + + # @return [String, nil] the first available error, if any. + def error + steps.detect(&:failure?)&.error + end + 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 00000000000..82f27dca428 --- /dev/null +++ b/plugins/chat/lib/chat/transcript_service.rb @@ -0,0 +1,179 @@ +# 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. +module Chat + class TranscriptService + CHAINED_ATTR = "chained=\"true\"" + MULTIQUOTE_ATTR = "multiQuote=\"true\"" + NO_LINK_ATTR = "noLink=\"true\"" + + class TranscriptBBCode + 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 = + TranscriptBBCode.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 = + TranscriptBBCode.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 ||= + Chat::Message + .includes(:user, upload_references: :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 +end diff --git a/plugins/chat/lib/chat/types/array.rb b/plugins/chat/lib/chat/types/array.rb new file mode 100644 index 00000000000..a8491a868b1 --- /dev/null +++ b/plugins/chat/lib/chat/types/array.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + module Types + class Array < ActiveModel::Type::Value + def serializable?(_) + false + end + + def cast_value(value) + case value + when String + value.split(",") + else + ::Array.wrap(value) + end + end + end + end +end + +ActiveSupport.on_load(:active_record) { ActiveModel::Type.register(:array, Chat::Types::Array) } diff --git a/plugins/chat/lib/chat/user_email_extension.rb b/plugins/chat/lib/chat/user_email_extension.rb new file mode 100644 index 00000000000..366fc41bb32 --- /dev/null +++ b/plugins/chat/lib/chat/user_email_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + module 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| + Chat::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 +end diff --git a/plugins/chat/lib/chat/user_extension.rb b/plugins/chat/lib/chat/user_extension.rb new file mode 100644 index 00000000000..d5c7e14c926 --- /dev/null +++ b/plugins/chat/lib/chat/user_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + module UserExtension + extend ActiveSupport::Concern + + prepended do + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + dependent: :destroy + has_many :user_chat_thread_memberships, + class_name: "Chat::UserChatThreadMembership", + dependent: :destroy + has_many :chat_message_reactions, class_name: "Chat::MessageReaction", dependent: :destroy + has_many :chat_mentions, class_name: "Chat::Mention" + end + end +end diff --git a/plugins/chat/lib/chat/user_notifications_extension.rb b/plugins/chat/lib/chat/user_notifications_extension.rb new file mode 100644 index 00000000000..6c73ceb1d0d --- /dev/null +++ b/plugins/chat/lib/chat/user_notifications_extension.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Chat + module UserNotificationsExtension + def chat_summary(user, opts) + guardian = Guardian.new(user) + return unless guardian.can_chat? + + @messages = + Chat::Message + .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 AND cm.notification_id IS NOT NULL", + ) + .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_join_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) + all_channels = grouped_messages.keys + grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } + channels = grouped_channels.first + + dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } + dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) + + # Prioritize messages from regular channels over direct messages + if channels.any? + channel_notification_text( + channels.sort_by { |channel| [channel.last_message.created_at, channel.created_at] }, + dm_users, + ) + else + direct_message_notification_text(dm_users) + end + end + + private + + def channel_notification_text(channels, dm_users) + total_count = channels.size + dm_users.size + + if total_count > 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_more", + email_prefix: @email_prefix, + channel: channels.first.title, + count: total_count - 1, + ) + elsif channels.size == 1 && dm_users.size == 0 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_1", + email_prefix: @email_prefix, + channel: channels.first.title, + ) + elsif channels.size == 1 && dm_users.size == 1 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_and_direct_message", + email_prefix: @email_prefix, + channel: channels.first.title, + username: dm_users.first.username, + ) + elsif channels.size == 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_2", + email_prefix: @email_prefix, + channel1: channels.first.title, + channel2: channels.second.title, + ) + end + end + + def direct_message_notification_text(dm_users) + case dm_users.size + when 1 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_1", + email_prefix: @email_prefix, + username: dm_users.first.username, + ) + when 2 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_2", + email_prefix: @email_prefix, + username1: dm_users.first.username, + username2: dm_users.second.username, + ) + else + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_more", + email_prefix: @email_prefix, + username: dm_users.first.username, + count: dm_users.size - 1, + ) + end + end + end +end diff --git a/plugins/chat/lib/chat/user_option_extension.rb b/plugins/chat/lib/chat/user_option_extension.rb new file mode 100644 index 00000000000..290f6361d55 --- /dev/null +++ b/plugins/chat/lib/chat/user_option_extension.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Chat + module 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 + + def base.chat_header_indicator_preferences + @chat_header_indicator_preferences ||= { all_new: 0, dm_and_mentions: 1, never: 2 } + end + + if !base.method_defined?(:send_chat_email_never?) # Avoid attempting to override when autoloading + base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" + end + + if !base.method_defined?(:chat_header_indicator_never?) # Avoid attempting to override when autoloading + base.enum :chat_header_indicator_preference, + base.chat_header_indicator_preferences, + prefix: "chat_header_indicator" + end + end + end +end diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb deleted file mode 100644 index cf656ef4cac..00000000000 --- a/plugins/chat/lib/chat_channel_archive_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -## -# From time to time, site admins may choose to sunset a chat channel and archive -# the messages within. 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 - - class ArchiveValidationError < StandardError - attr_reader :errors - - def initialize(errors: []) - super - @errors = errors - end - end - - def self.create_archive_process(chat_channel:, acting_user:, topic_params:) - return if ChatChannelArchive.exists?(chat_channel: chat_channel) - - # Only need to validate topic params for a new topic, not an existing one. - if topic_params[:topic_id].blank? - valid, errors = - Chat::ChatChannelArchiveService.validate_topic_params( - Guardian.new(acting_user), - topic_params, - ) - - raise ArchiveValidationError.new(errors: errors) if !valid - end - - 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, - ) - chat_channel.chat_channel_archive - end - - def self.validate_topic_params(guardian, topic_params) - topic_creator = - TopicCreator.new( - Discourse.system_user, - guardian, - { - title: topic_params[:topic_title], - category: topic_params[:category_id], - tags: topic_params[:tags], - import_mode: true, - }, - ) - [topic_creator.valid?, topic_creator.errors.full_messages] - 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 - return if !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_message: err.message) - 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, - }, - ) - - if topic_creator.valid? - chat_channel_archive.update!(destination_topic: topic_creator.create) - else - Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") - notify_archiver( - :failed_no_topic, - error_message: topic_creator.errors.full_messages.join("\n"), - ) - end - end - - if chat_channel_archive.destination_topic.present? - 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, - ), - ) - end - else - Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") - end - - if chat_channel_archive.destination_topic.present? - update_destination_topic_status - return true - end - - false - 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.new_topic? - 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_message: nil) - base_translation_params = { - channel_hashtag_or_name: channel_hashtag_or_name, - topic_title: chat_channel_archive.destination_topic&.title, - topic_url: chat_channel_archive.destination_topic&.url, - topic_validation_errors: result == :failed_no_topic ? error_message : nil, - } - - if result == :failed || result == :failed_no_topic - Discourse.warn_exception( - error_message, - 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) - message_translation_key = - case result - when :failed - :chat_channel_archive_failed - when :failed_no_topic - :chat_channel_archive_failed_no_topic - end - SystemMessage.create_from_system_user( - chat_channel_archive.archived_by, - message_translation_key, - 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 != :success ? :failed : :success, - 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 - - def channel_hashtag_or_name - if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete - return "##{chat_channel.slug}::channel" - end - chat_channel_title - end -end diff --git a/plugins/chat/lib/chat_channel_fetcher.rb b/plugins/chat/lib/chat_channel_fetcher.rb deleted file mode 100644 index 028e0f3643e..00000000000 --- a/plugins/chat/lib/chat_channel_fetcher.rb +++ /dev/null @@ -1,257 +0,0 @@ -# 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, exclude_dm_channels: false) - category_channel_sql = - Category - .post_create_allowed(guardian) - .joins( - "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", - ) - .select("chat_channels.id") - .to_sql - dm_channel_sql = "" - if !exclude_dm_channels - dm_channel_sql = <<~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 - - <<~SQL - -- secured category chat channels - #{category_channel_sql} - #{dm_channel_sql} - SQL - end - - def self.secured_public_channel_slug_lookup(guardian, slugs) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - ChatChannel - .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 (#{allowed_channel_ids})") - .where("chat_channels.slug IN (:slugs)", slugs: slugs) - .limit(1) - end - - def self.secured_public_channel_search(guardian, options = {}) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - channels = ChatChannel.includes(chatable: [:topic_only_relative_url]) - channels = channels.includes(:chat_channel_archive) if options[:include_archives] - - channels = - channels - .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 (#{allowed_channel_ids})") - - channels = channels.where(status: options[:status]) if options[:status].present? - - if options[:filter].present? - category_filter = - (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") - - sql = - "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" - if options[:match_filter_on_starts_with] - filter_sql = "#{options[:filter].downcase}%" - else - filter_sql = "%#{options[:filter].downcase}%" - end - - channels = - channels.where(sql, filter: filter_sql).order("chat_channels.name ASC, categories.name ASC") - end - - if options.key?(:slugs) - channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) - 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.merge(include_archives: true, filter_on_category_name: true), - ) - - 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_join_chat_channel?(chat_channel) - chat_channel - end -end diff --git a/plugins/chat/lib/chat_channel_hashtag_data_source.rb b/plugins/chat/lib/chat_channel_hashtag_data_source.rb deleted file mode 100644 index 5c4e31cd867..00000000000 --- a/plugins/chat/lib/chat_channel_hashtag_data_source.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatChannelHashtagDataSource - def self.icon - "comment" - end - - def self.type - "channel" - end - - def self.channel_to_hashtag_item(guardian, channel) - HashtagAutocompleteService::HashtagItem.new.tap do |item| - item.text = channel.title - item.description = channel.description - item.slug = channel.slug - item.icon = icon - item.relative_url = channel.relative_url - item.type = "channel" - end - end - - def self.lookup(guardian, slugs) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_slug_lookup(guardian, slugs) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search( - guardian, - term, - limit, - condition = HashtagAutocompleteService.search_conditions[:contains] - ) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_search( - guardian, - filter: term, - limit: limit, - exclude_dm_channels: true, - match_filter_on_starts_with: - condition == HashtagAutocompleteService.search_conditions[:starts_with], - ) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search_sort(search_results, _) - search_results.sort_by { |result| result.text.downcase } - end - - def self.search_without_term(guardian, limit) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - allowed_channel_ids_sql = - Chat::ChatChannelFetcher.generate_allowed_channel_ids_sql( - guardian, - exclude_dm_channels: true, - ) - ChatChannel - .joins( - "INNER JOIN user_chat_channel_memberships - ON user_chat_channel_memberships.chat_channel_id = chat_channels.id - AND user_chat_channel_memberships.user_id = #{guardian.user.id} - AND user_chat_channel_memberships.following = true", - ) - .where("chat_channels.id IN (#{allowed_channel_ids_sql})") - .order(messages_count: :desc) - .limit(limit) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end -end diff --git a/plugins/chat/lib/chat_channel_membership_manager.rb b/plugins/chat/lib/chat_channel_membership_manager.rb deleted file mode 100644 index 5947f23d7a4..00000000000 --- a/plugins/chat/lib/chat_channel_membership_manager.rb +++ /dev/null @@ -1,79 +0,0 @@ -# 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 deleted file mode 100644 index 7600a25fe4c..00000000000 --- a/plugins/chat/lib/chat_mailer.rb +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index 09353508701..00000000000 --- a/plugins/chat/lib/chat_message_bookmarkable.rb +++ /dev/null @@ -1,69 +0,0 @@ -# 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_join_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_join_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 deleted file mode 100644 index fe7fed04599..00000000000 --- a/plugins/chat/lib/chat_message_creator.rb +++ /dev/null @@ -1,104 +0,0 @@ -# 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, - last_editor_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, - ) - @chat_channel.touch(:last_message_sent_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 deleted file mode 100644 index bf2a621d920..00000000000 --- a/plugins/chat/lib/chat_message_processor.rb +++ /dev/null @@ -1,33 +0,0 @@ -# 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, user_id: chat_message.last_editor_id) - @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 deleted file mode 100644 index 9f205098e7b..00000000000 --- a/plugins/chat/lib/chat_message_rate_limiter.rb +++ /dev/null @@ -1,49 +0,0 @@ -# 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 deleted file mode 100644 index 9e7260f7979..00000000000 --- a/plugins/chat/lib/chat_message_reactor.rb +++ /dev/null @@ -1,86 +0,0 @@ -# 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_join_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) - - reaction = nil - ActiveRecord::Base.transaction do - enforce_channel_membership! - reaction = create_reaction(message, react_action, emoji) - end - - publish_reaction(message, react_action, emoji) - - reaction - 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 deleted file mode 100644 index 04e8ae9372b..00000000000 --- a/plugins/chat/lib/chat_message_updater.rb +++ /dev/null @@ -1,97 +0,0 @@ -# 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! - @chat_message.reload - 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 deleted file mode 100644 index 017aa1b3200..00000000000 --- a/plugins/chat/lib/chat_notifier.rb +++ /dev/null @@ -1,359 +0,0 @@ -# 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:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: :edit, - ) - end - - def notify_new(chat_message:, timestamp:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: :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 - 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(to_notify) - - 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 - 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(to_notify) - - notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) - - to_notify - end - - private - - def list_users_to_notify - direct_mentions_count = direct_mentions_from_cooked.length - group_mentions_count = group_name_mentions.length - - skip_notifications = - (direct_mentions_count + group_mentions_count) > SiteSetting.max_mentions_per_chat_message - - {}.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, skip_notifications) - expand_group_mentions(to_notify, already_covered_ids, skip_notifications) - expand_here_mention(to_notify, already_covered_ids, skip_notifications) - expand_global_mention(to_notify, already_covered_ids, skip_notifications) - - 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 - User - .includes(:user_chat_channel_memberships, :group_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, skip) - typed_global_mention = direct_mentions_from_cooked.include?("@all") - - if typed_global_mention && @chat_channel.allow_channel_wide_mentions && !skip - 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, skip) - typed_here_mention = direct_mentions_from_cooked.include?("@here") - - if typed_here_mention && @chat_channel.allow_channel_wide_mentions && !skip - 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? && guardian.can_join_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, skip) - if skip - direct_mentions = [] - else - direct_mentions = - chat_users - .where(username_lower: normalized_mentions(direct_mentions_from_cooked)) - .where.not(id: already_covered_ids) - end - - 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 visible_groups - @visible_groups ||= Group.where("LOWER(name) IN (?)", group_name_mentions).visible_groups(@user) - end - - def expand_group_mentions(to_notify, already_covered_ids, skip) - return [] if skip || visible_groups.empty? - - mentionable_groups = - Group.mentionable(@user, include_public: false).where(id: visible_groups.map(&:id)) - - mentions_disabled = visible_groups - mentionable_groups - - too_many_members, mentionable = - mentionable_groups.partition do |group| - group.user_count > SiteSetting.max_users_notified_per_group_mention - end - - to_notify[:group_mentions_disabled] = mentions_disabled - to_notify[:too_many_members] = too_many_members - - mentionable.each { |g| to_notify[g.name.downcase] = [] } - - reached_by_group = - chat_users - .includes(:groups) - .joins(:groups) - .where(groups: mentionable) - .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.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 - already_covered_ids << user.id - end - - 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(to_notify) - inaccessible = - to_notify.extract!( - :unreachable, - :welcome_to_join, - :too_many_members, - :group_mentions_disabled, - ) - return if inaccessible.values.all?(&:blank?) - - ChatPublisher.publish_inaccessible_mentions( - @user.id, - @chat_message, - inaccessible[:unreachable].to_a, - inaccessible[:welcome_to_join].to_a, - inaccessible[:too_many_members].to_a, - inaccessible[:group_mentions_disabled].to_a, - ) - 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. - def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) - screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) - - return if screen_targets.blank? - - screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) - to_notify - .except(:unreachable, :welcome_to_join) - .each do |key, user_ids| - to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } - end - - # :welcome_to_join contains users because it's serialized by MB. - to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| - screener.ignoring_or_muting_actor?(user.id) - end - - already_covered_ids.reject! do |already_covered| - screener.ignoring_or_muting_actor?(already_covered) - end - 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, - }, - ) - end - - def notify_watching_users(except: []) - Jobs.enqueue( - :chat_notify_watching, - { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, - ) - end -end diff --git a/plugins/chat/lib/chat_review_queue.rb b/plugins/chat/lib/chat_review_queue.rb deleted file mode 100644 index 4b0392e1511..00000000000 --- a/plugins/chat/lib/chat_review_queue.rb +++ /dev/null @@ -1,208 +0,0 @@ -# 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 deleted file mode 100644 index 79d8dc23bda..00000000000 --- a/plugins/chat/lib/chat_seeder.rb +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index ab79fcf1110..00000000000 --- a/plugins/chat/lib/chat_statistics.rb +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index 6326494cdde..00000000000 --- a/plugins/chat/lib/chat_transcript_service.rb +++ /dev/null @@ -1,177 +0,0 @@ -# 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 deleted file mode 100644 index 63342e3cfb7..00000000000 --- a/plugins/chat/lib/direct_message_channel_creator.rb +++ /dev/null @@ -1,126 +0,0 @@ -# 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 - enforce_max_direct_message_users!(acting_user, target_users) - 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.enforce_max_direct_message_users!(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 } - - if !acting_user.staff? && target_users.size > SiteSetting.chat_max_direct_message_users - raise NotAllowed.new( - I18n.t( - "chat.errors.over_chat_max_direct_message_users", - count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting_user - ), - ) - end - end - - 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/public_channel.rb b/plugins/chat/lib/discourse_dev/category_channel.rb similarity index 64% rename from plugins/chat/lib/discourse_dev/public_channel.rb rename to plugins/chat/lib/discourse_dev/category_channel.rb index cb9c672caa9..c6ed4d29b24 100644 --- a/plugins/chat/lib/discourse_dev/public_channel.rb +++ b/plugins/chat/lib/discourse_dev/category_channel.rb @@ -4,9 +4,9 @@ require "discourse_dev/record" require "faker" module DiscourseDev - class PublicChannel < Record + class CategoryChannel < Record def initialize - super(::CategoryChannel, 5) + super(::Chat::CategoryChannel, 5) end def data @@ -22,6 +22,7 @@ module DiscourseDev end def create! + users = [] super do |channel| Faker::Number .between(from: 5, to: 10) @@ -36,7 +37,17 @@ module DiscourseDev admin_user = ::User.find_by(username: admin_username) if admin_username end - Chat::ChatChannelMembershipManager.new(channel).follow(admin_user || User.new.create!) + user = admin_user || User.new(username: Faker::Internet.username(specifier: 10)).create! + Chat::ChannelMembershipManager.new(channel).follow(user) + users << user + end + + Faker::Number + .between(from: 20, to: 80) + .times do + Chat::MessageCreator.create( + { user: users.sample, chat_channel: channel, content: Faker::Lorem.paragraph }, + ) end end end diff --git a/plugins/chat/lib/discourse_dev/direct_channel.rb b/plugins/chat/lib/discourse_dev/direct_channel.rb deleted file mode 100644 index 4ee6e835fe2..00000000000 --- a/plugins/chat/lib/discourse_dev/direct_channel.rb +++ /dev/null @@ -1,31 +0,0 @@ -# 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 index 6cd72225a11..bbc38b9ab6c 100644 --- a/plugins/chat/lib/discourse_dev/message.rb +++ b/plugins/chat/lib/discourse_dev/message.rb @@ -5,26 +5,30 @@ require "faker" module DiscourseDev class Message < Record - def initialize - super(::ChatMessage, 200) + def initialize(channel_id: nil, count: nil, ignore_current_count: false) + @channel_id = channel_id + @ignore_current_count = ignore_current_count + super(::Chat::Message, count&.to_i || 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 + if @channel_id + channel = ::Chat::Channel.find(@channel_id) else - membership = ::UserChatChannelMembership.order("RANDOM()").first - channel = membership.chat_channel - user = membership.user + channel = ::Chat::Channel.where(chatable_type: "Category").order("RANDOM()").first end + return if !channel + + membership = + ::Chat::UserChatChannelMembership.where(chat_channel: channel).order("RANDOM()").first + user = membership.user + { user: user, content: Faker::Lorem.paragraph, chat_channel: channel } end def create! - Chat::ChatMessageCreator.create(data) + Chat::MessageCreator.create(data) end end end diff --git a/plugins/chat/lib/discourse_dev/thread.rb b/plugins/chat/lib/discourse_dev/thread.rb new file mode 100644 index 00000000000..fefa1264c61 --- /dev/null +++ b/plugins/chat/lib/discourse_dev/thread.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class Thread < Record + def initialize(channel_id:, message_count: nil, ignore_current_count: false) + @channel_id = channel_id + @message_count = message_count&.to_i || 30 + @ignore_current_count = ignore_current_count + super(::Chat::Thread, 1) + end + + def data + channel = ::Chat::Channel.find(@channel_id) + return if !channel + + if !channel.threading_enabled + puts "Enabling threads in channel #{channel.id}" + channel.update!(threading_enabled: true) + end + + membership = + ::Chat::UserChatChannelMembership.where(chat_channel: channel).order("RANDOM()").first + user = membership.user + + om = + Chat::MessageCreator.create( + user: user, + content: Faker::Lorem.paragraph, + chat_channel: channel, + ).chat_message + + { original_message_user: user, original_message: om, channel: channel } + end + + def create! + super do |thread| + thread.original_message.update!(thread: thread) + user = + ::Chat::UserChatChannelMembership + .where(chat_channel: thread.channel) + .order("RANDOM()") + .first + .user + @message_count.times do + Chat::MessageCreator.create( + { + user: user, + chat_channel: thread.channel, + content: Faker::Lorem.paragraph, + thread_id: thread.id, + }, + ) + end + end + end + end +end diff --git a/plugins/chat/lib/duplicate_message_validator.rb b/plugins/chat/lib/duplicate_message_validator.rb deleted file mode 100644 index 7b094692ff4..00000000000 --- a/plugins/chat/lib/duplicate_message_validator.rb +++ /dev/null @@ -1,46 +0,0 @@ -# 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/extensions/category_extension.rb b/plugins/chat/lib/extensions/category_extension.rb deleted file mode 100644 index d12ce387645..00000000000 --- a/plugins/chat/lib/extensions/category_extension.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Chat::CategoryExtension - extend ActiveSupport::Concern - - include Chatable - - prepended { has_one :category_channel, as: :chatable, dependent: :destroy } - - 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 deleted file mode 100644 index 6742dccbe37..00000000000 --- a/plugins/chat/lib/extensions/user_email_extension.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index b4c041d4d8b..00000000000 --- a/plugins/chat/lib/extensions/user_extension.rb +++ /dev/null @@ -1,11 +0,0 @@ -# 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 deleted file mode 100644 index a054b108d85..00000000000 --- a/plugins/chat/lib/extensions/user_notifications_extension.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserNotificationsExtension - def chat_summary(user, opts) - guardian = Guardian.new(user) - return unless guardian.can_chat? - - @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_join_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) - all_channels = grouped_messages.keys - grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } - channels = grouped_channels.first - - dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } - dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) - - # Prioritize messages from regular channels over direct messages - if channels.any? - channel_notification_text(channels.sort_by(&:last_message_sent_at), dm_users) - else - direct_message_notification_text(dm_users) - end - end - - private - - def channel_notification_text(channels, dm_users) - total_count = channels.size + dm_users.size - - if total_count > 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_more", - email_prefix: @email_prefix, - channel: channels.first.title, - count: total_count - 1, - ) - elsif channels.size == 1 && dm_users.size == 0 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_1", - email_prefix: @email_prefix, - channel: channels.first.title, - ) - elsif channels.size == 1 && dm_users.size == 1 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_and_direct_message", - email_prefix: @email_prefix, - channel: channels.first.title, - username: dm_users.first.username, - ) - elsif channels.size == 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_2", - email_prefix: @email_prefix, - channel1: channels.first.title, - channel2: channels.second.title, - ) - end - end - - def direct_message_notification_text(dm_users) - case dm_users.size - when 1 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_1", - email_prefix: @email_prefix, - username: dm_users.first.username, - ) - when 2 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_2", - email_prefix: @email_prefix, - username1: dm_users.first.username, - username2: dm_users.second.username, - ) - else - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_more", - email_prefix: @email_prefix, - username: dm_users.first.username, - count: dm_users.size - 1, - ) - end - end -end diff --git a/plugins/chat/lib/extensions/user_option_extension.rb b/plugins/chat/lib/extensions/user_option_extension.rb deleted file mode 100644 index ae2993a216f..00000000000 --- a/plugins/chat/lib/extensions/user_option_extension.rb +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index d539c122548..00000000000 --- a/plugins/chat/lib/guardian_extensions.rb +++ /dev/null @@ -1,188 +0,0 @@ -# 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? - return false if anonymous? - @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_preview_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_join_chat_channel?(chat_channel) - return false if anonymous? - can_preview_chat_channel?(chat_channel) && - (chat_channel.direct_message_channel? || can_post_in_category?(chat_channel.chatable)) - end - - def can_flag_chat_messages? - return false if @user.silenced? - return true if @user.staff? - - @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_join_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 deleted file mode 100644 index fc290f757ca..00000000000 --- a/plugins/chat/lib/message_mover.rb +++ /dev/null @@ -1,175 +0,0 @@ -# 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 index b0bcc8ef5e8..c0fdf1ff9d4 100644 --- a/plugins/chat/lib/onebox/templates/discourse_chat.mustache +++ b/plugins/chat/lib/onebox/templates/discourse_chat.mustache @@ -5,7 +5,7 @@ {{#is_category}} - + {{/is_category}} {{{channel_name}}} @@ -39,10 +39,10 @@ - + {{#is_category}} - + {{/is_category}} {{#is_topic}} diff --git a/plugins/chat/lib/post_notification_handler.rb b/plugins/chat/lib/post_notification_handler.rb deleted file mode 100644 index beefe24ab73..00000000000 --- a/plugins/chat/lib/post_notification_handler.rb +++ /dev/null @@ -1,40 +0,0 @@ -# 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 deleted file mode 100644 index 6fd898f10bc..00000000000 --- a/plugins/chat/lib/secure_uploads_compatibility.rb +++ /dev/null @@ -1,23 +0,0 @@ -# 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/service_runner.rb b/plugins/chat/lib/service_runner.rb new file mode 100644 index 00000000000..78ecff46998 --- /dev/null +++ b/plugins/chat/lib/service_runner.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true +# +# = ServiceRunner +# +# This class is to be used via its helper +with_service+ in any class. Its +# main purpose is to ease how actions can be run upon a service completion. +# Since a service will likely return the same kind of things over and over, +# this allows us to not have to repeat the same boilerplate code in every +# object. +# +# There are several available actions and we can add new ones very easily: +# +# * +on_success+: will execute the provided block if the service succeeds +# * +on_failure+: will execute the provided block if the service fails +# * +on_failed_step(name)+: will execute the provided block if the step named +# `name` fails +# * +on_failed_policy(name)+: will execute the provided block if the policy +# named `name` fails +# * +on_failed_contract(name)+: will execute the provided block if the contract +# named `name` fails +# * +on_model_not_found(name)+: will execute the provided block if the model +# named `name` is not present +# * +on_model_errors(name)+: will execute the provided block if the model named +# `name` contains validation errors +# +# All the specialized steps receive the failing step result object as an +# argument to their block. `on_model_errors` receives the actual model so it’s +# easier to inspect it. +# +# Default actions for each of these are defined in [Chat::ApiController#default_actions_for_service] +# +# @example In a controller +# def create +# with_service MyService do +# on_success do +# flash[:notice] = "Success!" +# redirect_to a_path +# end +# on_failed_policy(:a_named_policy) { |policy| redirect_to root_path, alert: policy.reason } +# on_failure { render :new } +# end +# end +# +# @example In a job (inheriting from +ServiceJob+) +# def execute(args = {}) +# with_service(MyService, **args) do +# on_success { Rails.logger.info "SUCCESS" } +# on_failure { Rails.logger.error "FAILURE" } +# end +# end +# +# The actions will be evaluated in the order they appear. So even if the +# service will ultimately fail with a failed policy, in this example only the +# +on_failed_policy+ action will be executed and not the +on_failure+ one. +# The only exception to this being +on_failure+ as it will always be executed +# last. +# + +class ServiceRunner + # @!visibility private + AVAILABLE_ACTIONS = { + on_success: { + condition: -> { result.success? }, + key: [], + }, + on_failure: { + condition: -> { result.failure? }, + key: [], + }, + on_failed_step: { + condition: ->(name) { failure_for?("result.step.#{name}") }, + key: %w[result step], + }, + on_failed_policy: { + condition: ->(name = "default") { failure_for?("result.policy.#{name}") }, + key: %w[result policy], + default_name: "default", + }, + on_failed_contract: { + condition: ->(name = "default") { failure_for?("result.contract.#{name}") }, + key: %w[result contract], + default_name: "default", + }, + on_model_not_found: { + condition: ->(name = "model") { failure_for?("result.model.#{name}") && result[name].blank? }, + key: %w[result model], + default_name: "model", + }, + on_model_errors: { + condition: ->(name = "model") do + failure_for?("result.model.#{name}") && result["result.model.#{name}"].invalid + end, + key: [], + default_name: "model", + }, + }.with_indifferent_access.freeze + + # @!visibility private + attr_reader :service, :object, :dependencies + + delegate :result, to: :object + + # @!visibility private + def initialize(service, object, **dependencies) + @service = service + @object = object + @dependencies = dependencies + @actions = {} + end + + # @param service [Class] a class including {Service::Base} + # @param block [Proc] a block containing the steps to match on + # @return [void] + def self.call(service, object, **dependencies, &block) + new(service, object, **dependencies).call(&block) + end + + # @!visibility private + def call(&block) + instance_eval(&block) + object.run_service(service, dependencies) + # Always have `on_failure` as the last action + ( + actions + .except(:on_failure) + .merge(actions.slice(:on_failure)) + .detect { |name, (condition, _)| condition.call } || [-> {}] + ).flatten.last.call + end + + private + + attr_reader :actions + + def failure_for?(key) + object.result[key]&.failure? + end + + def add_action(name, *args, &block) + action = AVAILABLE_ACTIONS[name] + actions[[name, *args].join("_").to_sym] = [ + -> { instance_exec(*args, &action[:condition]) }, + -> do + object.instance_exec( + result[[*action[:key], args.first || action[:default_name]].join(".")], + &block + ) + end, + ] + end + + def method_missing(method_name, *args, &block) + return super unless AVAILABLE_ACTIONS[method_name] + add_action(method_name, *args, &block) + end + + def respond_to_missing?(method_name, include_private = false) + AVAILABLE_ACTIONS[method_name] || super + end +end diff --git a/plugins/chat/lib/slack_compatibility.rb b/plugins/chat/lib/slack_compatibility.rb deleted file mode 100644 index 106af32caf3..00000000000 --- a/plugins/chat/lib/slack_compatibility.rb +++ /dev/null @@ -1,60 +0,0 @@ -# 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 index a53e1b319cc..2e91673c900 100644 --- a/plugins/chat/lib/tasks/chat.rake +++ b/plugins/chat/lib/tasks/chat.rake @@ -1,23 +1,26 @@ # 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) + task "chat:message:populate", %i[channel_id count] => ["db:load_config"] do |_, args| + DiscourseDev::Message.populate!( + ignore_current_count: true, + channel_id: args[:channel_id], + count: args[:count], + ) + end + + desc "Generates random channels from categories" + task "chat:category_channel:populate" => ["db:load_config"] do |_, args| + DiscourseDev::CategoryChannel.populate!(ignore_current_count: true) + end + + desc "Creates a thread with sample messages in a channel" + task "chat:thread:populate", %i[channel_id message_count] => ["db:load_config"] do |_, args| + DiscourseDev::Thread.populate!( + ignore_current_count: true, + channel_id: args[:channel_id], + message_count: args[:message_count], + ) end end diff --git a/plugins/chat/lib/tasks/chat_message.rake b/plugins/chat/lib/tasks/chat_message.rake index 603722e4ba4..316033fcd21 100644 --- a/plugins/chat/lib/tasks/chat_message.rake +++ b/plugins/chat/lib/tasks/chat_message.rake @@ -15,7 +15,7 @@ end def rebake_uncooked_chat_messages puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}" - uncooked = ChatMessage.uncooked + uncooked = Chat::Message.uncooked rebaked = 0 total = uncooked.count @@ -100,7 +100,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme raw: "This is some cool first post for archive stuff", ) chat_channel = - ChatChannel.create( + Chat::Channel.create( chatable: topic, chatable_type: "Topic", name: "testing channel for archiving #{SecureRandom.hex(4)}", @@ -112,12 +112,13 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme users = [make_test_user, make_test_user, make_test_user] - ChatChannel.transaction do + Chat::Channel.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 = + Chat::Message.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) cm.cook cm.save! end @@ -125,7 +126,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme puts "message creation done" puts "took #{Time.now - start_time} seconds" - UserChatChannelMembership.create( + Chat::UserChatChannelMembership.create( chat_channel: chat_channel, last_read_message_id: 0, user: User.find_by(username: user_for_membership), diff --git a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb deleted file mode 100644 index bd7bbd4b020..00000000000 --- a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 917663fcfea..00000000000 --- a/plugins/chat/lib/validators/chat_default_channel_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index bcd54905124..00000000000 --- a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 index 8a9127e51e6..16584f9de06 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -9,71 +9,15 @@ 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/common/chat-index.scss" -register_asset "stylesheets/mobile/chat-index.scss", :mobile -register_asset "stylesheets/desktop/chat-index-full-page.scss", :desktop -register_asset "stylesheets/desktop/chat-index-drawer.scss", :desktop -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_asset "stylesheets/common/chat-mention-warnings.scss" -register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss" +register_asset "stylesheets/mixins/index.scss" +register_asset "stylesheets/common/index.scss" +register_asset "stylesheets/desktop/index.scss", :desktop +register_asset "stylesheets/mobile/index.scss", :mobile 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" @@ -81,160 +25,18 @@ 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) +module ::Chat + PLUGIN_NAME = "chat" +end + +require_relative "lib/chat/engine" +require_relative "lib/chat/types/array" + 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/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/base_chat_channel_membership_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_channel_hashtag_data_source.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/regular/delete_user_messages.rb", __FILE__) - load File.expand_path("../app/jobs/regular/send_message_notifications.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/jobs/scheduled/chat_periodical_updates.rb", __FILE__) - load File.expand_path("../app/services/chat_publisher.rb", __FILE__) - load File.expand_path("../app/services/chat_message_destroyer.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_current_user_channels_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_membership_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_memberships_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_messages_moves_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_archives_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channels_status_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_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) @@ -244,8 +46,9 @@ after_initialize do UserUpdater::OPTION_ATTR.push(:chat_sound) UserUpdater::OPTION_ATTR.push(:ignore_channel_wide_mention) UserUpdater::OPTION_ATTR.push(:chat_email_frequency) + UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference) - register_reviewable_type ReviewableChatMessage + register_reviewable_type Chat::ReviewableMessage reloadable_patch do |plugin| ReviewableScore.add_new_types([:needs_review]) @@ -256,30 +59,25 @@ after_initialize do UserNotifications.prepend Chat::UserNotificationsExtension UserOption.prepend Chat::UserOptionExtension Category.prepend Chat::CategoryExtension + Reviewable.prepend Chat::ReviewableExtension + Bookmark.prepend Chat::BookmarkExtension User.prepend Chat::UserExtension Jobs::UserEmail.prepend Chat::UserEmailExtension - Bookmark.register_bookmarkable(ChatMessageBookmarkable) + Plugin::Instance.prepend Chat::PluginInstanceExtension + Jobs::ExportCsvFile.class_eval { prepend Chat::MessagesExporter } 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) + if route[:message_id].present? + message = Chat::Message.find_by(id: route[:message_id]) 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]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel end @@ -332,16 +130,8 @@ after_initialize do 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) + if route[:message_id].present? + message = Chat::Message.find_by(id: route[:message_id]) next if !message chat_channel = message.chat_channel @@ -356,7 +146,7 @@ after_initialize do username: user.username, ) else - chat_channel = ChatChannel.find_by(id: route[:channel_id]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel title = @@ -371,22 +161,14 @@ after_initialize do 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( + Chat::Message.where( "message LIKE ? OR message LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", ).exists? || - ChatDraft.where( + Chat::Draft.where( "data LIKE ? OR data LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", @@ -401,34 +183,51 @@ after_initialize do scope.user.id != object.id && scope.can_chat? && Guardian.new(object).can_chat? end - add_to_serializer(:current_user, :can_chat) { true } + add_to_serializer( + :current_user, + :can_chat, + include_condition: -> do + return @can_chat if defined?(@can_chat) + @can_chat = SiteSetting.chat_enabled && scope.can_chat? + end, + ) { true } - add_to_serializer(:current_user, :include_can_chat?) do - return @can_chat if defined?(@can_chat) + add_to_serializer( + :current_user, + :has_chat_enabled, + include_condition: -> do + return @has_chat_enabled if defined?(@has_chat_enabled) + @has_chat_enabled = include_can_chat? && object.user_option.chat_enabled + end, + ) { true } - @can_chat = SiteSetting.chat_enabled && scope.can_chat? - end + add_to_serializer( + :current_user, + :chat_sound, + include_condition: -> { include_has_chat_enabled? && object.user_option.chat_sound }, + ) { object.user_option.chat_sound } - add_to_serializer(:current_user, :has_chat_enabled) { true } + add_to_serializer( + :current_user, + :needs_channel_retention_reminder, + include_condition: -> do + include_has_chat_enabled? && object.staff? && + !object.user_option.dismissed_channel_retention_reminder && + !SiteSetting.chat_channel_retention_days.zero? + end, + ) { 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, + :needs_dm_retention_reminder, + include_condition: -> do + include_has_chat_enabled? && !object.user_option.dismissed_dm_retention_reminder && + !SiteSetting.chat_dm_retention_days.zero? + end, + ) { true } add_to_serializer(:current_user, :has_joinable_public_channels) do - Chat::ChatChannelFetcher.secured_public_channel_search( + Chat::ChannelFetcher.secured_public_channel_search( self.scope, following: false, limit: 1, @@ -437,23 +236,34 @@ after_initialize do 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 + structured = Chat::ChannelFetcher.structured(self.scope) + + structured[:unread_thread_overview] = ::Chat::TrackingStateReportQuery.call( + guardian: self.scope, + channel_ids: structured[:public_channels].map(&:id), + include_threads: true, + include_read: false, + include_last_reply_details: true, + ).thread_unread_overview_by_channel + + category_ids = structured[:public_channels].map { |c| c.chatable_id } + post_allowed_category_ids = + Category.post_create_allowed(self.scope).where(id: category_ids).pluck(:id) + + Chat::ChannelIndexSerializer.new( + structured, + scope: self.scope, + root: false, + post_allowed_category_ids: post_allowed_category_ids, + ).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 + add_to_serializer( + :current_user, + :chat_drafts, + include_condition: -> { include_has_chat_enabled? }, + ) do + Chat::Draft .where(user_id: object.id) .order(updated_at: :desc) .limit(20) @@ -461,13 +271,13 @@ after_initialize do .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, + :chat_sound, + include_condition: -> { !object.chat_sound.blank? }, + ) { object.chat_sound } add_to_serializer(:user_option, :only_chat_push_notifications) do object.only_chat_push_notifications @@ -479,6 +289,14 @@ after_initialize do add_to_serializer(:user_option, :chat_email_frequency) { object.chat_email_frequency } + add_to_serializer(:user_option, :chat_header_indicator_preference) do + object.chat_header_indicator_preference + end + + add_to_serializer(:current_user_option, :chat_header_indicator_preference) do + object.chat_header_indicator_preference + end + RETENTION_SETTINGS_TO_USER_OPTION_FIELDS = { chat_channel_retention_days: :dismissed_channel_retention_reminder, chat_dm_retention_days: :dismissed_dm_retention_reminder, @@ -498,6 +316,13 @@ after_initialize do if name == :secure_uploads && old_value == false && new_value == true Chat::SecureUploadsCompatibility.update_settings end + + if name == :chat_allowed_groups + Jobs.enqueue( + Jobs::Chat::AutoRemoveMembershipHandleChatAllowedGroupsChange, + new_allowed_groups: new_value, + ) + end end on(:post_alerter_after_save_post) do |post, new_record, notified| @@ -505,6 +330,13 @@ after_initialize do Chat::PostNotificationHandler.new(post, notified).handle end + on(:group_destroyed) do |group, user_ids| + Jobs.enqueue( + Jobs::Chat::AutoRemoveMembershipHandleDestroyedGroup, + destroyed_group_user_ids: user_ids, + ) + end + register_presence_channel_prefix("chat") do |channel_name| next nil unless channel_name == "/chat/online" config = PresenceChannel::Config.new @@ -514,7 +346,7 @@ after_initialize do 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) + chat_channel = Chat::Channel.find(chat_channel_id) PresenceChannel::Config.new.tap do |config| config.allowed_group_ids = chat_channel.allowed_group_ids @@ -548,27 +380,27 @@ after_initialize do on(:user_seen) do |user| if user.last_seen_at == user.first_seen_at - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_confirmed_email) do |user| if user.active? - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_added_to_group) do |user, group| channels_to_add = - ChatChannel + Chat::Channel .distinct .where(auto_join_users: true, chatable_type: "Category") .joins( @@ -577,106 +409,38 @@ after_initialize do .where(category_groups: { group_id: group.id }) channels_to_add.each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end + on(:user_removed_from_group) do |user, group| + Jobs.enqueue(Jobs::Chat::AutoRemoveMembershipHandleUserRemovedFromGroup, user_id: user.id) + 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.is_a?(Category) && category_channel = Chat::Channel.find_by(chatable: category) + if category_channel.auto_join_users + Chat::ChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships + end - if category_channel - Chat::ChatChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships + Jobs.enqueue(Jobs::Chat::AutoRemoveMembershipHandleCategoryUpdated, category_id: category.id) end end - Chat::Engine.routes.draw do - namespace :api, defaults: { format: :json } do - get "/chatables" => "chat_chatables#index" - get "/channels" => "chat_channels#index" - get "/channels/me" => "chat_current_user_channels#index" - post "/channels" => "chat_channels#create" - delete "/channels/:channel_id" => "chat_channels#destroy" - put "/channels/:channel_id" => "chat_channels#update" - get "/channels/:channel_id" => "chat_channels#show" - put "/channels/:channel_id/status" => "chat_channels_status#update" - post "/channels/:channel_id/messages/moves" => "chat_channels_messages_moves#create" - post "/channels/:channel_id/archives" => "chat_channels_archives#create" - get "/channels/:channel_id/memberships" => "chat_channels_memberships#index" - delete "/channels/:channel_id/memberships/me" => - "chat_channels_current_user_membership#destroy" - post "/channels/:channel_id/memberships/me" => "chat_channels_current_user_membership#create" - put "/channels/:channel_id/notifications-settings/me" => - "chat_channels_current_user_notifications_settings#update" - - # Category chatables controller hints. 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 - - # Hints for JIT warnings. - get "/mentions/groups" => "hints#check_group_mentions", :format => :json - 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_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/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", + + get "/admin/plugins/chat" => "chat/admin/incoming_webhooks#index", :constraints => StaffConstraint.new - post "/admin/plugins/chat/hooks" => "chat/admin_incoming_chat_webhooks#create", + post "/admin/plugins/chat/hooks" => "chat/admin/incoming_webhooks#create", :constraints => StaffConstraint.new put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#update", + "chat/admin/incoming_webhooks#update", :constraints => StaffConstraint.new delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#destroy", + "chat/admin/incoming_webhooks#destroy", :constraints => StaffConstraint.new get "u/:username/preferences/chat" => "users#preferences", :constraints => { @@ -696,12 +460,12 @@ after_initialize do 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")) + channel = Chat::Channel.find_by(id: fields.dig("chat_channel_id", "value")) placeholders = { channel_name: channel.title(sender) }.merge(context["placeholders"] || {}) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: channel, user: sender, content: utils.apply_placeholders(fields.dig("message", "value"), placeholders), @@ -725,11 +489,7 @@ after_initialize do 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_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) register_about_stat_group("chat_messages", show_in_ui: true) { Chat::Statistics.about_messages } @@ -738,21 +498,23 @@ after_initialize do register_about_stat_group("chat_users") { Chat::Statistics.about_users } # Make sure to update spec/system/hashtag_autocomplete_spec.rb when changing this. - register_hashtag_data_source(Chat::ChatChannelHashtagDataSource) + register_hashtag_data_source(Chat::ChannelHashtagDataSource) register_hashtag_type_priority_for_context("channel", "chat-composer", 200) register_hashtag_type_priority_for_context("category", "chat-composer", 100) register_hashtag_type_priority_for_context("tag", "chat-composer", 50) register_hashtag_type_priority_for_context("channel", "topic-composer", 10) Site.markdown_additional_options["chat"] = { - limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES, - limited_pretty_text_markdown_rules: ChatMessage::MARKDOWN_IT_RULES, + limited_pretty_text_features: Chat::Message::MARKDOWN_FEATURES, + limited_pretty_text_markdown_rules: Chat::Message::MARKDOWN_IT_RULES, hashtag_configurations: HashtagAutocompleteService.contexts_with_ordered_types, } register_user_destroyer_on_content_deletion_callback( - Proc.new { |user| Jobs.enqueue(:delete_user_messages, user_id: user.id) }, + Proc.new { |user| Jobs.enqueue(Jobs::Chat::DeleteUserMessages, user_id: user.id) }, ) + + register_bookmarkable(Chat::MessageBookmarkable) end if Rails.env == "test" diff --git a/plugins/chat/spec/components/chat_mailer_spec.rb b/plugins/chat/spec/components/chat/mailer_spec.rb similarity index 95% rename from plugins/chat/spec/components/chat_mailer_spec.rb rename to plugins/chat/spec/components/chat/mailer_spec.rb index 20229526a89..dec8390e20a 100644 --- a/plugins/chat/spec/components/chat_mailer_spec.rb +++ b/plugins/chat/spec/components/chat/mailer_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMailer do +describe Chat::Mailer 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) } @@ -18,7 +18,13 @@ describe Chat::ChatMailer do end fab!(:private_chat_channel) do Group.refresh_automatic_groups! - Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user_1]) + result = + Chat::CreateDirectMessageChannel.call( + guardian: sender.guardian, + target_usernames: [sender.username, user_1.username], + ) + service_failed!(result) if result.failure? + result.channel end before do @@ -170,13 +176,12 @@ describe Chat::ChatMailer do 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, - ) + 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) @@ -266,7 +271,7 @@ describe Chat::ChatMailer do assert_only_queued_once end - it "Doesn't mix or update mentions from other users when joining tables" do + 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( 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 00000000000..3da123f44f0 --- /dev/null +++ b/plugins/chat/spec/components/chat/message_creator_spec.rb @@ -0,0 +1,1246 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::MessageCreator 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 + result = + Chat::CreateDirectMessageChannel.call( + guardian: user1.guardian, + target_usernames: [user1.username, user2.username], + ) + service_failed!(result) if result.failure? + result.channel + 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 = + described_class.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", + { count: SiteSetting.chat_minimum_message_length }, + ), + ) + end + + it "errors when a blank message is sent" do + creator = + described_class.create(chat_channel: public_chat_channel, user: user1, content: " ") + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t( + "chat.errors.minimum_length_not_met", + { count: SiteSetting.chat_minimum_message_length }, + ), + ) + end + + it "errors when length is greater than `chat_maximum_message_length`" do + SiteSetting.chat_maximum_message_length = 100 + creator = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "a really long and in depth message that is just too detailed" * 100, + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t("chat.errors.message_too_long", { count: SiteSetting.chat_maximum_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 { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "2 short", + upload_ids: [upload.id], + ) + }.to change { Chat::Message.count }.by(1) + end + + it "creates messages for users who can see the channel" do + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ) + }.to change { Chat::Message.count }.by(1) + end + + it "updates the last_message for the channel" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ).chat_message + expect(public_chat_channel.reload.last_message).to eq(message) + end + + it "sets the last_editor_id to the user who created the message" do + message = + described_class.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 do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ) + end + expect(events.map { _1[:event_name] }).to include(:chat_message_created) + end + + it "publishes created message to message bus" do + content = "a test chat message" + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.create(chat_channel: public_chat_channel, user: user1, content: content) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["message"]).to eq(content) + expect(message["chat_message"]["user"]["id"]).to eq(user1.id) + end + + context "with mentions" do + it "creates mentions and mention notifications for public chat" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: + "this is a @#{user1.username} message with @system @mentions @#{user2.username} and @#{user3.username}", + ).chat_message + + # a mention for the user himself was created, but a notification wasn't + user1_mention = user1.chat_mentions.where(chat_message: message).first + expect(user1_mention).to be_present + expect(user1_mention.notification).to be_nil + + system_user_mention = Discourse.system_user.chat_mentions.where(chat_message: message).first + expect(system_user_mention).to be_nil + + user2_mention = user2.chat_mentions.where(chat_message: message).first + expect(user2_mention).to be_present + expect(user2_mention.notification).to be_present + + user3_mention = user3.chat_mentions.where(chat_message: message).first + expect(user3_mention).to be_present + expect(user3_mention.notification).to be_present + end + + it "mentions are case insensitive" do + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hey @#{user2.username.upcase}", + ) + }.to change { user2.chat_mentions.count }.by(1) + end + + context "when mentioning @all" do + it "creates a chat mention record for every user" do + expect { + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@all") + }.to change { Chat::Mention.count }.by(5) + + Chat::UserChatChannelMembership.where( + user: user2, + chat_channel: public_chat_channel, + ).update_all(following: false) + + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "again! @all", + ) + }.to change { Chat::Mention.count }.by(4) + end + + it "does not create a notification for acting user" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "@all", + ).chat_message + + mention = user1.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_blank + end + end + + context "when mentioning @here" do + it "creates a chat mention record for every active user" 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 { + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@here") + }.to change { Chat::Mention.count }.by(3) + end + + it "does not create a notification for acting user" do + user1.update(last_seen_at: Time.now) + + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ).chat_message + + mention = user1.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_blank + end + + it "doesn't send double notifications" do + user2.update(last_seen_at: Time.now) + expect { + described_class.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 { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here plus @#{user3.username}", + ) + }.to change { user3.chat_mentions.count }.by(1) + end + end + + context "with group mentions" do + it "creates chat mentions for group mentions where the group is mentionable" do + expect { + described_class.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 { + described_class.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 { + described_class.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 { + described_class.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 { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name}", + ) + }.not_to change { Chat::Mention.count } + end + + it "creates a chat mention without notification for acting user" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "@#{user_group.name}", + ).chat_message + + mention = user1.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_blank + end + end + + context "when ignore_channel_wide_mention is enabled" do + before { user2.user_option.update(ignore_channel_wide_mention: true) } + + it "when mentioning @all creates a mention without notification" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi! @all", + ).chat_message + + mention = user2.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_nil + end + + it "when mentioning @here creates a mention without notification" do + user2.update(last_seen_at: Time.now) + + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ).chat_message + + mention = user2.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_nil + end + end + + it "doesn't create mention notifications for users without a membership record" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{user_without_memberships.username}", + ).chat_message + + mention = user_without_memberships.chat_mentions.where(chat_message: message).first + expect(mention.notification).to be_nil + end + + it "doesn't create mention notifications for users who cannot chat" do + new_group = Group.create + SiteSetting.chat_allowed_groups = new_group.id + + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username} @#{user3.username}", + ).chat_message + + user2_mention = user2.chat_mentions.where(chat_message: message).first + expect(user2_mention.notification).to be_nil + + user3_mention = user2.chat_mentions.where(chat_message: message).first + expect(user3_mention.notification).to be_nil + end + + it "doesn't create mentions for users with chat disabled" do + user2.user_option.update(chat_enabled: false) + + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username}", + ).chat_message + + mention = user2.chat_mentions.where(chat_message: message).first + expect(mention).to be_nil + end + + it "creates only mention notifications for users with access in private chat" do + message = + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello there @#{user2.username} and @#{user3.username}", + ).chat_message + + # Only user2 should be notified + user2_mention = user2.chat_mentions.where(chat_message: message).first + expect(user2_mention.notification).to be_present + + user3_mention = user3.chat_mentions.where(chat_message: message).first + expect(user3_mention.notification).to be_nil + end + + it "creates a mention for group users even if they're not participating in private chat" do + expect { + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello there @#{user_group.name}", + ) + }.to change { user2.chat_mentions.count }.by(1).and change { user3.chat_mentions.count }.by( + 1, + ) + end + + it "creates a mention notifications only for group users that are participating in private chat" do + message = + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello there @#{user_group.name}", + ).chat_message + + user2_mention = user2.chat_mentions.where(chat_message: message).first + expect(user2_mention.notification).to be_present + + user3_mention = user3.chat_mentions.where(chat_message: message).first + expect(user3_mention.notification).to be_nil + end + + it "publishes inaccessible mentions when user isn't aren't a part of the channel" do + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.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] + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.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 + Chat::Publisher.expects(:publish_inaccessible_mentions).never + described_class.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{admin2.username}", + ) + end + + it "creates mentions for suspended users" do + user2.update(suspended_till: Time.now + 10.years) + + message = + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello @#{user2.username}", + ).chat_message + + mention = user2.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + end + + it "does not create mention notifications for suspended users" do + user2.update(suspended_till: Time.now + 10.years) + + message = + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello @#{user2.username}", + ).chat_message + + mention = user2.chat_mentions.where(chat_message: message).first + expect(mention.notification).to be_nil + end + + it "adds mentioned user and their status to the message bus message" do + SiteSetting.enable_user_status = true + status = { description: "dentist", emoji: "tooth" } + user2.set_status!(status[:description], status[:emoji]) + + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hey @#{user2.username}", + ) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["mentioned_users"].count).to be(1) + mentioned_user = message["chat_message"]["mentioned_users"][0] + + expect(mentioned_user["id"]).to eq(user2.id) + expect(mentioned_user["username"]).to eq(user2.username) + expect(mentioned_user["status"]).to be_present + expect(mentioned_user["status"].slice(:description, :emoji)).to eq(status) + end + + it "doesn't add mentioned user's status to the message bus message when status is disabled" do + SiteSetting.enable_user_status = false + user2.set_status!("dentist", "tooth") + + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hey @#{user2.username}", + ) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["mentioned_users"].count).to be(1) + mentioned_user = message["chat_message"]["mentioned_users"][0] + + expect(mentioned_user["status"]).to be_blank + end + + it "creates a chat_mention record without notification when self mentioning" do + message = + described_class.create( + chat_channel: direct_message_channel, + user: user1, + content: "hello @#{user1.username}", + ).chat_message + + mention = user1.chat_mentions.where(chat_message: message).first + expect(mention).to be_present + expect(mention.notification).to be_nil + end + end + + describe "replies" do + fab!(:reply_message) do + Fabricate(:chat_message, chat_channel: public_chat_channel, user: user2) + end + fab!(:unrelated_message_1) { Fabricate(:chat_message, chat_channel: public_chat_channel) } + fab!(:unrelated_message_2) { Fabricate(:chat_message, chat_channel: public_chat_channel) } + + it "links the message that the user is replying to" do + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + + expect(message.in_reply_to_id).to eq(reply_message.id) + end + + it "creates a thread and includes the original message and the reply" do + message = nil + expect { + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + }.to change { Chat::Thread.count }.by(1) + + expect(message.reload.thread).not_to eq(nil) + expect(message.in_reply_to.thread).to eq(message.thread) + expect(message.thread.original_message).to eq(reply_message) + expect(message.thread.original_message_user).to eq(reply_message.user) + expect(message.thread.last_message).to eq(message) + end + + it "does not change the last_message of the channel for a thread reply" do + original_last_message = public_chat_channel.last_message + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ) + expect(public_chat_channel.reload.last_message).to eq(original_last_message) + end + + it "creates a user thread membership" do + message = nil + expect { + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + }.to change { Chat::UserChatThreadMembership.count }.by(2) + + expect( + Chat::UserChatThreadMembership.exists?(user: user1, thread: message.thread), + ).to be_truthy + end + + it "creates a thread membership for the original message user" do + message = nil + expect { + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + }.to change { Chat::UserChatThreadMembership.count }.by(2) + + expect( + Chat::UserChatThreadMembership.exists?(user: reply_message.user, thread: message.thread), + ).to be_truthy + end + + context "when threading is enabled" do + it "publishes the new thread" do + public_chat_channel.update!(threading_enabled: true) + + messages = + MessageBus.track_publish do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + end + + thread_created_message = messages.find { |m| m.data["type"] == "thread_created" } + expect(thread_created_message.channel).to eq("/chat/#{public_chat_channel.id}") + end + end + + context "when threading is disabled" do + it "doesn’t publish the new thread" do + public_chat_channel.update!(threading_enabled: false) + + messages = + MessageBus.track_publish do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + end + + thread_created_message = messages.find { |m| m.data["type"] == "thread_created" } + expect(thread_created_message).to be_nil + end + end + + context "when a staged_thread_id is provided" do + fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) } + + it "creates a thread and publishes with the staged id" do + public_chat_channel.update!(threading_enabled: true) + + messages = + MessageBus.track_publish do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + staged_thread_id: "stagedthreadid", + ).chat_message + end + + thread_event = messages.find { |m| m.data["type"] == "thread_created" } + expect(thread_event.data["staged_thread_id"]).to eq("stagedthreadid") + expect(Chat::Thread.find(thread_event.data["thread_id"])).to be_persisted + + send_event = messages.find { |m| m.data["type"] == "sent" } + expect(send_event.data["staged_thread_id"]).to eq("stagedthreadid") + end + end + + context "when the thread_id is provided" do + fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) } + + it "does not create a thread when one is passed in" do + message = nil + expect { + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + thread_id: existing_thread.id, + ).chat_message + }.not_to change { Chat::Thread.count } + + expect(message.reload.thread).to eq(existing_thread) + expect(existing_thread.reload.last_message).to eq(message) + end + + it "creates a user thread membership if one does not exist" do + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + thread_id: existing_thread.id, + ).chat_message + }.to change { Chat::UserChatThreadMembership.count } + + expect( + Chat::UserChatThreadMembership.exists?(user: user1, thread: existing_thread), + ).to be_truthy + end + + it "does not create a thread membership if one exists" do + Fabricate(:user_chat_thread_membership, user: user1, thread: existing_thread) + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + thread_id: existing_thread.id, + ).chat_message + }.not_to change { Chat::UserChatThreadMembership.count } + end + + it "sets the last_read_message_id of the existing UserChatThreadMembership for the user to the new message id" do + message = Fabricate(:chat_message, thread: existing_thread) + membership = + Fabricate( + :user_chat_thread_membership, + user: user1, + thread: existing_thread, + last_read_message_id: message.id, + ) + new_message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + thread_id: existing_thread.id, + ).chat_message + + expect(membership.reload.last_read_message_id).to eq(new_message.id) + end + + it "errors when the thread ID is for a different channel" do + other_channel_thread = Fabricate(:chat_thread, channel: Fabricate(:chat_channel)) + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + thread_id: other_channel_thread.id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.thread_invalid_for_channel")) + end + + it "errors when the thread does not match the in_reply_to thread" do + reply_message.update!(thread: existing_thread) + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + thread_id: Fabricate(:chat_thread, channel: public_chat_channel).id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent")) + end + + it "errors when the root message does not have a thread ID" do + reply_message.update!(thread: nil) + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + thread_id: existing_thread.id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent")) + end + end + + context "for missing root messages" do + fab!(:original_message) do + Fabricate( + :chat_message, + chat_channel: public_chat_channel, + user: user2, + created_at: 1.day.ago, + ) + end + + before { reply_message.update!(in_reply_to: original_message) } + + it "raises an error when the root message has been trashed" do + original_message.trash! + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.original_message_not_found")) + end + + it "uses the next message in the chain as the root when the root is deleted" do + original_message.destroy! + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ) + expect(reply_message.reload.thread).not_to eq(nil) + end + end + + context "when there is an existing reply chain" do + fab!(:old_message_1) do + Fabricate( + :chat_message, + chat_channel: public_chat_channel, + user: user1, + created_at: 6.hours.ago, + ) + end + fab!(:old_message_2) do + Fabricate( + :chat_message, + chat_channel: public_chat_channel, + user: user2, + in_reply_to: old_message_1, + created_at: 4.hours.ago, + ) + end + fab!(:old_message_3) do + Fabricate( + :chat_message, + chat_channel: public_chat_channel, + user: user1, + in_reply_to: old_message_2, + created_at: 1.hour.ago, + ) + end + + before do + reply_message.update!( + created_at: old_message_3.created_at + 1.hour, + in_reply_to: old_message_3, + ) + end + + it "creates a thread and updates all the messages in the chain" do + # This must be done since the fabricator uses Chat::MessageCreator + # under the hood and it creates the thread already. + old_message_1.update!(thread_id: nil) + old_message_2.update!(thread_id: nil) + old_message_3.update!(thread_id: nil) + reply_message.update!(thread_id: nil) + + thread_count = Chat::Thread.count + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + + expect(Chat::Thread.count).to eq(thread_count + 1) + expect(message.reload.thread).not_to eq(nil) + expect(message.reload.in_reply_to.thread).to eq(message.thread) + expect(old_message_1.reload.thread).to eq(message.thread) + expect(old_message_2.reload.thread).to eq(message.thread) + expect(old_message_3.reload.thread).to eq(message.thread) + expect(message.thread.chat_messages.count).to eq(5) + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + end + + context "when a thread already exists and the thread_id is passed in" do + let!(:last_message) do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + end + let!(:existing_thread) { last_message.reload.thread } + + it "does not create a new thread" do + thread_count = Chat::Thread.count + + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message again", + in_reply_to_id: last_message.id, + thread_id: existing_thread.id, + ).chat_message + + expect(Chat::Thread.count).to eq(thread_count) + expect(message.reload.thread).to eq(existing_thread) + expect(message.reload.in_reply_to.thread).to eq(existing_thread) + expect(message.thread.chat_messages.count).to eq(6) + end + + it "errors when the thread does not match the root thread" do + old_message_1.update!(thread: Fabricate(:chat_thread, channel: public_chat_channel)) + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + thread_id: existing_thread.id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent")) + end + + it "errors when the root message does not have a thread ID" do + old_message_1.update!(thread: nil) + result = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + thread_id: existing_thread.id, + ) + expect(result.error.message).to eq(I18n.t("chat.errors.thread_does_not_match_parent")) + end + end + + context "when there are hundreds of messages in a reply chain already" do + before do + previous_message = nil + 1000.times do |i| + previous_message = + Fabricate( + :chat_message, + chat_channel: public_chat_channel, + user: [user1, user2].sample, + in_reply_to: previous_message, + created_at: i.hours.ago, + ) + end + @last_message_in_chain = previous_message + end + + xit "works" do + thread_count = Chat::Thread.count + + message = nil + puts Benchmark.measure { + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: @last_message_in_chain.id, + ).chat_message + } + + expect(Chat::Thread.count).to eq(thread_count + 1) + expect(message.reload.thread).not_to eq(nil) + expect(message.reload.in_reply_to.thread).to eq(message.thread) + expect(message.thread.chat_messages.count).to eq(1001) + end + end + + context "if the root message already had a thread" do + fab!(:old_thread) { Fabricate(:chat_thread, original_message: old_message_1) } + fab!(:incorrect_thread) { Fabricate(:chat_thread, channel: public_chat_channel) } + + before do + old_message_1.update!(thread: old_thread) + old_message_2.update!(thread: old_thread) + old_message_3.update!(thread: incorrect_thread) + end + + it "does not change any messages in the chain, assumes they have the correct thread ID" do + thread_count = Chat::Thread.count + message = + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + in_reply_to_id: reply_message.id, + ).chat_message + + expect(Chat::Thread.count).to eq(thread_count) + expect(message.reload.thread).to eq(old_thread) + expect(message.reload.in_reply_to.thread).to eq(old_thread) + expect(old_message_1.reload.thread).to eq(old_thread) + expect(old_message_2.reload.thread).to eq(old_thread) + expect(old_message_3.reload.thread).to eq(incorrect_thread) + expect(message.thread.chat_messages.count).to eq(4) + end + end + end + end + + describe "push notifications" do + before do + Chat::UserChatChannelMembership.where( + user: user1, + chat_channel: public_chat_channel, + ).update( + mobile_notification_level: Chat::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 + described_class.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 + described_class.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 { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.to change { UploadReference.where(upload_id: upload1.id).count }.by(1) + end + + it "can attach multiple uploads to a new message" do + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id, upload2.id], + ) + }.to change { UploadReference.where(upload_id: [upload1.id, upload2.id]).count }.by(2) + end + + it "filters out uploads that weren't uploaded by the user" do + expect { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [private_upload.id], + ) + }.not_to change { UploadReference.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 { + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.to not_change { UploadReference.where(upload_id: upload1.id).count } + end + end + end + + it "destroys draft after message was created" do + Chat::Draft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") + + expect do + described_class.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hi @#{user2.username}", + ) + end.to change { Chat::Draft.count }.by(-1) + end + + describe "watched words" do + fab!(:watched_word) { Fabricate(:watched_word) } + + it "errors when a blocked word is present" do + creator = + described_class.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) + described_class.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.closed"), + ) + end + + it "does not error when trying to create a message for staff" do + expect { create_message(admin1) }.to change { Chat::Message.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.read_only"), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t("chat.errors.channel_new_message_disallowed.read_only"), + ) + 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.archived"), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t("chat.errors.channel_new_message_disallowed.archived"), + ) + 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 similarity index 98% rename from plugins/chat/spec/components/chat_message_rate_limiter_spec.rb rename to plugins/chat/spec/components/chat/message_rate_limiter_spec.rb index fa73e927819..50d394ffa33 100644 --- a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb +++ b/plugins/chat/spec/components/chat/message_rate_limiter_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageRateLimiter do +describe Chat::MessageRateLimiter do fab!(:user) { Fabricate(:user, trust_level: 3) } let(:limiter) { described_class.new(user) } diff --git a/plugins/chat/spec/components/chat_message_updater_spec.rb b/plugins/chat/spec/components/chat/message_updater_spec.rb similarity index 52% rename from plugins/chat/spec/components/chat_message_updater_spec.rb rename to plugins/chat/spec/components/chat/message_updater_spec.rb index f32f979faaf..dd611f72d46 100644 --- a/plugins/chat/spec/components/chat_message_updater_spec.rb +++ b/plugins/chat/spec/components/chat/message_updater_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageUpdater do +describe Chat::MessageUpdater do let(:guardian) { Guardian.new(user1) } fab!(:admin1) { Fabricate(:admin) } fab!(:admin2) { Fabricate(:admin) } @@ -30,13 +30,11 @@ describe Chat::ChatMessageUpdater do 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::MessageCreator.create( chat_channel: channel, user: user, in_reply_to_id: nil, @@ -53,7 +51,7 @@ describe Chat::ChatMessageUpdater do new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -62,7 +60,7 @@ describe Chat::ChatMessageUpdater do expect(updater.error.message).to match( I18n.t( "chat.errors.minimum_length_not_met", - { minimum: SiteSetting.chat_minimum_message_length }, + { count: SiteSetting.chat_minimum_message_length }, ), ) expect(chat_message.reload.message).to eq(og_message) @@ -75,14 +73,35 @@ describe Chat::ChatMessageUpdater do new_message = "2 long" * 100 updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.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.message_too_long", { maximum: SiteSetting.chat_maximum_message_length }), + I18n.t("chat.errors.message_too_long", { count: SiteSetting.chat_maximum_message_length }), + ) + expect(chat_message.reload.message).to eq(og_message) + end + + it "errors when a blank message is sent" do + og_message = "This won't be changed!" + chat_message = create_chat_message(user1, og_message, public_chat_channel) + new_message = " " + + updater = + Chat::MessageUpdater.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", + { count: SiteSetting.chat_minimum_message_length }, + ), ) expect(chat_message.reload.message).to eq(og_message) end @@ -92,7 +111,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, og_message, public_chat_channel) new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(Fabricate(:user)), chat_message: chat_message, new_content: new_message, @@ -105,7 +124,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) new_message = "Change to this!" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -117,7 +136,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) events = DiscourseEvent.track_events do - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "Change to this!", @@ -126,118 +145,255 @@ describe Chat::ChatMessageUpdater do expect(events.map { _1[:event_name] }).to include(:chat_message_edited) end - it "creates mention notifications for unmentioned users" do + it "publishes updated message to message bus" 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) + new_content = "New content" + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_content, + ) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["message"]).to eq(new_content) 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( + context "with mentions" do + it "sends notifications if a message was updated with new mentions" do + message = create_chat_message(user1, "Mentioning @#{user2.username}", public_chat_channel) + + Chat::MessageUpdater.update( guardian: guardian, - chat_message: chat_message, - new_content: message + " editedddd", + chat_message: message, + new_content: "Mentioning @#{user2.username} and @#{user3.username}", ) - }.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) + mention = user3.chat_mentions.where(chat_message: message).first + expect(mention.notification).to be_present + end - expect { - Chat::ChatMessageUpdater.update( + 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::MessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: message + " editedddd", + ) + }.not_to change { Chat::Mention.count } + end + + it "doesn't create mention notification for users without access" do + message = "ping" + chat_message = create_chat_message(user1, message, public_chat_channel) + + Chat::MessageUpdater.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( + mention = user_without_memberships.chat_mentions.where(chat_message: chat_message).first + expect(mention.notification).to be_nil + end + + it "destroys mentions that should be removed" do + chat_message = + create_chat_message( + user1, + "ping @#{user2.username} @#{user3.username}", + public_chat_channel, + ) + expect { + Chat::MessageUpdater.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::MessageUpdater.update( guardian: guardian, chat_message: chat_message, - new_content: "ping @#{user3.username}", + new_content: "ping @#{user3.username} @#{user4.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 - 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 "doesn't create mention notification in direct message for users without access" do + result = + Chat::CreateDirectMessageChannel.call( + guardian: user1.guardian, + target_usernames: [user1.username, user2.username], + ) + service_failed!(result) if result.failure? + direct_message_channel = result.channel + message = create_chat_message(user1, "ping nobody", direct_message_channel) - 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( + Chat::MessageUpdater.update( guardian: guardian, - chat_message: chat_message, + chat_message: 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 + mention = admin1.chat_mentions.where(chat_message: message).first + expect(mention.notification).to be_nil 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 } + it "creates a chat_mention record without notification when self mentioning" do + chat_message = create_chat_message(user1, "I will mention myself soon", public_chat_channel) + new_content = "hello @#{user1.username}" + + described_class.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_content, + ) + + mention = user1.chat_mentions.where(chat_message: chat_message).first + expect(mention).to be_present + expect(mention.notification).to be_nil 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( + it "adds mentioned user and their status to the message bus message" do + SiteSetting.enable_user_status = true + status = { description: "dentist", emoji: "tooth" } + user2.set_status!(status[:description], status[:emoji]) + chat_message = create_chat_message(user1, "This will be updated", public_chat_channel) + new_content = "Hey @#{user2.username}" + + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_content, + ) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["mentioned_users"].count).to be(1) + mentioned_user = message["chat_message"]["mentioned_users"][0] + + expect(mentioned_user["id"]).to eq(user2.id) + expect(mentioned_user["username"]).to eq(user2.username) + expect(mentioned_user["status"]).to be_present + expect(mentioned_user["status"].slice(:description, :emoji)).to eq(status) + end + + it "doesn't add mentioned user's status to the message bus message when status is disabled" do + SiteSetting.enable_user_status = false + user2.set_status!("dentist", "tooth") + chat_message = create_chat_message(user1, "This will be updated", public_chat_channel) + new_content = "Hey @#{user2.username}" + + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + described_class.update( + guardian: guardian, + chat_message: chat_message, + new_content: new_content, + ) + end + + expect(messages.count).to be(1) + message = messages[0].data + expect(message["chat_message"]["mentioned_users"].count).to be(1) + mentioned_user = message["chat_message"]["mentioned_users"][0] + + expect(mentioned_user["status"]).to be_blank + end + + context "when updating a mentioned user" do + it "updates the mention record" do + chat_message = create_chat_message(user1, "ping @#{user2.username}", public_chat_channel) + + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, - new_content: "ping nobody anymore!", + new_content: "ping @#{user3.username}", ) - }.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 + user2_mentions = user2.chat_mentions.where(chat_message: chat_message) + expect(user2_mentions.length).to be(0) + + user3_mentions = user3.chat_mentions.where(chat_message: chat_message) + expect(user3_mentions.length).to be(1) + end + end + + context "when there are duplicate mentions" do + it "creates a single mention record per user" do + chat_message = create_chat_message(user1, "ping @#{user2.username}", public_chat_channel) + + Chat::MessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{user2.username} @#{user2.username} edited", + ) + + expect(user2.chat_mentions.where(chat_message: chat_message).count).to eq(1) + end + end + + describe "with group mentions" do + it "creates group mentions on update" do + chat_message = create_chat_message(user1, "ping nobody", public_chat_channel) + expect { + Chat::MessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping @#{admin_group.name}", + ) + }.to change { Chat::Mention.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::MessageUpdater.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::MessageUpdater.update( + guardian: guardian, + chat_message: chat_message, + new_content: "ping nobody anymore!", + ) + }.to change { Chat::Mention.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 end @@ -245,7 +401,7 @@ describe Chat::ChatMessageUpdater 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( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -279,7 +435,7 @@ describe Chat::ChatMessageUpdater do chat_message_2.update!(created_at: 20.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message_1, new_content: "another different chat message here", @@ -299,7 +455,7 @@ describe Chat::ChatMessageUpdater do chat_message.update!(created_at: 30.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "this is some chat message", @@ -323,13 +479,13 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.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 } + }.to not_change { UploadReference.count } end it "removes uploads that should be removed" do @@ -340,14 +496,15 @@ describe Chat::ChatMessageUpdater do public_chat_channel, upload_ids: [upload1.id, upload2.id], ) + expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.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) + }.to change { UploadReference.where(upload_id: upload2.id).count }.by(-1) end it "removes all uploads if they should be removed" do @@ -358,64 +515,65 @@ describe Chat::ChatMessageUpdater do public_chat_channel, upload_ids: [upload1.id, upload2.id], ) + expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.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) + }.to change { UploadReference.where(target: 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( + Chat::MessageUpdater.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) + }.to change { UploadReference.where(target: 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( + Chat::MessageUpdater.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) + }.to change { UploadReference.where(target: 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( + Chat::MessageUpdater.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 } + }.to not_change { UploadReference.where(target: 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( + Chat::MessageUpdater.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 } + }.to not_change { UploadReference.where(target: chat_message).count } end it "doesn't remove existing uploads if `chat_allow_uploads` is false" do @@ -428,13 +586,13 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.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 } + }.to not_change { UploadReference.where(target: chat_message).count } end it "updates if upload is present even if length is less than `chat_minimum_message_length`" do @@ -447,7 +605,7 @@ describe Chat::ChatMessageUpdater do ) SiteSetting.chat_minimum_message_length = 10 new_message = "hi :)" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -457,13 +615,36 @@ describe Chat::ChatMessageUpdater do end end + context "when the message is in a thread" do + fab!(:message) do + Fabricate( + :chat_message, + user: user1, + chat_channel: public_chat_channel, + thread: Fabricate(:chat_thread, channel: public_chat_channel), + ) + end + + it "publishes a MessageBus event to update the original message metadata" do + messages = + MessageBus.track_publish("/chat/#{public_chat_channel.id}") do + Chat::MessageUpdater.update( + guardian: guardian, + chat_message: message, + new_content: "some new updated content", + ) + end + expect(messages.find { |m| m.data["type"] == "update_thread_original_message" }).to be_present + 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::MessageCreator.create( chat_channel: public_chat_channel, user: user1, content: "bad word - #{watched_word.word}", @@ -480,7 +661,7 @@ describe Chat::ChatMessageUpdater do def update_message(user) message.update(user: user) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(user), chat_message: message, new_content: "I guess this is different", @@ -494,10 +675,7 @@ describe Chat::ChatMessageUpdater 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, - ), + I18n.t("chat.errors.channel_modify_message_disallowed.closed"), ) end @@ -514,18 +692,12 @@ describe Chat::ChatMessageUpdater 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, - ), + I18n.t("chat.errors.channel_modify_message_disallowed.read_only"), ) 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, - ), + I18n.t("chat.errors.channel_modify_message_disallowed.read_only"), ) end end @@ -537,18 +709,12 @@ describe Chat::ChatMessageUpdater 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, - ), + I18n.t("chat.errors.channel_modify_message_disallowed.archived"), ) 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, - ), + I18n.t("chat.errors.channel_modify_message_disallowed.archived"), ) end end diff --git a/plugins/chat/spec/components/chat_seeder_spec.rb b/plugins/chat/spec/components/chat/seeder_spec.rb similarity index 76% rename from plugins/chat/spec/components/chat_seeder_spec.rb rename to plugins/chat/spec/components/chat/seeder_spec.rb index e0a7c5222a6..f0ee8d1faac 100644 --- a/plugins/chat/spec/components/chat_seeder_spec.rb +++ b/plugins/chat/spec/components/chat/seeder_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatSeeder do +describe Chat::Seeder do fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) } fab!(:general_category) { Fabricate(:category, name: "General") } @@ -27,16 +27,16 @@ describe ChatSeeder do expected_members_count = GroupUser.where(group: group).count memberships_count = - UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count + Chat::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 + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) - general_channel = ChatChannel.find_by(chatable: general_category) + staff_channel = Chat::Channel.find_by(chatable_id: staff_category) + general_channel = Chat::Channel.find_by(chatable_id: general_category) assert_channel_was_correctly_seeded(staff_channel, Group[:staff]) assert_channel_was_correctly_seeded(general_channel, Group[:everyone]) @@ -49,24 +49,24 @@ describe ChatSeeder do it "applies a name to the general category channel" do expected_name = general_category.name - ChatSeeder.new.execute + Chat::Seeder.new.execute - general_channel = ChatChannel.find_by(chatable: general_category) + general_channel = Chat::Channel.find_by(chatable_id: 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 + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) + staff_channel = Chat::Channel.find_by(chatable_id: 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 } + expect { Chat::Seeder.new.execute }.not_to change { Chat::Channel.count } end end diff --git a/plugins/chat/spec/components/chat_message_creator_spec.rb b/plugins/chat/spec/components/chat_message_creator_spec.rb deleted file mode 100644 index 45f3a4ad2e3..00000000000 --- a/plugins/chat/spec/components/chat_message_creator_spec.rb +++ /dev/null @@ -1,608 +0,0 @@ -# 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 "errors when length is greater than `chat_maximum_message_length`" do - SiteSetting.chat_maximum_message_length = 100 - creator = - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "a really long and in depth message that is just too detailed" * 100, - ) - expect(creator.failed?).to eq(true) - expect(creator.error.message).to match( - I18n.t( - "chat.errors.message_too_long", - { maximum: SiteSetting.chat_maximum_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 "updates the channel’s last message date" do - previous_last_message_sent_at = public_chat_channel.last_message_sent_at - - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "this is a message", - ) - - expect(previous_last_message_sent_at).to be < public_chat_channel.reload.last_message_sent_at - 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 do - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "this is a message", - ) - end - 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/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index a8d7f3e544e..359a813db6e 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -Fabricator(:chat_channel) do +Fabricator(:chat_channel, class_name: "Chat::Channel") do name do sequence(:name) do |n| random_name = [ @@ -25,14 +25,14 @@ Fabricator(:chat_channel) do status { :open } end -Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {} +Fabricator(:category_channel, from: :chat_channel) {} -Fabricator(:private_category_channel, from: :category_channel, class_name: :category_channel) do +Fabricator(:private_category_channel, from: :category_channel) do transient :group chatable { |attrs| Fabricate(:private_category, group: attrs[:group] || Group[:staff]) } end -Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_message_channel) do +Fabricator(:direct_message_channel, from: :chat_channel) do transient :users, following: true, with_membership: true chatable do |attrs| Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) @@ -49,22 +49,41 @@ Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_mes end end -Fabricator(:chat_message) do - chat_channel - user - message "Beep boop" - cooked { |attrs| ChatMessage.cook(attrs[:message]) } - cooked_version ChatMessage::BAKED_VERSION - in_reply_to nil +Fabricator(:chat_message, class_name: "Chat::MessageCreator") do + transient :chat_channel + transient :user + transient :message + transient :in_reply_to + transient :thread + transient :upload_ids + + initialize_with do |transients| + user = transients[:user] || Fabricate(:user) + channel = + transients[:chat_channel] || transients[:thread]&.channel || + transients[:in_reply_to]&.chat_channel || Fabricate(:chat_channel) + + resolved_class.create( + chat_channel: channel, + user: user, + content: transients[:message] || Faker::Lorem.paragraph_by_chars(number: 500), + thread_id: transients[:thread]&.id, + in_reply_to_id: transients[:in_reply_to]&.id, + upload_ids: transients[:upload_ids], + ).chat_message + end end -Fabricator(:chat_mention) do - chat_message { Fabricate(:chat_message) } +Fabricator(:chat_mention, class_name: "Chat::Mention") do + transient read: false + transient high_priority: true + transient identifier: :direct_mentions + user { Fabricate(:user) } - notification { Fabricate(:notification) } + chat_message { Fabricate(:chat_message) } end -Fabricator(:chat_message_reaction) do +Fabricator(:chat_message_reaction, class_name: "Chat::MessageReaction") do chat_message { Fabricate(:chat_message) } user { Fabricate(:user) } emoji { %w[+1 tada heart joffrey_facepalm].sample } @@ -73,47 +92,39 @@ Fabricator(:chat_message_reaction) do end end -Fabricator(:chat_upload) do - transient :user - - user { Fabricate(:user) } - - chat_message { |attrs| Fabricate(:chat_message, user: attrs[:user]) } - upload { |attrs| Fabricate(:upload, user: attrs[:user]) } -end - -Fabricator(:chat_message_revision) do +Fabricator(:chat_message_revision, class_name: "Chat::MessageRevision") 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 +Fabricator(:chat_reviewable_message, class_name: "Chat::ReviewableMessage") 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(:direct_message, class_name: "Chat::DirectMessage") do + users { [Fabricate(:user), Fabricate(:user)] } +end -Fabricator(:chat_webhook_event) do +Fabricator(:chat_webhook_event, class_name: "Chat::WebhookEvent") 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 +Fabricator(:incoming_chat_webhook, class_name: "Chat::IncomingWebhook") 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 +Fabricator(:user_chat_channel_membership, class_name: "Chat::UserChatChannelMembership") do user chat_channel following true @@ -127,7 +138,7 @@ Fabricator(:user_chat_channel_membership_for_dm, from: :user_chat_channel_member mobile_notification_level 2 end -Fabricator(:chat_draft) do +Fabricator(:chat_draft, class_name: "Chat::Draft") do user chat_channel @@ -139,3 +150,48 @@ Fabricator(:chat_draft) do { value: attrs[:value], replyToMsg: attrs[:reply_to_msg], uploads: attrs[:uploads] }.to_json end end + +Fabricator(:chat_thread, class_name: "Chat::Thread") do + before_create do |thread, transients| + thread.original_message_user = original_message.user + thread.channel = original_message.chat_channel + end + + transient :with_replies + transient :channel + transient :original_message_user + transient :old_om + + original_message do |attrs| + Fabricate( + :chat_message, + chat_channel: attrs[:channel] || Fabricate(:chat_channel), + user: attrs[:original_message_user] || Fabricate(:user), + ) + end + + after_create do |thread, transients| + attrs = { thread_id: thread.id } + + # Sometimes we make this older via created_at so any messages fabricated for this thread + # afterwards are not created earlier in time than the OM. + attrs[:created_at] = 1.week.ago if transients[:old_om] + + thread.original_message.update!(**attrs) + thread.add(thread.original_message_user) + + if transients[:with_replies] + Fabricate.times(transients[:with_replies], :chat_message, thread: thread) + end + end +end + +Fabricator(:user_chat_thread_membership, class_name: "Chat::UserChatThreadMembership") do + user + after_create do |membership| + Chat::UserChatChannelMembership.find_or_create_by!( + user: membership.user, + chat_channel: membership.thread.channel, + ).update!(following: true) + end +end diff --git a/plugins/chat/spec/integration/auto_channel_user_removal_spec.rb b/plugins/chat/spec/integration/auto_channel_user_removal_spec.rb new file mode 100644 index 00000000000..3265bacd172 --- /dev/null +++ b/plugins/chat/spec/integration/auto_channel_user_removal_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +describe "Automatic user removal from channels" do + fab!(:user_1) { Fabricate(:user, trust_level: TrustLevel[1]) } + let(:user_1_guardian) { Guardian.new(user_1) } + fab!(:user_2) { Fabricate(:user, trust_level: TrustLevel[1]) } + + fab!(:secret_group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: secret_group) } + + fab!(:public_channel) { Fabricate(:chat_channel) } + fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) } + fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } + + before do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] + SiteSetting.chat_enabled = true + Group.refresh_automatic_groups! + Jobs.run_immediately! + + secret_group.add(user_1) + public_channel.add(user_1) + private_channel.add(user_1) + public_channel.add(user_2) + + CategoryGroup.create(category: public_channel.chatable, group_id: Group::AUTO_GROUPS[:everyone]) + end + + context "when the chat_allowed_groups site setting changes" do + it "removes the user who is no longer in chat_allowed_groups" do + expect { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_3] }.to change { + Chat::UserChatChannelMembership.count + }.by(-3) + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: public_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + public_channel.id, + ) + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + private_channel.id, + ) + end + + it "does not remove the user who is in one of the chat_allowed_groups" do + user_2.change_trust_level!(TrustLevel[4]) + expect { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_3] }.to change { + Chat::UserChatChannelMembership.count + }.by(-2) + expect( + Chat::UserChatChannelMembership.exists?(user: user_2, chat_channel: public_channel), + ).to eq(true) + end + + it "does not remove users from their DM channels" do + expect { SiteSetting.chat_allowed_groups = "" }.to change { + Chat::UserChatChannelMembership.count + }.by(-3) + + expect(Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: dm_channel)).to eq( + true, + ) + expect(Chat::UserChatChannelMembership.exists?(user: user_2, chat_channel: dm_channel)).to eq( + true, + ) + end + + context "for staff users" do + fab!(:staff_user) { Fabricate(:admin) } + + it "does not remove them from public channels" do + public_channel.add(staff_user) + private_channel.add(staff_user) + SiteSetting.chat_allowed_groups = "" + expect( + Chat::UserChatChannelMembership.where( + user: staff_user, + chat_channel: [public_channel, private_channel], + ).count, + ).to eq(2) + end + + it "does not remove them from DM channels" do + staff_dm_channel = Fabricate(:direct_message_channel, users: [user_1, staff_user]) + expect( + Chat::UserChatChannelMembership.where( + user: staff_user, + chat_channel: [staff_dm_channel], + ).count, + ).to eq(1) + end + end + end + + context "when a user is removed from a group" do + context "when the user is no longer in any chat_allowed_groups" do + fab!(:group) { Fabricate(:group) } + + before do + group.add(user_1) + SiteSetting.chat_allowed_groups = group.id + end + + it "removes the user from the category channels" do + group.remove(user_1) + expect( + Chat::UserChatChannelMembership.where( + user: user_1, + chat_channel: [public_channel, private_channel], + ).count, + ).to eq(0) + end + + it "does not remove the user from DM channels" do + group.remove(user_1) + expect( + Chat::UserChatChannelMembership.where(user: user_1, chat_channel: dm_channel).count, + ).to eq(1) + end + + context "for staff users" do + fab!(:staff_user) { Fabricate(:admin) } + + it "does not remove them from public channels" do + public_channel.add(staff_user) + private_channel.add(staff_user) + group.add(staff_user) + group.remove(staff_user) + + expect( + Chat::UserChatChannelMembership.where( + user: staff_user, + chat_channel: [public_channel, private_channel], + ).count, + ).to eq(2) + end + end + end + + context "when a user is removed from a private category group" do + context "when the user is in another group that can interact with the channel" do + fab!(:stealth_group) { Fabricate(:group) } + before do + CategoryGroup.create!( + category: private_category, + group: stealth_group, + permission_type: CategoryGroup.permission_types[:full], + ) + stealth_group.add(user_1) + end + + it "does not remove them from the corresponding channel" do + secret_group.remove(user_1) + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(true) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to include( + private_channel.id, + ) + end + end + + context "when the user is in no other groups that can interact with the channel" do + it "removes them from the corresponding channel" do + secret_group.remove(user_1) + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + private_channel.id, + ) + end + end + end + end + + context "when a category is updated" do + context "when the group's permission changes from reply+see to just see for the category" do + it "removes the user from the corresponding category channel" do + private_category.update!(permissions: { secret_group.id => :readonly }) + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + private_channel.id, + ) + end + + context "for staff users" do + fab!(:staff_user) { Fabricate(:admin) } + + it "does not remove them from the channel" do + secret_group.add(staff_user) + private_channel.add(staff_user) + private_category.update!(permissions: { secret_group.id => :readonly }) + expect( + Chat::UserChatChannelMembership.exists?( + user: staff_user, + chat_channel: private_channel, + ), + ).to eq(true) + end + end + end + + context "when the secret_group is no longer allowed to access the private category" do + it "removes the user from the corresponding category channel" do + private_category.update!(permissions: { Group::AUTO_GROUPS[:staff] => :full }) + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + private_channel.id, + ) + end + + context "for staff users" do + fab!(:staff_user) { Fabricate(:admin) } + + it "does not remove them from the channel" do + secret_group.add(staff_user) + private_channel.add(staff_user) + private_category.update!(permissions: {}) + expect( + Chat::UserChatChannelMembership.exists?( + user: staff_user, + chat_channel: private_channel, + ), + ).to eq(true) + end + end + end + end + + context "when a group is destroyed" do + context "when it was the last group on the private category" do + it "no users are removed because the category defaults to Everyone having full access" do + secret_group.destroy! + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(true) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to include( + private_channel.id, + ) + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: public_channel), + ).to eq(true) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to include( + public_channel.id, + ) + end + end + + context "when there is another group on the private category" do + before do + CategoryGroup.create(group_id: Group::AUTO_GROUPS[:staff], category: private_category) + end + + it "only removes users who are not in that group" do + secret_group.destroy! + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel), + ).to eq(false) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).not_to include( + private_channel.id, + ) + + expect( + Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: public_channel), + ).to eq(true) + expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to include( + public_channel.id, + ) + end + end + end +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 index 6fa39be8848..b981161ebc2 100644 --- a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb +++ b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb @@ -44,7 +44,7 @@ describe "API keys scoped to chat#create_message" do end it "can create chat messages" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -54,12 +54,12 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + }.to change { Chat::Message.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) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -69,12 +69,16 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + }.not_to change { Chat::Message.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) + Chat::UserChatChannelMembership.create( + user: admin, + chat_channel: chat_channel_2, + following: true, + ) expect { post "/chat/#{chat_channel_2.id}.json", headers: { @@ -84,7 +88,7 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel_2).count }.by(1) + }.to change { Chat::Message.where(chat_channel: chat_channel_2).count }.by(1) expect(response.status).to eq(200) end end diff --git a/plugins/chat/spec/integration/post_chat_quote_spec.rb b/plugins/chat/spec/integration/post_chat_quote_spec.rb index a49ae55665f..83e4b83ed4d 100644 --- a/plugins/chat/spec/integration/post_chat_quote_spec.rb +++ b/plugins/chat/spec/integration/post_chat_quote_spec.rb @@ -16,12 +16,10 @@ describe "chat bbcode quoting in posts" do
    martin
    - -
    +
    -

    This is a chat message.

    -
    +

    This is a chat message.

    COOKED end @@ -34,19 +32,16 @@ describe "chat bbcode quoting in posts" do expect(post.cooked.chomp).to eq(<<~COOKED.chomp)
    martin
    - -
    +
    -

    This is a chat message.

    -
    +

    This is a chat message.

    COOKED end @@ -63,14 +58,11 @@ describe "chat bbcode quoting in posts" do
    martin
    - -
    - - #Cool Cats Club -
    +
    + + #Cool Cats Club
    -

    This is a chat message.

    -
    +

    This is a chat message.

    COOKED end @@ -87,14 +79,11 @@ describe "chat bbcode quoting in posts" do
    martin
    - -
    - - #Cool Cats Club -
    +
    + + #Cool Cats Club
    -

    This is a chat message.

    -
    +

    This is a chat message.

    COOKED end @@ -107,19 +96,16 @@ describe "chat bbcode quoting in posts" do expect(post.cooked.chomp).to eq(<<~COOKED.chomp)
    - Originally sent in Cool Cats Club -
    + Originally sent in Cool Cats Club
    martin
    - -
    +
    -

    This is a chat message.

    -
    +

    This is a chat message.

    COOKED end @@ -137,14 +123,11 @@ describe "chat bbcode quoting in posts" do
    martin
    - -
    - - #Cool Cats Club -
    +
    + + #Cool Cats Club
    -

    This is a chat message.

    -
    +

    This is a chat message.

    +1 1
    @@ -198,12 +181,10 @@ This is an inline onebox https://en.wikipedia.org/wiki/Hyperlink.
    martin
    - -
    +
    -

    This is a chat message.

    -
    +

    This is a chat message.

    #{full_onebox_html}

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

    @@ -220,14 +201,14 @@ martin message1 = Fabricate(:chat_message, chat_channel: channel, user: post.user) message2 = Fabricate(:chat_message, chat_channel: channel, user: post.user) md = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message2.user, messages_or_ids: [message2.id], ).generate_markdown message1.update!(message: md) md_for_post = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message1.user, messages_or_ids: [message1.id], @@ -237,35 +218,27 @@ martin
    - -
    +
    #{message1.user.username}
    - -
    - -##{channel.name} -
    + + +##{channel.name}
    - -
    +
    #{message2.user.username}
    - -
    - -##{channel.name} -
    +
    + +##{channel.name}
    -

    #{message2.message}

    -
    - - +

    #{message2.message}

    + COOKED end diff --git a/plugins/chat/spec/integration/thread_replies_count_cache_accuracy_spec.rb b/plugins/chat/spec/integration/thread_replies_count_cache_accuracy_spec.rb new file mode 100644 index 00000000000..01df30c356b --- /dev/null +++ b/plugins/chat/spec/integration/thread_replies_count_cache_accuracy_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +RSpec.describe "Chat::Thread replies_count cache accuracy" do + include ActiveSupport::Testing::TimeHelpers + + fab!(:user) { Fabricate(:user) } + fab!(:thread) { Fabricate(:chat_thread) } + + before { SiteSetting.chat_enabled = true } + + it "keeps an accurate replies_count cache" do + freeze_time + Jobs.run_immediately! + + expect(thread.replies_count).to eq(0) + expect(thread.replies_count_cache).to eq(0) + + # Create 5 replies + 5.times do |i| + Chat::MessageCreator.create( + chat_channel: thread.channel, + user: user, + thread_id: thread.id, + content: "Hello world #{i}", + ) + end + + # The job only runs to completion if the cache has not been recently + # updated, so the DB count will only be 1. + expect(thread.replies_count_cache).to eq(5) + expect(thread.reload.replies_count).to eq(1) + + # Travel to the future so the cache expires. + travel_to 6.minutes.from_now + Chat::MessageCreator.create( + chat_channel: thread.channel, + user: user, + thread_id: thread.id, + content: "Hello world now that time has passed", + ) + expect(thread.replies_count_cache).to eq(6) + expect(thread.reload.replies_count).to eq(6) + + # Lose the cache intentionally. + Chat::Thread.clear_caches!(thread.id) + message_to_destroy = thread.last_message + Chat::TrashMessage.call( + message_id: message_to_destroy.id, + channel_id: thread.channel_id, + guardian: Guardian.new(user), + ) + expect(thread.replies_count_cache).to eq(5) + expect(thread.reload.replies_count).to eq(5) + + # Lose the cache intentionally. + Chat::Thread.clear_caches!(thread.id) + Chat::RestoreMessage.call( + message_id: message_to_destroy.id, + channel_id: thread.channel_id, + guardian: Guardian.new(user), + ) + expect(thread.replies_count_cache).to eq(6) + expect(thread.reload.replies_count).to eq(6) + end +end diff --git a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb b/plugins/chat/spec/jobs/chat_channel_delete_spec.rb deleted file mode 100644 index 033274f04c0..00000000000 --- a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -# 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/regular/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb deleted file mode 100644 index e97776b10fe..00000000000 --- a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# 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(: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/chat/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb new file mode 100644 index 00000000000..fa8d7534ef9 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::Chat::AutoJoinChannelBatch do + it "can successfully queue this job" do + expect { + Jobs.enqueue( + described_class, + channel_id: Fabricate(:chat_channel).id, + start_user_id: 0, + end_user_id: 10, + ) + }.to change(Jobs::Chat::AutoJoinChannelBatch.jobs, :size).by(1) + end + + context "when contract fails" do + before { Jobs.run_immediately! } + + it "logs an error" do + Rails.logger.expects(:error).with(regexp_matches(/Channel can't be blank/)).at_least_once + + Jobs.enqueue(described_class) + end + end + + context "when model is not found" do + before { Jobs.run_immediately! } + + it "logs an error" do + Rails.logger.expects(:error).with("Channel not found (id=-999)").at_least_once + + Jobs.enqueue(described_class, channel_id: -999, start_user_id: 1, end_user_id: 2) + end + end +end diff --git a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_memberships_spec.rb similarity index 87% rename from plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb rename to plugins/chat/spec/jobs/regular/chat/auto_join_channel_memberships_spec.rb index 1ea5470c7f9..bfb6cacfd02 100644 --- a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_memberships_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::AutoManageChannelMemberships do +describe Jobs::Chat::AutoJoinChannelMemberships 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) } @@ -13,7 +13,7 @@ describe Jobs::AutoManageChannelMemberships do end it "does nothing when the channel doesn't exist" do - assert_batches_enqueued(ChatChannel.new(id: -1), 0) + assert_batches_enqueued(Chat::Channel.new(id: -1), 0) end it "does nothing when the chatable is not a category" do @@ -44,24 +44,24 @@ describe Jobs::AutoManageChannelMemberships do 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!( + Chat::UserChatChannelMembership.create!( user: user_2, chat_channel: channel, following: true, - join_mode: UserChatChannelMembership.join_modes[:automatic], + join_mode: Chat::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) + Chat::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) + Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) assert_batches_enqueued(channel, 0) end @@ -120,7 +120,7 @@ describe Jobs::AutoManageChannelMemberships do def assert_batches_enqueued(channel, expected) expect { subject.execute(chat_channel_id: channel.id) }.to change( - Jobs::AutoJoinChannelBatch.jobs, + Jobs::Chat::AutoJoinChannelBatch.jobs, :size, ).by(expected) end diff --git a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/chat_channel_archive_spec.rb rename to plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb index a60c8de55d2..02b43d1749a 100644 --- a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb @@ -2,12 +2,12 @@ require "rails_helper" -describe Jobs::ChatChannelArchive do +describe Jobs::Chat::ChannelArchive do fab!(:chat_channel) { Fabricate(:category_channel) } fab!(:user) { Fabricate(:user, admin: true) } fab!(:category) { Fabricate(:category) } fab!(:chat_archive) do - ChatChannelArchive.create!( + Chat::ChannelArchive.create!( chat_channel: chat_channel, archived_by: user, destination_topic_title: "This will be the archive topic", @@ -34,7 +34,7 @@ describe Jobs::ChatChannelArchive do end it "processes the archive" do - Chat::ChatChannelArchiveService.any_instance.expects(:execute) + Chat::ChannelArchiveService.any_instance.expects(:execute) run_job end end diff --git a/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb new file mode 100644 index 00000000000..7d7bf75bef3 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +describe Jobs::Chat::ChannelDelete 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 { Chat::MessageReaction.create(chat_message: messages.sample, user: users.sample) } + + 10.times do + upload = Fabricate(:upload, user: users.sample) + message = messages.sample + + UploadReference.create(target: message, upload: upload) + end + + Chat::Mention.create( + user: user2, + chat_message: messages.sample, + notification: Fabricate(:notification), + ) + + @incoming_chat_webhook_id = Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) + Chat::WebhookEvent.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, + ) + + Chat::Draft.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 + + def counts + { + incoming_webhooks: Chat::IncomingWebhook.where(chat_channel_id: chat_channel.id).count, + webhook_events: + Chat::WebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count, + drafts: Chat::Draft.where(chat_channel: chat_channel).count, + channel_memberships: Chat::UserChatChannelMembership.where(chat_channel: chat_channel).count, + revisions: Chat::MessageRevision.where(chat_message_id: @message_ids).count, + mentions: Chat::Mention.where(chat_message_id: @message_ids).count, + upload_references: + UploadReference.where( + target_id: @message_ids, + target_type: Chat::Message.polymorphic_name, + ).count, + messages: Chat::Message.where(id: @message_ids).count, + reactions: Chat::MessageReaction.where(chat_message_id: @message_ids).count, + } + end + + it "deletes all of the messages and related records completely" do + initial_counts = counts + described_class.new.execute(chat_channel_id: chat_channel.id) + new_counts = counts + + expect(new_counts[:incoming_webhooks]).to eq(initial_counts[:incoming_webhooks] - 1) + expect(new_counts[:webhook_events]).to eq(initial_counts[:webhook_events] - 1) + expect(new_counts[:drafts]).to eq(initial_counts[:drafts] - 1) + expect(new_counts[:channel_memberships]).to eq(initial_counts[:channel_memberships] - 3) + expect(new_counts[:revisions]).to eq(initial_counts[:revisions] - 1) + expect(new_counts[:mentions]).to eq(initial_counts[:mentions] - 1) + expect(new_counts[:upload_references]).to eq(initial_counts[:upload_references] - 10) + expect(new_counts[:messages]).to eq(initial_counts[:messages] - 20) + expect(new_counts[:reactions]).to eq(initial_counts[:reactions] - 10) + end + + it "does not error if there are no messages in the channel" do + other_channel = Fabricate(:chat_channel) + expect { described_class.new.execute(chat_channel_id: other_channel.id) }.not_to raise_error + end +end diff --git a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb similarity index 73% rename from plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb rename to plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb index 26242bae9c7..1cb3f1c8da3 100644 --- a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true -RSpec.describe Jobs::DeleteUserMessages do +RSpec.describe Jobs::Chat::DeleteUserMessages do describe "#execute" do + subject(:execute) { described_class.new.execute(user_id: user_1) } + fab!(:user_1) { Fabricate(:user) } fab!(:channel) { Fabricate(:chat_channel) } fab!(:chat_message) { Fabricate(:chat_message, chat_channel: channel, user: user_1) } it "deletes messages from the user" do - subject.execute(user_id: user_1) + execute expect { chat_message.reload }.to raise_error(ActiveRecord::RecordNotFound) end @@ -16,7 +18,7 @@ RSpec.describe Jobs::DeleteUserMessages do user_2 = Fabricate(:user) user_2_message = Fabricate(:chat_message, chat_channel: channel, user: user_2) - subject.execute(user_id: user_1) + execute expect(user_2_message.reload).to be_present end @@ -24,9 +26,9 @@ RSpec.describe Jobs::DeleteUserMessages do it "deletes trashed messages" do chat_message.trash! - subject.execute(user_id: user_1) + execute - expect(ChatMessage.with_deleted.where(id: chat_message.id)).to be_empty + expect(Chat::Message.with_deleted.where(id: chat_message.id)).to be_empty end end end diff --git a/plugins/chat/spec/jobs/regular/chat/kick_users_from_channel_spec.rb b/plugins/chat/spec/jobs/regular/chat/kick_users_from_channel_spec.rb new file mode 100644 index 00000000000..caecf6ef3e7 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat/kick_users_from_channel_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::Chat::KickUsersFromChannel do + fab!(:channel) { Fabricate(:chat_channel) } + + it "publishes the correct MessageBus message" do + message = + MessageBus + .track_publish(Chat::Publisher.kick_users_message_bus_channel(channel.id)) do + described_class.new.execute(channel_id: channel.id, user_ids: [1, 2, 3]) + end + .first + + expect(message.user_ids).to eq([1, 2, 3]) + end + + it "does nothing if the channel is deleted" do + channel_id = channel.id + channel.trash! + message = + MessageBus + .track_publish(Chat::Publisher.kick_users_message_bus_channel(channel.id)) do + described_class.new.execute(channel_id: channel_id, user_ids: [1, 2, 3]) + end + .first + expect(message).to be_nil + end + + it "does nothing if no user_ids are provided" do + message = + MessageBus + .track_publish(Chat::Publisher.kick_users_message_bus_channel(channel.id)) do + described_class.new.execute(channel_id: channel.id) + end + .first + expect(message).to be_nil + 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 similarity index 82% rename from plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb index 32204adc9cd..1810a843168 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb @@ -2,7 +2,9 @@ require "rails_helper" -describe Jobs::ChatNotifyMentioned do +describe Jobs::Chat::NotifyMentioned do + subject(:job) { described_class.new } + fab!(:user_1) { Fabricate(:user) } fab!(:user_2) { Fabricate(:user) } fab!(:public_channel) { Fabricate(:category_channel) } @@ -13,16 +15,37 @@ describe Jobs::ChatNotifyMentioned do 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]) + result = + Chat::CreateDirectMessageChannel.call( + guardian: user_1.guardian, + target_usernames: [user_1.username, user_2.username], + ) + + service_failed!(result) if result.failure? + + @personal_chat_channel = result.channel [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) + def create_chat_message( + channel: public_channel, + author: user_1, + mentioned_user: user_2, + thread: nil + ) + message = + Fabricate( + :chat_message, + chat_channel: channel, + user: author, + created_at: 10.minutes.ago, + thread: thread, + ) + Fabricate(:chat_mention, chat_message: message, user: mentioned_user) + message end def track_desktop_notification( @@ -33,7 +56,7 @@ describe Jobs::ChatNotifyMentioned do ) MessageBus .track_publish("/chat/notification-alert/#{user.id}") do - subject.execute( + job.execute( chat_message_id: message.id, timestamp: message.created_at, to_notify_ids_map: to_notify_ids_map, @@ -44,7 +67,7 @@ describe Jobs::ChatNotifyMentioned do end def track_core_notification(user: user_2, message:, to_notify_ids_map:) - subject.execute( + job.execute( chat_message_id: message.id, timestamp: message.created_at, to_notify_ids_map: to_notify_ids_map, @@ -74,7 +97,7 @@ describe Jobs::ChatNotifyMentioned do 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!( + Chat::UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( following: false, ) @@ -92,7 +115,7 @@ describe Jobs::ChatNotifyMentioned do 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! + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! PostAlerter.expects(:push_notification).never @@ -143,8 +166,8 @@ describe Jobs::ChatNotifyMentioned do 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], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) desktop_notification = @@ -155,13 +178,13 @@ describe Jobs::ChatNotifyMentioned do 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], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) PostAlerter.expects(:push_notification).never - subject.execute( + job.execute( chat_message_id: message.id, timestamp: message.created_at, to_notify_ids_map: to_notify_ids_map, @@ -170,8 +193,8 @@ describe Jobs::ChatNotifyMentioned do 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], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) @@ -183,14 +206,14 @@ describe Jobs::ChatNotifyMentioned do 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], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) PostAlerter.expects(:push_notification).never - subject.execute( + job.execute( chat_message_id: message.id, timestamp: message.created_at, to_notify_ids_map: to_notify_ids_map, @@ -211,11 +234,11 @@ describe Jobs::ChatNotifyMentioned do 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), + Chat::Notifier.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}", + "/chat/c/#{public_channel.slug}/#{public_channel.id}/#{message.id}", ) end @@ -227,15 +250,14 @@ describe Jobs::ChatNotifyMentioned do { notification_type: Notification.types[:chat_mention], username: user_1.username, - tag: Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + tag: Chat::Notifier.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}", + post_url: "/chat/c/#{public_channel.slug}/#{public_channel.id}/#{message.id}", translated_title: payload_translated_title, }, ) - subject.execute( + job.execute( chat_message_id: message.id, timestamp: message.created_at, to_notify_ids_map: to_notify_ids_map, @@ -262,7 +284,7 @@ describe Jobs::ChatNotifyMentioned do expect(data_hash[:chat_channel_slug]).to eq(public_channel.slug) chat_mention = - ChatMention.where(notification: created_notification, user: user_2, chat_message: message) + Chat::Mention.where(notification: created_notification, user: user_2, chat_message: message) expect(chat_mention).to be_present end end @@ -406,6 +428,26 @@ describe Jobs::ChatNotifyMentioned do expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) end + context "when the mention is within a thread" do + before { public_channel.update!(threading_enabled: true) } + + fab!(:thread) { Fabricate(:chat_thread, channel: public_channel) } + + it "uses the thread URL for the post_url in the desktop notification" do + message = create_chat_message(thread: thread) + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification.data[:post_url]).to eq(thread.relative_url) + end + + it "includes the thread ID in the core notification data" do + message = create_chat_message(thread: thread) + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(created_notification.data_hash[:chat_thread_id]).to eq(thread.id) + end + end + context "with private channels" do it "users a different translated title" do message = create_chat_message(channel: @personal_chat_channel) diff --git a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb similarity index 90% rename from plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb index 72a09d58bf8..0b19f1e6972 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatNotifyWatching do +RSpec.describe Jobs::Chat::NotifyWatching do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } fab!(:user3) { Fabricate(:user) } @@ -39,7 +39,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -56,7 +56,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ) @@ -75,8 +75,8 @@ RSpec.describe Jobs::ChatNotifyWatching do 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], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -93,7 +93,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ), @@ -179,7 +179,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -196,8 +196,8 @@ RSpec.describe Jobs::ChatNotifyWatching do "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, + tag: Chat::Notifier.push_notification_tag(:message, channel.id), + excerpt: message.push_notification_excerpt, }, ) end @@ -215,8 +215,8 @@ RSpec.describe Jobs::ChatNotifyWatching do 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], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -233,8 +233,8 @@ RSpec.describe Jobs::ChatNotifyWatching do "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, + tag: Chat::Notifier.push_notification_tag(:message, channel.id), + excerpt: message.push_notification_excerpt, }, ), ) diff --git a/plugins/chat/spec/jobs/process_chat_message_spec.rb b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/process_chat_message_spec.rb rename to plugins/chat/spec/jobs/regular/chat/process_message_spec.rb index cb98c286afc..658bb2e3d1c 100644 --- a/plugins/chat/spec/jobs/process_chat_message_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::ProcessChatMessage do +describe Jobs::Chat::ProcessMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "https://discourse.org/team") } it "updates cooked with oneboxes" do @@ -23,7 +23,7 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "publishes the update" do - ChatPublisher.expects(:publish_processed!).once + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id, is_dirty: true) end end @@ -32,14 +32,14 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "doesn’t publish the update" do - ChatPublisher.expects(:publish_processed!).never + Chat::Publisher.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 + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id) end end diff --git a/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb new file mode 100644 index 00000000000..93d395da9ac --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::Chat::SendMessageNotifications do + subject(:job) { described_class.new } + + describe "#execute" do + context "when the message doesn't exist" do + it "does nothing" do + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never + + job.execute(reason: "new", timestamp: 1.minute.ago) + end + end + + context "when there's a message" do + fab!(:chat_message) { Fabricate(:chat_message) } + + it "does nothing when the reason is invalid" do + Chat::Notifier.expects(:notify_new).never + Chat::Notifier.expects(:notify_edit).never + + job.execute(chat_message_id: chat_message.id, reason: "invalid", timestamp: 1.minute.ago) + end + + it "does nothing if there is no timestamp" do + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never + + job.execute(chat_message_id: chat_message.id, reason: "new") + end + + it "calls notify_new when the reason is 'new'" do + Chat::Notifier.any_instance.expects(:notify_new).once + Chat::Notifier.any_instance.expects(:notify_edit).never + + job.execute(chat_message_id: chat_message.id, reason: "new", timestamp: 1.minute.ago) + end + + it "calls notify_edit when the reason is 'edit'" do + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).once + + job.execute(chat_message_id: chat_message.id, reason: "edit", timestamp: 1.minute.ago) + end + end + end +end diff --git a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb similarity index 82% rename from plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb rename to plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb index 6674e53b9e7..a6d2bffbe27 100644 --- a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::UpdateChannelUserCount do +RSpec.describe Jobs::Chat::UpdateChannelUserCount do fab!(:channel) { Fabricate(:category_channel, user_count: 0, user_count_stale: true) } fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } @@ -18,18 +18,18 @@ RSpec.describe Jobs::UpdateChannelUserCount do it "does nothing if the channel does not exist" do channel.destroy - ChatPublisher.expects(:publish_chat_channel_metadata).never + Chat::Publisher.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 + Chat::Publisher.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) + Chat::Publisher.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) diff --git a/plugins/chat/spec/jobs/regular/mark_all_channel_threads_read_spec.rb b/plugins/chat/spec/jobs/regular/mark_all_channel_threads_read_spec.rb new file mode 100644 index 00000000000..b98db58b589 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/mark_all_channel_threads_read_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::Chat::MarkAllChannelThreadsRead do + fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel) } + fab!(:thread_2) { Fabricate(:chat_thread, channel: channel) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:thread_1_message_1) { Fabricate(:chat_message, thread: thread_1) } + fab!(:thread_1_message_2) { Fabricate(:chat_message, thread: thread_1) } + fab!(:thread_1_message_3) { Fabricate(:chat_message, thread: thread_1) } + fab!(:thread_2_message_1) { Fabricate(:chat_message, thread: thread_2) } + fab!(:thread_2_message_2) { Fabricate(:chat_message, thread: thread_2) } + + before do + channel.add(user_1) + channel.add(user_2) + thread_1.add(user_1) + thread_2.add(user_2) + end + + def unread_count(user) + Chat::ThreadUnreadsQuery.call(channel_ids: [channel.id], user_id: user.id).first.unread_count + end + + it "marks all threads as read across all users in the channel" do + expect(unread_count(user_1)).to eq(3) + expect(unread_count(user_2)).to eq(2) + described_class.new.execute(channel_id: channel.id) + expect(unread_count(user_1)).to eq(0) + expect(unread_count(user_2)).to eq(0) + end +end diff --git a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb b/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb deleted file mode 100644 index e00bad83f5c..00000000000 --- a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Jobs::SendMessageNotifications do - describe "#execute" do - context "when the message doesn't exist" do - it "does nothing" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never - - subject.execute(eason: "new", timestamp: 1.minute.ago) - end - end - - context "when there's a message" do - fab!(:chat_message) { Fabricate(:chat_message) } - - it "does nothing when the reason is invalid" do - Chat::ChatNotifier.expects(:notify_new).never - Chat::ChatNotifier.expects(:notify_edit).never - - subject.execute( - chat_message_id: chat_message.id, - reason: "invalid", - timestamp: 1.minute.ago, - ) - end - - it "does nothing if there is no timestamp" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never - - subject.execute(chat_message_id: chat_message.id, reason: "new") - end - - it "calls notify_new when the reason is 'new'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).once - Chat::ChatNotifier.any_instance.expects(:notify_edit).never - - subject.execute(chat_message_id: chat_message.id, reason: "new", timestamp: 1.minute.ago) - end - - it "calls notify_edit when the reason is 'edit'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).once - - subject.execute(chat_message_id: chat_message.id, reason: "edit", timestamp: 1.minute.ago) - end - end - end -end diff --git a/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb b/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb new file mode 100644 index 00000000000..8c844c4fad8 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/update_thread_reply_count_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::Chat::UpdateThreadReplyCount do + fab!(:thread) { Fabricate(:chat_thread) } + fab!(:message_1) { Fabricate(:chat_message, thread: thread) } + fab!(:message_2) { Fabricate(:chat_message, thread: thread) } + + before { Chat::Thread.clear_caches!(thread.id) } + + it "does not error if the thread is deleted" do + id = thread.id + thread.destroy! + expect { described_class.new.execute(thread_id: id) }.not_to raise_error + end + + it "does not set the reply count in the DB if it has been changed recently" do + described_class.new.execute(thread_id: thread.id) + expect(thread.reload.replies_count).to eq(2) + Fabricate(:chat_message, thread: thread) + described_class.new.execute(thread_id: thread.id) + expect(thread.reload.replies_count).to eq(2) + end + + it "sets the updated_at cache to the current time" do + freeze_time + described_class.new.execute(thread_id: thread.id) + expect(thread.replies_count_cache_updated_at).to eq_time( + Time.at(Time.zone.now.to_i, in: Time.zone), + ) + 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 index e420b0a8a58..4abbdc37405 100644 --- a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb @@ -2,18 +2,20 @@ require "rails_helper" -describe Jobs::AutoJoinUsers do +describe Jobs::Chat::AutoJoinUsers do + subject(:job) { described_class.new } + 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) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership).to be_nil - subject.execute({}) + job.execute({}) - membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership.following).to eq(true) end end diff --git a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb similarity index 83% rename from plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb rename to plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb index 79b0ea94c21..d812460bac3 100644 --- a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::DeleteOldChatMessages do +describe Jobs::Chat::DeleteOldMessages do base_date = DateTime.parse("2020-12-01 00:00 UTC") fab!(:public_channel) { Fabricate(:category_channel) } @@ -79,13 +79,16 @@ describe Jobs::DeleteOldChatMessages do ) end - before { freeze_time(base_date) } + before do + freeze_time(base_date) + SiteSetting.chat_enabled = true + end 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 } + expect { described_class.new.execute }.not_to change { Chat::Message.count } end describe "public channels" do @@ -107,7 +110,17 @@ describe Jobs::DeleteOldChatMessages do 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 } + expect { described_class.new.execute }.not_to change { Chat::Message.in_public_channel.count } + end + + context "when chat is disabled" do + before { SiteSetting.chat_enabled = false } + + it "does nothing" do + expect { described_class.new.execute }.not_to change { + Chat::Message.in_public_channel.count + } + end end end @@ -130,7 +143,15 @@ describe Jobs::DeleteOldChatMessages do 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 } + expect { described_class.new.execute }.not_to change { Chat::Message.in_dm_channel.count } + end + + context "when chat is disabled" do + before { SiteSetting.chat_enabled = false } + + it "does nothing" do + expect { described_class.new.execute }.not_to change { Chat::Message.in_dm_channel.count } + end end end end diff --git a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb similarity index 55% rename from plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb rename to plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb index c061288aabd..a0c2975e68e 100644 --- a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -describe Jobs::EmailChatNotifications do +describe Jobs::Chat::EmailNotifications 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) + Chat::Mailer.expects(:send_unread_mentions_summary) - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end @@ -17,9 +17,9 @@ describe Jobs::EmailChatNotifications do before { SiteSetting.chat_enabled = false } it "does nothing" do - Chat::ChatMailer.expects(:send_unread_mentions_summary).never + Chat::Mailer.expects(:send_unread_mentions_summary).never - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end end diff --git a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb similarity index 53% rename from plugins/chat/spec/jobs/chat_periodical_updates_spec.rb rename to plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb index 113a58229ce..5fa751779a3 100644 --- a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatPeriodicalUpdates do +RSpec.describe Jobs::Chat::PeriodicalUpdates do it "works" do # does not blow up, no mocks, everything is called - Jobs::ChatPeriodicalUpdates.new.execute(nil) + Jobs::Chat::PeriodicalUpdates.new.execute(nil) 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 deleted file mode 100644 index 753b7b7bdfa..00000000000 --- a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# 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 similarity index 78% rename from plugins/chat/spec/lib/chat_channel_archive_service_spec.rb rename to plugins/chat/spec/lib/chat/channel_archive_service_spec.rb index d42c0f3d1a9..1911e2fa3c6 100644 --- a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb @@ -2,15 +2,15 @@ require "rails_helper" -describe Chat::ChatChannelArchiveService do +describe Chat::ChannelArchiveService 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 } before { SiteSetting.chat_enabled = true } @@ -18,7 +18,7 @@ describe Chat::ChatChannelArchiveService do before { 3.times { Fabricate(:chat_message, chat_channel: channel) } } it "marks the channel as read_only" do - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -27,12 +27,12 @@ describe Chat::ChatChannelArchiveService do end it "creates the chat channel archive record to save progress and topic params" do - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, ) - channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + channel_archive = Chat::ChannelArchive.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) @@ -42,14 +42,14 @@ describe Chat::ChatChannelArchiveService do it "enqueues the archive job" do channel_archive = - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, ) expect( job_enqueued?( - job: :chat_channel_archive, + job: Jobs::Chat::ChannelArchive, args: { chat_channel_archive_id: channel_archive.id, }, @@ -58,25 +58,25 @@ describe Chat::ChatChannelArchiveService do end it "does nothing if there is already an archive record for the channel" do - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, ) expect { - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, ) - }.not_to change { ChatChannelArchive.count } + }.not_to change { Chat::ChannelArchive.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.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -92,7 +92,7 @@ describe Chat::ChatChannelArchiveService do def start_archive @channel_archive = - subject.create_archive_process( + described_class.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -106,14 +106,14 @@ describe Chat::ChatChannelArchiveService do 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!( + reaction_message = Chat::Message.last + Chat::MessageReaction.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 + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + described_class.new(@channel_archive).execute end @channel_archive.reload @@ -146,21 +146,21 @@ describe Chat::ChatChannelArchiveService do 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 + described_class.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 + UploadReference.create!(target: Chat::Message.last, upload: Fabricate(:upload)) + described_class.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 + described_class.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) @@ -174,7 +174,7 @@ describe Chat::ChatChannelArchiveService do create_messages(3) && start_archive @channel_archive.update!(destination_topic_title: "Wow this is the new title :tada: :joy:") - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(false) expect(@channel_archive.reload.failed?).to eq(true) expect(@channel_archive.archive_error).to eq("Title can't have more than 1 emoji") @@ -191,12 +191,23 @@ describe Chat::ChatChannelArchiveService do it "uses the channel slug to autolink a hashtag for the channel in the PM" do create_messages(3) && start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) pm_topic = Topic.private_messages.last - expect(pm_topic.first_post.cooked).to include( - "#{channel.title(user)}", - ) + expect(pm_topic.first_post.cooked).to have_tag( + "a", + with: { + class: "hashtag-cooked", + href: channel.relative_url, + "data-type": "channel", + "data-slug": channel.slug, + "data-id": channel.id, + "data-ref": "#{channel.slug}::channel", + }, + ) do + with_tag("span", with: { class: "hashtag-icon-placeholder" }) + with_tag("span", text: channel.title(user)) + end end end @@ -207,30 +218,34 @@ describe Chat::ChatChannelArchiveService do .chat_messages .map(&:user) .each do |user| - UserChatChannelMembership.create!(chat_channel: channel, user: user, following: true) + Chat::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, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(3) start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) expect( - UserChatChannelMembership.where(chat_channel: channel, following: true).count, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(0) end it "resets unread state for all users" do - UserChatChannelMembership.last.update!( + Chat::UserChatChannelMembership.last.update!( last_read_message_id: channel.chat_messages.first.id, ) start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) - expect(UserChatChannelMembership.last.last_read_message_id).to eq( + expect(Chat::UserChatChannelMembership.last.last_read_message_id).to eq( channel.chat_messages.last.id, ) end @@ -242,7 +257,7 @@ describe Chat::ChatChannelArchiveService do it "archives the topic" do create_messages(3) && start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute topic = @channel_archive.destination_topic topic.reload expect(topic.archived).to eq(true) @@ -254,7 +269,7 @@ describe Chat::ChatChannelArchiveService do it "leaves the topic open" do create_messages(3) && start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute topic = @channel_archive.destination_topic topic.reload expect(topic.archived).to eq(false) @@ -267,7 +282,7 @@ describe Chat::ChatChannelArchiveService do it "closes the topic" do create_messages(3) && start_archive - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute topic = @channel_archive.destination_topic topic.reload expect(topic.archived).to eq(false) @@ -282,7 +297,7 @@ describe Chat::ChatChannelArchiveService do destination_topic_title: nil, destination_topic_id: Fabricate(:topic).id, ) - subject.new(@channel_archive).execute + described_class.new(@channel_archive).execute topic = @channel_archive.destination_topic topic.reload expect(topic.archived).to eq(false) @@ -300,14 +315,14 @@ describe Chat::ChatChannelArchiveService do 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!( + reaction_message = Chat::Message.last + Chat::MessageReaction.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 + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + described_class.new(@channel_archive).execute end @channel_archive.reload @@ -342,13 +357,13 @@ describe Chat::ChatChannelArchiveService do Rails.logger = @fake_logger = FakeLogger.new create_messages(35) && start_archive - Chat::ChatChannelArchiveService + Chat::ChannelArchiveService .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) + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + expect { described_class.new(@channel_archive).execute }.to raise_error(FakeArchiveError) end expect(@channel_archive.reload.archive_error).to eq("this is a test error") @@ -359,9 +374,9 @@ describe Chat::ChatChannelArchiveService do 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 + Chat::ChannelArchiveService.any_instance.unstub(:create_post) + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + described_class.new(@channel_archive).execute end @channel_archive.reload diff --git a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb similarity index 58% rename from plugins/chat/spec/lib/chat_channel_fetcher_spec.rb rename to plugins/chat/spec/lib/chat/channel_fetcher_spec.rb index 46a1f394197..82de60560e6 100644 --- a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Chat::ChatChannelFetcher do +describe Chat::ChannelFetcher do fab!(:category) { Fabricate(:category, name: "support") } fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } fab!(:category_channel) { Fabricate(:category_channel, chatable: category, slug: "support") } @@ -8,7 +8,8 @@ describe Chat::ChatChannelFetcher do 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!(:chatters) { Fabricate(:group) } + fab!(:user1) { Fabricate(:user, group_ids: [chatters.id]) } fab!(:user2) { Fabricate(:user) } def guardian @@ -16,36 +17,38 @@ describe Chat::ChatChannelFetcher do end def memberships - UserChatChannelMembership.where(user: user1) + Chat::UserChatChannelMembership.where(user: user1) end + before { SiteSetting.chat_allowed_groups = [chatters] } + 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] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) category_channel.closed!(Discourse.system_user) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to be_blank end it "returns followed channel only" do - channels = subject.structured(guardian)[:public_channels] + channels = described_class.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] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) end end - describe ".unread_counts" do + describe ".tracking_state" do context "when user is member of the channel" do before do Fabricate(:user_chat_channel_membership, chat_channel: category_channel, user: user1) @@ -58,15 +61,17 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) - expect(unread_counts[category_channel.id]).to eq(2) + tracking_state = + described_class.tracking_state([category_channel.id], Guardian.new(user1)) + expect(tracking_state.find_channel(category_channel.id).unread_count).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) + tracking_state = + described_class.tracking_state([category_channel.id], Guardian.new(user1)) + expect(tracking_state.find_channel(category_channel.id).unread_count).to eq(0) end end @@ -78,8 +83,9 @@ describe Chat::ChatChannelFetcher do 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) + tracking_state = + described_class.tracking_state([category_channel.id], Guardian.new(user1)) + expect(tracking_state.find_channel(category_channel.id).unread_count).to eq(0) end end end @@ -91,8 +97,9 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) - expect(unread_counts[category_channel.id]).to eq(0) + tracking_state = + described_class.tracking_state([category_channel.id], Guardian.new(user1)) + expect(tracking_state.find_channel(category_channel.id).unread_count).to eq(0) end end end @@ -100,41 +107,45 @@ describe Chat::ChatChannelFetcher do 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([]) + expect(described_class.all_secured_channel_ids(guardian)).to eq([]) end context "when the user has memberships to all the channels" do before do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) - UserChatChannelMembership.create!( + Chat::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], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: Chat::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]) + expect(described_class.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( + Chat::DirectMessageUser.create!(user: user1, direct_message: dm_channel1) + expect(described_class.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 + expect(described_class.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]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end context "when restricted category" do @@ -150,7 +161,7 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:readonly], ), ) - expect(subject.all_secured_channel_ids(guardian)).to be_empty + expect(described_class.all_secured_channel_ids(guardian)).to be_empty end it "includes the category channel for member of group with create_post access" do @@ -162,7 +173,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:create_post], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end it "includes the category channel for member of group with full access" do @@ -174,7 +187,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:full], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end end end @@ -185,15 +200,20 @@ describe Chat::ChatChannelFetcher do it "does not include DM channels" do expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, following: following).map(&:id), ).to match_array([category_channel.id]) end + it "returns an empty array when public channels are disabled" do + SiteSetting.enable_public_channels = false + + expect(described_class.secured_public_channels(guardian, following: nil)).to be_empty + end + it "can filter by channel name, or category name" do expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, - memberships, following: following, filter: "support", ).map(&:id), @@ -202,9 +222,8 @@ describe Chat::ChatChannelFetcher do category_channel.update!(name: "cool stuff") expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, - memberships, following: following, filter: "cool stuff", ).map(&:id), @@ -213,29 +232,29 @@ describe Chat::ChatChannelFetcher do it "can filter by an array of slugs" do expect( - subject.secured_public_channels(guardian, memberships, slugs: ["support"]).map(&:id), + described_class.secured_public_channels(guardian, slugs: ["support"]).map(&:id), ).to match_array([category_channel.id]) end it "returns nothing if the array of slugs is empty" do - expect(subject.secured_public_channels(guardian, memberships, slugs: []).map(&:id)).to eq([]) + expect(described_class.secured_public_channels(guardian, slugs: []).map(&:id)).to eq([]) end it "can filter by status" do expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, status: "closed").map(&:id), ).to match_array([]) category_channel.closed!(Discourse.system_user) expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, 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), + described_class.secured_public_channels(guardian, following: true).map(&:id), ).to be_blank end @@ -244,35 +263,35 @@ describe Chat::ChatChannelFetcher do another_channel = Fabricate(:category_channel) expect( - subject.secured_public_channels(guardian, memberships, following: false).map(&:id), + described_class.secured_public_channels(guardian, 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), + described_class.secured_public_channels(guardian, 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), + described_class.secured_public_channels(guardian, 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 = Chat::ChannelFetcher::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) + expect(described_class.secured_public_channels(guardian, limit: over_limit).length).to eq( + Chat::ChannelFetcher::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), + described_class.secured_public_channels(guardian, following: following).map(&:id), ).to be_empty end @@ -281,22 +300,22 @@ describe Chat::ChatChannelFetcher do 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), + described_class.secured_public_channels(guardian, following: following).map(&:id), ).to be_empty - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, following: following).map(&:id), ).to match_array([category_channel.id]) end it "includes the unread count based on mute settings" do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, @@ -306,53 +325,49 @@ describe Chat::ChatChannelFetcher do Fabricate(:chat_message, user: user2, chat_channel: category_channel) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + result = + described_class.tracking_state(resolved_memberships.map(&:chat_channel_id), guardian) - expect( - resolved_memberships - .find { |membership| membership.chat_channel_id == category_channel.id } - .unread_count, - ).to eq(2) - - resolved_memberships.last.update!(muted: true) + expect(result.channel_tracking[category_channel.id][:unread_count]).to eq(2) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + resolved_memberships.last.update!(muted: true) + result = + described_class.tracking_state(resolved_memberships.map(&:chat_channel_id), guardian) - expect( - resolved_memberships - .find { |membership| membership.chat_channel_id == category_channel.id } - .unread_count, - ).to eq(0) + expect(result.channel_tracking[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 + describe ".secured_direct_message_channels" do + it "includes direct message channels the user is a member of ordered by last_message.created_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) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::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) + Chat::DirectMessageUser.create!(direct_message: dm_channel2, user: user1) + Chat::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) + Fabricate(:chat_message, user: user1, chat_channel: direct_message_channel1) + Fabricate(:chat_message, user: user1, chat_channel: direct_message_channel2) - expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), - ).to eq([direct_message_channel2.id, direct_message_channel1.id]) + direct_message_channel1.last_message.update!(created_at: 1.day.ago) + direct_message_channel2.last_message.update!(created_at: 1.hour.ago) + + expect(described_class.secured_direct_message_channels(user1.id, 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 @@ -362,10 +377,10 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + described_class.secured_direct_message_channels(user1.id, guardian).map(&:id), ).not_to include(direct_message_channel1.id) end @@ -377,44 +392,46 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user1) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::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) + result = described_class.tracking_state([direct_message_channel1.id], guardian) + expect(result.channel_tracking[target_membership.chat_channel_id][: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) + result = described_class.tracking_state([direct_message_channel1.id], guardian) + expect(result.channel_tracking[target_membership.chat_channel_id][: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, - ) + expect { + described_class.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, - ) + expect { + described_class.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) + expect(described_class.find_with_access_check(category_channel.id, guardian)).to eq( + category_channel, + ) end end end diff --git a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb similarity index 92% rename from plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb rename to plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb index cf7de252b76..87c44e90e1c 100644 --- a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelHashtagDataSource do +RSpec.describe Chat::ChannelHashtagDataSource do fab!(:user) { Fabricate(:user) } fab!(:category) { Fabricate(:category) } fab!(:group) { Fabricate(:group) } @@ -32,6 +32,18 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do Group.refresh_automatic_groups! end + describe "#enabled?" do + it "returns false if public channels are disabled" do + SiteSetting.enable_public_channels = false + expect(described_class.enabled?).to eq(false) + end + + it "returns true if public channels are disabled" do + SiteSetting.enable_public_channels = true + expect(described_class.enabled?).to eq(true) + end + end + describe "#lookup" do it "finds a channel by a slug" do result = described_class.lookup(guardian, ["random"]).first @@ -41,6 +53,7 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do text: "Zany Things", description: "Just weird stuff", icon: "comment", + id: channel1.id, type: "channel", ref: nil, slug: "random", @@ -60,6 +73,7 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do text: "Secret Stuff", description: nil, icon: "comment", + id: channel2.id, type: "channel", ref: nil, slug: "secret", @@ -94,6 +108,7 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do text: "Zany Things", description: "Just weird stuff", icon: "comment", + id: channel1.id, type: "channel", ref: nil, slug: "random", @@ -109,6 +124,7 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do text: "Zany Things", description: "Just weird stuff", icon: "comment", + id: channel1.id, type: "channel", ref: nil, slug: "random", @@ -127,6 +143,7 @@ RSpec.describe Chat::ChatChannelHashtagDataSource do text: "Secret Stuff", description: nil, icon: "comment", + id: channel2.id, type: "channel", ref: nil, slug: "secret", diff --git a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb similarity index 86% rename from plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb rename to plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb index f5c9c694792..ac96a8fb979 100644 --- a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelMembershipManager do +RSpec.describe Chat::ChannelMembershipManager do fab!(:user) { Fabricate(:user) } fab!(:channel1) { Fabricate(:category_channel) } fab!(:channel2) { Fabricate(:category_channel) } @@ -31,7 +31,7 @@ RSpec.describe Chat::ChatChannelMembershipManager 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 + Chat::UserChatChannelMembership.count }.by(1) expect(membership.following).to eq(true) expect(membership.chat_channel).to eq(channel1) @@ -41,7 +41,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do 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 }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "updates the membership to following if it already existed" do @@ -53,7 +58,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect { membership = described_class.new(channel1).follow(user) }.not_to change { - UserChatChannelMembership.count + Chat::UserChatChannelMembership.count } expect(membership.reload.following).to eq(true) end @@ -76,7 +81,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do 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 }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "does not recalculate user count if the user was already not following the channel" do @@ -88,7 +98,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect_not_enqueued_with( - job: :update_channel_user_count, + job: Jobs::Chat::UpdateChannelUserCount, args: { chat_channel_id: channel1.id, }, diff --git a/plugins/chat/spec/lib/duplicate_message_validator_spec.rb b/plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb similarity index 100% rename from plugins/chat/spec/lib/duplicate_message_validator_spec.rb rename to plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb diff --git a/plugins/chat/spec/lib/guardian_extensions_spec.rb b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb similarity index 77% rename from plugins/chat/spec/lib/guardian_extensions_spec.rb rename to plugins/chat/spec/lib/chat/guardian_extensions_spec.rb index e2e4c4cbc70..2501d208e13 100644 --- a/plugins/chat/spec/lib/guardian_extensions_spec.rb +++ b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb @@ -3,7 +3,8 @@ require "rails_helper" RSpec.describe Chat::GuardianExtensions do - fab!(:user) { Fabricate(:user) } + fab!(:chatters) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, group_ids: [chatters.id]) } fab!(:staff) { Fabricate(:user, admin: true) } fab!(:chat_group) { Fabricate(:group) } fab!(:channel) { Fabricate(:category_channel) } @@ -11,6 +12,8 @@ RSpec.describe Chat::GuardianExtensions do let(:guardian) { Guardian.new(user) } let(:staff_guardian) { Guardian.new(staff) } + before { SiteSetting.chat_allowed_groups = [chatters] } + it "cannot chat if the user is not in the Chat.allowed_group_ids" do SiteSetting.chat_allowed_groups = "" expect(guardian.can_chat?).to eq(false) @@ -82,7 +85,7 @@ RSpec.describe Chat::GuardianExtensions do end it "returns true if the user is part of the direct message" do - DirectMessageUser.create!(user: user, direct_message: chatable) + Chat::DirectMessageUser.create!(user: user, direct_message: chatable) expect(guardian.can_join_chat_channel?(channel)).to eq(true) end end @@ -125,6 +128,112 @@ RSpec.describe Chat::GuardianExtensions do end end + describe "#can_post_in_chatable?" do + alias_matcher :be_able_to_post_in_chatable, :be_can_post_in_chatable + + context "when channel is a category channel" do + context "when post_allowed_category_ids given" do + context "when no chatable given" do + it "returns false" do + expect(guardian).not_to be_able_to_post_in_chatable( + nil, + post_allowed_category_ids: [channel.chatable.id], + ) + end + end + + context "when user is anonymous" do + it "returns false" do + expect(Guardian.new).not_to be_able_to_post_in_chatable( + channel.chatable, + post_allowed_category_ids: [channel.chatable.id], + ) + end + end + + context "when user is admin" do + it "returns true" do + guardian = Fabricate(:admin).guardian + expect(guardian).to be_able_to_post_in_chatable( + channel.chatable, + post_allowed_category_ids: [channel.chatable.id], + ) + end + end + + context "when chatable id is part of allowed ids" do + it "returns true" do + expect(guardian).to be_able_to_post_in_chatable( + channel.chatable, + post_allowed_category_ids: [channel.chatable.id], + ) + end + end + + context "when chatable id is not part of allowed ids" do + it "returns false" do + expect(guardian).not_to be_able_to_post_in_chatable( + channel.chatable, + post_allowed_category_ids: [-1], + ) + end + end + end + + context "when no post_allowed_category_ids given" do + context "when no chatable given" do + it "returns false" do + expect(guardian).not_to be_able_to_post_in_chatable(nil) + end + end + + context "when user is anonymous" do + it "returns false" do + expect(Guardian.new).not_to be_able_to_post_in_chatable(channel.chatable) + end + end + + context "when user is admin" do + it "returns true" do + guardian = Fabricate(:admin).guardian + expect(guardian).to be_able_to_post_in_chatable(channel.chatable) + end + end + + context "when chatable id is part of allowed ids" do + it "returns true" do + expect(guardian).to be_able_to_post_in_chatable(channel.chatable) + end + end + + context "when user can't post in chatable" do + fab!(:group) { Fabricate(:group) } + fab!(:channel) { Fabricate(:private_category_channel, group: group) } + + before do + channel.chatable.category_groups.first.update!( + permission_type: CategoryGroup.permission_types[:readonly], + ) + group.add(user) + channel.add(user) + end + + it "returns false" do + expect(guardian).not_to be_able_to_post_in_chatable(channel.chatable) + end + end + end + end + + context "when channel is a direct message channel" do + let(:channel) { Fabricate(:direct_message_channel) } + + it "returns true" do + expect(guardian).to be_able_to_post_in_chatable(channel.chatable) + 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 @@ -206,7 +315,7 @@ RSpec.describe Chat::GuardianExtensions do end context "for DM channel" do - fab!(:dm_channel) { DirectMessage.create! } + fab!(:dm_channel) { Chat::DirectMessage.create! } before { channel.update(chatable_type: "DirectMessageType", chatable: dm_channel) } @@ -234,12 +343,18 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } - it "allows owner to restore" do + it "allows owner to restore when deleted by owner" do + message.trash!(guardian.user) expect(guardian.can_restore_chat?(message, chatable)).to eq(true) end + it "disallow owner to restore when deleted by staff" do + message.trash!(staff_guardian.user) + 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 @@ -284,7 +399,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows staff to restore" do expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) @@ -317,15 +432,21 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::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 + it "allows owner to restore when deleted by owner" do + message.trash!(guardian.user) expect(guardian.can_restore_chat?(message, chatable)).to eq(true) end + + it "disallow owner to restore when deleted by staff" do + message.trash!(staff_guardian.user) + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end 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 similarity index 61% rename from plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb rename to plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb index a78aa55588c..f5d050dcf77 100644 --- a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb +++ b/plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb @@ -2,8 +2,11 @@ require "rails_helper" -describe ChatMessageBookmarkable do - fab!(:user) { Fabricate(:user) } +describe Chat::MessageBookmarkable do + subject(:registered_bookmarkable) { RegisteredBookmarkable.new(described_class) } + + fab!(:chatters) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, group_ids: [chatters.id]) } fab!(:guardian) { Guardian.new(user) } fab!(:other_category) { Fabricate(:private_category, group: Fabricate(:group)) } fab!(:category_channel) { Fabricate(:category_channel, chatable: other_category) } @@ -11,10 +14,13 @@ describe ChatMessageBookmarkable do fab!(:channel) { Fabricate(:category_channel) } before do - Bookmark.register_bookmarkable(ChatMessageBookmarkable) - UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + register_test_bookmarkable(described_class) + Chat::UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + SiteSetting.chat_allowed_groups = [chatters] end + after { DiscoursePluginRegistry.reset_register!(:bookmarkables) } + let!(:message1) { Fabricate(:chat_message, chat_channel: channel) } let!(:message2) { Fabricate(:chat_message, chat_channel: channel) } let!(:bookmark1) do @@ -23,23 +29,21 @@ describe ChatMessageBookmarkable do 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( + expect(registered_bookmarkable.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) + expect(registered_bookmarkable.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( + expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array( [bookmark1.id, bookmark2.id], ) end @@ -47,12 +51,12 @@ describe ChatMessageBookmarkable do 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) + expect(registered_bookmarkable.perform_list_query(user, guardian)).to eq(nil) + Chat::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( + expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array( [bookmark1.id, bookmark2.id], ) end @@ -60,7 +64,9 @@ describe ChatMessageBookmarkable do 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]) + expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark2.id], + ) end end @@ -70,8 +76,8 @@ describe ChatMessageBookmarkable do 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), + registered_bookmarkable.perform_search_query( + registered_bookmarkable.perform_list_query(user, guardian), "%gotta%", ts_query, ).map(&:id), @@ -83,8 +89,8 @@ describe ChatMessageBookmarkable do ts_query = Search.ts_query(term: "good soup", ts_config: "simple") expect( - subject.perform_search_query( - subject.perform_list_query(user, guardian), + registered_bookmarkable.perform_search_query( + registered_bookmarkable.perform_list_query(user, guardian), "%good soup%", ts_query, ).map(&:id), @@ -92,8 +98,8 @@ describe ChatMessageBookmarkable do ts_query = Search.ts_query(term: "blah", ts_config: "simple") expect( - subject.perform_search_query( - subject.perform_list_query(user, guardian), + registered_bookmarkable.perform_search_query( + registered_bookmarkable.perform_list_query(user, guardian), "%blah%", ts_query, ).map(&:id), @@ -103,23 +109,23 @@ describe ChatMessageBookmarkable do 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) + expect(registered_bookmarkable.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! + expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false) + Chat::Message.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) + expect(registered_bookmarkable.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, - ) + expect { registered_bookmarkable.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( @@ -140,38 +146,44 @@ describe ChatMessageBookmarkable do 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) + expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(true) bookmark1.bookmarkable.chat_channel.update!(chatable: private_category) - expect(subject.can_see?(guardian, bookmark1)).to eq(false) + expect(registered_bookmarkable.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) + expect(registered_bookmarkable.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 + expect { + registered_bookmarkable.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, - ) + expect { + registered_bookmarkable.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 + expect { + registered_bookmarkable.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 + expect { + registered_bookmarkable.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, - ) + expect { + registered_bookmarkable.validate_before_create(guardian, bookmark1.bookmarkable) + }.to raise_error(Discourse::InvalidAccess) end end @@ -180,7 +192,7 @@ describe ChatMessageBookmarkable do bookmark_post = Fabricate(:bookmark, bookmarkable: Fabricate(:post)) bookmark1.bookmarkable.trash! bookmark1.bookmarkable.update!(deleted_at: 4.days.ago) - subject.cleanup_deleted + registered_bookmarkable.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) diff --git a/plugins/chat/spec/lib/chat/message_mover_spec.rb b/plugins/chat/spec/lib/chat/message_mover_spec.rb new file mode 100644 index 00000000000..c458a7d749f --- /dev/null +++ b/plugins/chat/spec/lib/chat/message_mover_spec.rb @@ -0,0 +1,265 @@ +# 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] } + + describe "#move_to_channel" do + def move!(move_message_ids = [message1.id, message2.id, message3.id]) + described_class.new( + acting_user: acting_user, + source_channel: source_channel, + message_ids: move_message_ids, + ).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(Chat::Message.where(id: move_message_ids)).to eq([]) + deleted_messages = Chat::Message.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["type"]).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 = + Chat::Message.where(chat_channel: source_channel).order(:created_at).last + destination_first_moved_message = + Chat::Message.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 = + Chat::Message + .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(:upload_reference, target: 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 = + Chat::Message + .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.target_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 + + it "does not preserve reply chains using in_reply_to_id" do + message3.update!(in_reply_to: message2) + message2.update!(in_reply_to: message1) + move! + moved_messages = + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) + + expect(moved_messages.pluck(:in_reply_to_id).uniq).to eq([nil]) + end + + it "clears in_reply_to_id for remaining messages when the messages they were replying to are moved" do + message3.update!(in_reply_to: message2) + message2.update!(in_reply_to: message1) + move!([message2.id]) + expect(message3.reload.in_reply_to_id).to eq(nil) + end + + context "when there is a thread" do + fab!(:thread) { Fabricate(:chat_thread, channel: source_channel, original_message: message1) } + + before do + message1.update!(thread: thread) + message2.update!(thread: thread) + message3.update!(thread: thread) + end + + it "does not preserve thread_ids" do + move! + moved_messages = + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) + + expect(moved_messages.pluck(:thread_id).uniq).to eq([nil]) + end + + it "deletes the empty thread" do + move! + expect(Chat::Thread.exists?(id: thread.id)).to eq(false) + end + + it "clears in_reply_to_id for remaining messages when the messages they were replying to are moved but leaves the thread_id" do + message3.update!(in_reply_to: message2) + message2.update!(in_reply_to: message1) + move!([message2.id]) + expect(message3.reload.in_reply_to_id).to eq(nil) + expect(message3.reload.thread).to eq(thread) + end + + it "updates the tracking to the last non-deleted channel message for users whose last_read_message_id was the moved message" do + membership_1 = + Fabricate( + :user_chat_channel_membership, + chat_channel: source_channel, + last_read_message: message1, + ) + membership_2 = + Fabricate( + :user_chat_channel_membership, + chat_channel: source_channel, + last_read_message: message2, + ) + membership_3 = + Fabricate( + :user_chat_channel_membership, + chat_channel: source_channel, + last_read_message: message3, + ) + move!([message2.id]) + expect(membership_1.reload.last_read_message_id).to eq(message1.id) + expect(membership_2.reload.last_read_message_id).to eq(message3.id) + expect(membership_3.reload.last_read_message_id).to eq(message3.id) + end + + context "when a thread original message is moved" do + it "creates a new thread for the messages left behind in the old channel" do + message4 = + Fabricate( + :chat_message, + chat_channel: source_channel, + message: "the fourth message", + in_reply_to: message3, + thread: thread, + ) + message5 = + Fabricate( + :chat_message, + chat_channel: source_channel, + message: "the fifth message", + thread: thread, + ) + expect { move! }.to change { Chat::Thread.count }.by(1) + new_thread = Chat::Thread.last + expect(message4.reload.thread_id).to eq(new_thread.id) + expect(message5.reload.thread_id).to eq(new_thread.id) + expect(new_thread.channel).to eq(source_channel) + expect(new_thread.original_message).to eq(message4) + end + end + + context "when multiple thread original messages are moved" do + it "works the same as when one is" do + message4 = + Fabricate(:chat_message, chat_channel: source_channel, message: "the fourth message") + message5 = + Fabricate( + :chat_message, + chat_channel: source_channel, + in_reply_to: message5, + message: "the fifth message", + ) + other_thread = + Fabricate(:chat_thread, channel: source_channel, original_message: message4) + message4.update!(thread: other_thread) + message5.update!(thread: other_thread) + expect { move!([message1.id, message4.id]) }.to change { Chat::Thread.count }.by(2) + + new_threads = Chat::Thread.order(:created_at).last(2) + expect(message3.reload.thread_id).to eq(new_threads.first.id) + expect(message5.reload.thread_id).to eq(new_threads.second.id) + expect(new_threads.first.channel).to eq(source_channel) + expect(new_threads.second.channel).to eq(source_channel) + expect(new_threads.first.original_message).to eq(message2) + expect(new_threads.second.original_message).to eq(message5) + end + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_message_processor_spec.rb b/plugins/chat/spec/lib/chat/message_processor_spec.rb similarity index 57% rename from plugins/chat/spec/lib/chat_message_processor_spec.rb rename to plugins/chat/spec/lib/chat/message_processor_spec.rb index 3fdbd19baa7..2fb51caf257 100644 --- a/plugins/chat/spec/lib/chat_message_processor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_processor_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatMessageProcessor do +RSpec.describe Chat::MessageProcessor do fab!(:message) { Fabricate(:chat_message) } it "cooks using the last_editor_id of the message" do - ChatMessage.expects(:cook).with(message.message, user_id: message.last_editor_id) + Chat::Message.expects(:cook).with(message.message, user_id: message.last_editor_id) described_class.new(message) end end diff --git a/plugins/chat/spec/lib/chat_message_reactor_spec.rb b/plugins/chat/spec/lib/chat/message_reactor_spec.rb similarity index 67% rename from plugins/chat/spec/lib/chat_message_reactor_spec.rb rename to plugins/chat/spec/lib/chat/message_reactor_spec.rb index 565fab80db1..6efe970e746 100644 --- a/plugins/chat/spec/lib/chat_message_reactor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_reactor_spec.rb @@ -2,41 +2,44 @@ require "rails_helper" -describe Chat::ChatMessageReactor do +describe Chat::MessageReactor do + subject(:message_reactor) { described_class.new(reacting_user, channel) } + 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) } + + before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] } it "calls guardian ensure_can_join_chat_channel!" do Guardian.any_instance.expects(:ensure_can_join_chat_channel!).once - subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + message_reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") end 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:") + message_reactor.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:") + message_reactor.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]) + channel.update!(status: Chat::Channel.statuses[:archived]) expect { - subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + message_reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") }.to raise_error(Discourse::InvalidAccess) - channel.update!(status: ChatChannel.statuses[:open]) + channel.update!(status: Chat::Channel.statuses[:open]) expect { - subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") - }.to change(ChatMessageReaction, :count).by(1) + message_reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to change(Chat::MessageReaction, :count).by(1) end it "raises an error if the reaction is not valid" do @@ -59,9 +62,9 @@ describe Chat::ChatMessageReactor do context "when max reactions has been reached" do before do - emojis = Emoji.all.slice(0, Chat::ChatMessageReactor::MAX_REACTIONS_LIMIT) + emojis = Emoji.all.slice(0, described_class::MAX_REACTIONS_LIMIT) emojis.each do |emoji| - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message_1, user: reacting_user, emoji: ":#{emoji.name}:", @@ -93,47 +96,51 @@ describe Chat::ChatMessageReactor do 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) + }.to change(Chat::UserChatChannelMembership, :count).by(1) end it "doesn’t create a membership when present" do - UserChatChannelMembership.create!(user: reacting_user, chat_channel: channel, following: true) + Chat::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) + }.not_to change(Chat::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) + }.to change(Chat::MessageReaction, :count).by(1) end it "doesn’t duplicate reactions" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.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) + }.not_to change(Chat::MessageReaction, :count) end it "can remove an existing reaction" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.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) + }.to change(Chat::MessageReaction, :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) + }.not_to change(Chat::MessageReaction, :count) end it "publishes the reaction" do - ChatPublisher.expects(:publish_reaction!).once + Chat::Publisher.expects(:publish_reaction!).once reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") end diff --git a/plugins/chat/spec/lib/chat/messages_exporter_spec.rb b/plugins/chat/spec/lib/chat/messages_exporter_spec.rb new file mode 100644 index 00000000000..8c2274e5f31 --- /dev/null +++ b/plugins/chat/spec/lib/chat/messages_exporter_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +describe Chat::MessagesExporter do + fab!(:public_channel) { Fabricate(:chat_channel) } + fab!(:public_channel_message_1) { Fabricate(:chat_message, chat_channel: public_channel) } + fab!(:public_channel_message_2) { Fabricate(:chat_message, chat_channel: public_channel) } + # this message is deleted in the before block: + fab!(:deleted_message) { Fabricate(:chat_message, chat_channel: public_channel) } + + fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) } + fab!(:private_channel_message_1) { Fabricate(:chat_message, chat_channel: private_channel) } + fab!(:private_channel_message_2) { Fabricate(:chat_message, chat_channel: private_channel) } + + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } + fab!(:direct_message_1) { Fabricate(:chat_message, chat_channel: private_channel, user: user_1) } + fab!(:direct_message_2) { Fabricate(:chat_message, chat_channel: private_channel, user: user_2) } + + before { deleted_message.trash! } + + it "exports messages" do + exporter = Class.new.extend(Chat::MessagesExporter) + + result = [] + exporter.chat_message_export { |data_row| result << data_row } + + expect(result.length).to be(7) + assert_exported_message(result[0], public_channel_message_1) + assert_exported_message(result[1], public_channel_message_2) + assert_exported_message(result[2], deleted_message) + assert_exported_message(result[3], private_channel_message_1) + assert_exported_message(result[4], private_channel_message_2) + assert_exported_message(result[5], direct_message_1) + assert_exported_message(result[6], direct_message_2) + end + + def assert_exported_message(data_row, message) + expect(data_row[0]).to eq(message.id) + expect(data_row[1]).to eq(message.chat_channel.id) + expect(data_row[2]).to eq(message.chat_channel.name) + expect(data_row[3]).to eq(message.user.id) + expect(data_row[4]).to eq(message.user.username) + expect(data_row[5]).to eq(message.message) + expect(data_row[6]).to eq(message.cooked) + expect(data_row[7]).to eq_time(message.created_at) + expect(data_row[8]).to eq_time(message.updated_at) + expect(data_row[9]).to eq_time(message.deleted_at) + expect(data_row[10]).to eq(message.in_reply_to_id) + expect(data_row[11]).to eq(message.last_editor.id) + expect(data_row[12]).to eq(message.last_editor.username) + end +end diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat/notifier_spec.rb similarity index 95% rename from plugins/chat/spec/lib/chat_notifier_spec.rb rename to plugins/chat/spec/lib/chat/notifier_spec.rb index e9f413cfd68..f892bcca6a4 100644 --- a/plugins/chat/spec/lib/chat_notifier_spec.rb +++ b/plugins/chat/spec/lib/chat/notifier_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatNotifier do +describe Chat::Notifier do describe "#notify_new" do fab!(:channel) { Fabricate(:category_channel) } fab!(:user_1) { Fabricate(:user) } @@ -23,7 +23,7 @@ describe Chat::ChatNotifier do end def build_cooked_msg(message_body, user, chat_channel: channel) - ChatMessage.new( + Chat::Message.create( chat_channel: chat_channel, user: user, message: message_body, @@ -121,6 +121,28 @@ describe Chat::ChatNotifier do include_examples "channel-wide mentions" include_examples "ensure only channel members are notified" + describe "editing a direct mention into a global mention" do + let(:mention) { "hello @#{user_2.username}!" } + + it "doesn't send notifications with :all_mentioned_user_ids as an identifier" do + Jobs.run_immediately! + msg = build_cooked_msg(mention, user_1) + + Chat::MessageUpdater.update( + guardian: user_1.guardian, + chat_message: msg, + new_content: "hello @all", + ) + + described_class.new(msg, msg.created_at).notify_edit + + notifications = Notification.where(user: user_2) + notifications.each do |notification| + expect(notification.data).not_to include("\"identifier\":\"all_mentioned_user_ids\"") + end + end + 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) @@ -233,7 +255,7 @@ describe Chat::ChatNotifier 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 + Chat::Publisher.expects(:publish_new_mention).never to_notify = described_class.new(msg, msg.created_at).notify_new end @@ -386,10 +408,13 @@ describe Chat::ChatNotifier do 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], - ) + result = + Chat::CreateDirectMessageChannel.call( + guardian: user_1.guardian, + target_usernames: [user_1.username, user_2.username], + ) + service_failed!(result) if result.failure? + result.channel end before { @chat_group.add(user_3) } diff --git a/plugins/chat/spec/lib/chat/parsed_mentions_spec.rb b/plugins/chat/spec/lib/chat/parsed_mentions_spec.rb new file mode 100644 index 00000000000..f03ca7850d0 --- /dev/null +++ b/plugins/chat/spec/lib/chat/parsed_mentions_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ParsedMentions do + fab!(:channel_member_1) { Fabricate(:user) } + fab!(:channel_member_2) { Fabricate(:user) } + fab!(:channel_member_3) { Fabricate(:user) } + fab!(:not_a_channel_member) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:chat_channel) } + + before do + chat_channel.add(channel_member_1) + chat_channel.add(channel_member_2) + chat_channel.add(channel_member_3) + end + + describe "#global_mentions" do + it "returns all members of the channel" do + message = create_message("mentioning @all") + + mentions = described_class.new(message) + result = mentions.global_mentions.pluck(:username) + + expect(result).to contain_exactly( + channel_member_1.username, + channel_member_2.username, + channel_member_3.username, + ) + end + + it "doesn't include users that were also mentioned directly" do + message = create_message("mentioning @all and @#{channel_member_1.username}") + + mentions = described_class.new(message) + result = mentions.global_mentions.pluck(:username) + + expect(result).to contain_exactly(channel_member_2.username, channel_member_3.username) + end + + it "returns an empty list if there are no global mentions" do + message = create_message("not mentioning anybody") + + mentions = described_class.new(message) + result = mentions.global_mentions.pluck(:username) + + expect(result).to be_empty + end + end + + describe "#here_mentions" do + before do + freeze_time + channel_member_1.update(last_seen_at: 2.minutes.ago) + channel_member_2.update(last_seen_at: 2.minutes.ago) + channel_member_3.update(last_seen_at: 5.minutes.ago) + end + + it "returns all members of the channel who were online in the last 5 minutes" do + message = create_message("mentioning @here") + + mentions = described_class.new(message) + result = mentions.here_mentions.pluck(:username) + + expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) + end + + it "doesn't include users that were also mentioned directly" do + message = create_message("mentioning @here and @#{channel_member_1.username}") + + mentions = described_class.new(message) + result = mentions.here_mentions.pluck(:username) + + expect(result).to contain_exactly(channel_member_2.username) + end + + it "returns an empty list if there are no here mentions" do + message = create_message("not mentioning anybody") + + mentions = described_class.new(message) + result = mentions.here_mentions.pluck(:username) + + expect(result).to be_empty + end + end + + describe "#direct_mentions" do + it "returns users who were mentioned directly" do + message = + create_message("mentioning @#{channel_member_1.username} and @#{channel_member_2.username}") + + mentions = described_class.new(message) + result = mentions.direct_mentions.pluck(:username) + + expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) + end + + it "returns a user when self-mentioning" do + message = + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "Hey @#{channel_member_1.username}", + user: channel_member_1, + ) + + mentions = described_class.new(message) + + result = mentions.direct_mentions.pluck(:username) + expect(result).to contain_exactly(channel_member_1.username) + end + + it "returns a mentioned user even if he's not a member of the channel" do + message = create_message("mentioning @#{not_a_channel_member.username}") + + mentions = described_class.new(message) + result = mentions.direct_mentions.pluck(:username) + + expect(result).to contain_exactly(not_a_channel_member.username) + end + + it "returns an empty list if no one was mentioned directly" do + message = create_message("not mentioning anybody") + + mentions = described_class.new(message) + result = mentions.direct_mentions.pluck(:username) + + expect(result).to be_empty + end + end + + describe "#group_mentions" do + fab!(:group1) { Fabricate(:group, mentionable_level: Group::ALIAS_LEVELS[:everyone]) } + fab!(:group_member_1) { Fabricate(:user, group_ids: [group1.id]) } + fab!(:group_member_2) { Fabricate(:user, group_ids: [group1.id]) } + fab!(:group_member_3) { Fabricate(:user, group_ids: [group1.id]) } + + before do + chat_channel.add(group_member_1) + chat_channel.add(group_member_2) + end + + it "returns members of a mentioned group even if some of them is not members of the channel" do + message = create_message("mentioning @#{group1.name}") + + mentions = described_class.new(message) + result = mentions.group_mentions.pluck(:username) + + expect(result).to contain_exactly( + group_member_1.username, + group_member_2.username, + group_member_3.username, + ) + end + + it "returns an empty list if no group was mentioned" do + message = create_message("not mentioning anyone") + + mentions = described_class.new(message) + result = mentions.group_mentions.pluck(:username) + + expect(result).to be_empty + end + + it "returns an empty list when mentioning an unmentionable group" do + group1.mentionable_level = Group::ALIAS_LEVELS[:nobody] + group1.save! + message = create_message("mentioning @#{group1.name}") + + mentions = described_class.new(message) + result = mentions.group_mentions.pluck(:username) + + expect(result).to be_empty + end + end + + def create_message(text) + Fabricate(:chat_message, chat_channel: chat_channel, message: text) + end +end diff --git a/plugins/chat/spec/lib/post_notification_handler_spec.rb b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb similarity index 90% rename from plugins/chat/spec/lib/post_notification_handler_spec.rb rename to plugins/chat/spec/lib/chat/post_notification_handler_spec.rb index 620fe991e0c..47705958594 100644 --- a/plugins/chat/spec/lib/post_notification_handler_spec.rb +++ b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb @@ -3,10 +3,11 @@ require "rails_helper" describe Chat::PostNotificationHandler do + subject(:handler) { described_class.new(post, notified_users) } + 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 @@ -24,13 +25,13 @@ describe Chat::PostNotificationHandler do def expect_no_notification return_val = nil - expect { return_val = subject.handle }.not_to change { Notification.count } + expect { return_val = handler.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 + Chat::TranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown post.update!(raw: post.raw + "\n\n" + quote_markdown) end @@ -51,7 +52,7 @@ describe Chat::PostNotificationHandler do it "sends notifications to all of the quoted users" do update_post_with_chat_quote([message1, message2]) - subject.handle + handler.handle expect( Notification.where( user: message1.user, @@ -68,8 +69,8 @@ describe Chat::PostNotificationHandler do 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 + handler.handle + handler.handle expect( Notification.where( user: message1.user, @@ -87,7 +88,7 @@ describe Chat::PostNotificationHandler do topic: post.topic, user: message1.user, ) - subject.handle + handler.handle expect( Notification.where( user: message1.user, @@ -101,7 +102,7 @@ describe Chat::PostNotificationHandler do it "does not send notifications to those users" do update_post_with_chat_quote([message1, message2]) - subject.handle + handler.handle expect( Notification.where( user: message1.user, diff --git a/plugins/chat/spec/lib/chat_review_queue_spec.rb b/plugins/chat/spec/lib/chat/review_queue_spec.rb similarity index 91% rename from plugins/chat/spec/lib/chat_review_queue_spec.rb rename to plugins/chat/spec/lib/chat/review_queue_spec.rb index 5559543c52a..01b560ac6dd 100644 --- a/plugins/chat/spec/lib/chat_review_queue_spec.rb +++ b/plugins/chat/spec/lib/chat/review_queue_spec.rb @@ -2,18 +2,18 @@ require "rails_helper" -describe Chat::ChatReviewQueue do +describe Chat::ReviewQueue do + subject(:queue) { described_class.new } + 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) @@ -32,7 +32,7 @@ describe Chat::ChatReviewQueue do it "stores the message cooked content inside the reviewable" do queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["message_cooked"]).to eq(message.cooked) end @@ -73,7 +73,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(message, admin_guardian, ReviewableScore.types[:off_topic]) expect(second_flag_result).to include success: true - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(2) @@ -99,7 +99,7 @@ describe Chat::ChatReviewQueue do before do queue.flag_message(message, guardian, ReviewableScore.types[:spam]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last reviewable.perform(admin, :ignore) end @@ -109,14 +109,14 @@ describe Chat::ChatReviewQueue do end it "allows the user to re-flag after the cooldown period" do - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.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( + Chat::MessageUpdater.update( guardian: Guardian.new(message.user), chat_message: message, new_content: "I'm editing this message. Please flag it.", @@ -157,7 +157,7 @@ describe Chat::ChatReviewQueue do .map(&:data) flag_msg = messages.detect { |m| m["type"] == "flag" } - new_reviewable = ReviewableChatMessage.find_by(target: message) + new_reviewable = Chat::ReviewableMessage.find_by(target: message) expect(flag_msg["chat_message_id"]).to eq(message.id) expect(flag_msg["reviewable_id"]).to eq(new_reviewable.id) @@ -261,7 +261,7 @@ describe Chat::ChatReviewQueue do take_action: true, ) - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) expect(reviewable.approved?).to eq(true) expect(message.reload.trashed?).to eq(true) @@ -280,15 +280,16 @@ describe Chat::ChatReviewQueue do end .map(&:data) - delete_msg = messages.detect { |m| m[:type] == "delete" } + delete_msg = messages.detect { |m| m["type"] == "delete" } - expect(delete_msg[:deleted_id]).to eq(message.id) + 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) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(1) @@ -323,7 +324,8 @@ describe Chat::ChatReviewQueue do queue_for_review: true, ) - reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) score = reviewable.reviewable_scores.first expect(score.reason).to eq("chat_message_queued_by_staff") @@ -367,6 +369,18 @@ describe Chat::ChatReviewQueue do expect(message_poster.reload.silenced?).to eq(false) end + + context "when the target is an admin" do + it "does not silence the user" do + SiteSetting.chat_auto_silence_from_flags_duration = 1 + flagger.update!(trust_level: TrustLevel[4]) # Increase Score due to TL Bonus. + message_poster.update!(admin: true) + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(false) + end + end end context "when flagging a DM" do @@ -397,8 +411,7 @@ describe Chat::ChatReviewQueue do 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 + reviewable = Chat::ReviewableMessage.last expect(reviewable.target).to eq(dm_message_12) transcript_post = Post.find_by(topic_id: reviewable.payload["transcript_topic_id"]) @@ -410,7 +423,7 @@ describe Chat::ChatReviewQueue do 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 + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["transcript_topic_id"]).to be_nil end @@ -423,7 +436,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last transcript_topic = Topic.find(reviewable.payload["transcript_topic_id"]) expect(guardian.can_see_topic?(transcript_topic)).to eq(false) diff --git a/plugins/chat/spec/lib/slack_compatibility_spec.rb b/plugins/chat/spec/lib/chat/slack_compatibility_spec.rb similarity index 100% rename from plugins/chat/spec/lib/slack_compatibility_spec.rb rename to plugins/chat/spec/lib/chat/slack_compatibility_spec.rb diff --git a/plugins/chat/spec/lib/chat_statistics_spec.rb b/plugins/chat/spec/lib/chat/statistics_spec.rb similarity index 100% rename from plugins/chat/spec/lib/chat_statistics_spec.rb rename to plugins/chat/spec/lib/chat/statistics_spec.rb diff --git a/plugins/chat/spec/lib/chat/steps_inspector_spec.rb b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb new file mode 100644 index 00000000000..51b956d9cb6 --- /dev/null +++ b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +RSpec.describe Chat::StepsInspector do + class DummyService + include Service::Base + + model :model + policy :policy + contract + transaction do + step :in_transaction_step_1 + step :in_transaction_step_2 + end + step :final_step + + class Contract + attribute :parameter + + validates :parameter, presence: true + end + end + + subject(:inspector) { described_class.new(result) } + + let(:parameter) { "present" } + let(:result) { DummyService.call(parameter: parameter) } + + before do + class DummyService + %i[fetch_model policy in_transaction_step_1 in_transaction_step_2 final_step].each do |name| + define_method(name) { true } + end + end + end + + describe "#inspect" do + subject(:output) { inspector.inspect.strip } + + context "when service runs without error" do + it "outputs all the steps of the service" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ✅ + [3/7] [contract] 'default' ✅ + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' ✅ + [6/7] [step] 'in_transaction_step_2' ✅ + [7/7] [step] 'final_step' ✅ + OUTPUT + end + end + + context "when the model step is failing" do + before do + class DummyService + def fetch_model + false + end + end + end + + it "shows the failing step" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ❌ + [2/7] [policy] 'policy' + [3/7] [contract] 'default' + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' + OUTPUT + end + end + + context "when the policy step is failing" do + before do + class DummyService + def policy + false + end + end + end + + it "shows the failing step" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ❌ + [3/7] [contract] 'default' + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' + OUTPUT + end + end + + context "when the contract step is failing" do + let(:parameter) { nil } + + it "shows the failing step" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ✅ + [3/7] [contract] 'default' ❌ + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' + OUTPUT + end + end + + context "when a common step is failing" do + before do + class DummyService + def in_transaction_step_2 + fail!("step error") + end + end + end + + it "shows the failing step" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ✅ + [3/7] [contract] 'default' ✅ + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' ✅ + [6/7] [step] 'in_transaction_step_2' ❌ + [7/7] [step] 'final_step' + OUTPUT + end + end + + context "when running in specs" do + context "when a successful step is flagged as being an unexpected result" do + before { result["result.policy.policy"]["spec.unexpected_result"] = true } + + it "adapts its output accordingly" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ✅ ⚠️ <= expected to return false but got true instead + [3/7] [contract] 'default' ✅ + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' ✅ + [6/7] [step] 'in_transaction_step_2' ✅ + [7/7] [step] 'final_step' ✅ + OUTPUT + end + end + + context "when a failing step is flagged as being an unexpected result" do + before do + class DummyService + def policy + false + end + end + result["result.policy.policy"]["spec.unexpected_result"] = true + end + + it "adapts its output accordingly" do + expect(output).to eq <<~OUTPUT.chomp + [1/7] [model] 'model' ✅ + [2/7] [policy] 'policy' ❌ ⚠️ <= expected to return true but got false instead + [3/7] [contract] 'default' + [4/7] [transaction] + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' + OUTPUT + end + end + end + end + + describe "#error" do + subject(:error) { inspector.error } + + context "when there are no errors" do + it "returns nothing" do + expect(error).to be_blank + end + end + + context "when the model step is failing" do + context "when the model is missing" do + before do + class DummyService + def fetch_model + false + end + end + end + + it "returns an error related to the model" do + expect(error).to match(/Model not found/) + end + end + + context "when the model has errors" do + before do + class DummyService + def fetch_model + OpenStruct.new(invalid?: true, errors: ActiveModel::Errors.new(nil)) + end + end + end + + it "returns an error related to the model" do + expect(error).to match(/ActiveModel::Errors \[\]/) + end + end + end + + context "when the contract step is failing" do + let(:parameter) { nil } + + it "returns an error related to the contract" do + expect(error).to match(/ActiveModel::Error attribute=parameter, type=blank, options={}/) + end + end + + context "when the policy step is failing" do + before do + class DummyService + def policy + false + end + end + end + + context "when there is no reason provided" do + it "returns nothing" do + expect(error).to be_blank + end + end + + context "when a reason is provided" do + before { result["result.policy.policy"].reason = "failed" } + + it "returns the reason" do + expect(error).to eq "failed" + end + end + end + + context "when a common step is failing" do + before { result["result.step.final_step"].fail(error: "my error") } + + it "returns an error related to the step" do + expect(error).to eq("my error") + end + 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 similarity index 91% rename from plugins/chat/spec/lib/chat_transcript_service_spec.rb rename to plugins/chat/spec/lib/chat/transcript_service_spec.rb index 0f14d92f016..96d086ce17e 100644 --- a/plugins/chat/spec/lib/chat_transcript_service_spec.rb +++ b/plugins/chat/spec/lib/chat/transcript_service_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatTranscriptService do +describe Chat::TranscriptService do let(:acting_user) { Fabricate(:user) } let(:user1) { Fabricate(:user, username: "martinchat") } let(:user2) { Fabricate(:user, username: "brucechat") } @@ -118,7 +118,6 @@ describe ChatTranscriptService do 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") @@ -130,10 +129,14 @@ describe ChatTranscriptService do 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) + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "", + uploads: [video, audio, attachment, image], + ) video_markdown = UploadMarkdown.new(video).to_markdown audio_markdown = UploadMarkdown.new(audio).to_markdown attachment_markdown = UploadMarkdown.new(attachment).to_markdown @@ -166,7 +169,7 @@ describe ChatTranscriptService do original_filename: "test_img.jpg", extension: "jpg", ) - cu = ChatUpload.create(chat_message: message, created_at: 7.seconds.ago, upload: image) + UploadReference.create(target: message, created_at: 7.seconds.ago, upload: image) image_markdown = UploadMarkdown.new(image).to_markdown expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) @@ -206,27 +209,27 @@ describe ChatTranscriptService do message3 = Fabricate(:chat_message, user: user2, chat_channel: channel, message: "a new perspective") - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "bjorn"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "sigurd"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "hvitserk"), emoji: "+1", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message2, user: Fabricate(:user, username: "ubbe"), emoji: "money_mouth_face", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message3, user: Fabricate(:user, username: "ivar"), emoji: "sob", diff --git a/plugins/chat/spec/lib/chat/types/array_spec.rb b/plugins/chat/spec/lib/chat/types/array_spec.rb new file mode 100644 index 00000000000..c6e843d4a18 --- /dev/null +++ b/plugins/chat/spec/lib/chat/types/array_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::Types::Array do + subject(:type) { described_class.new } + + describe "#cast" do + subject(:casted_value) { type.cast(value) } + + context "when 'value' is a string" do + let(:value) { "first,second,third" } + + it "splits it" do + expect(casted_value).to eq(%w[first second third]) + end + end + + context "when 'value' is an array" do + let(:value) { %w[existing array] } + + it "returns it" do + expect(casted_value).to eq(value) + end + end + + context "when 'value' is something else" do + let(:value) { Time.current } + + it "wraps it in a new array" do + expect(casted_value).to eq([value]) + end + end + 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 deleted file mode 100644 index ff3863ca3a7..00000000000 --- a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb +++ /dev/null @@ -1,361 +0,0 @@ -# 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) do - Fabricate(:direct_message_channel, users: [user_1, user_2, user_3], with_membership: false) - end - fab!(:own_chat_channel) do - Fabricate(:direct_message_channel, users: [user_1], with_membership: false) - end - - 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 not following yet" 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, :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, :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 number of users is over the limit" do - before { SiteSetting.chat_max_direct_message_users = 1 } - - it "raises an error" 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.over_chat_max_direct_message_users", count: 2), - ) - end - - context "when acting user is staff" do - fab!(:admin) { Fabricate(:admin) } - - it "creates a new chat channel" do - expect { - subject.create!(acting_user: admin, target_users: [admin, user_1, user_2]) - }.to change { ChatChannel.count }.by(1) - end - end - - context "when limit is zero" do - before { SiteSetting.chat_max_direct_message_users = 0 } - - it "raises an error" do - expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) - }.to raise_error( - Chat::DirectMessageChannelCreator::NotAllowed, - I18n.t("chat.errors.over_chat_max_direct_message_users", count: 1), - ) - end - end - end - - context "when number of users is at the limit" do - before { SiteSetting.chat_max_direct_message_users = 0 } - - it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count - }.by(1) - end - end - - context "when number of users is under the limit" do - before { SiteSetting.chat_max_direct_message_users = 1 } - - it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count - }.by(1) - end - 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/message_mover_spec.rb b/plugins/chat/spec/lib/message_mover_spec.rb deleted file mode 100644 index 43182c64c1f..00000000000 --- a/plugins/chat/spec/lib/message_mover_spec.rb +++ /dev/null @@ -1,130 +0,0 @@ -# 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/service_runner_spec.rb b/plugins/chat/spec/lib/service_runner_spec.rb new file mode 100644 index 00000000000..9afa8d39722 --- /dev/null +++ b/plugins/chat/spec/lib/service_runner_spec.rb @@ -0,0 +1,424 @@ +# frozen_string_literal: true + +RSpec.describe ServiceRunner do + class SuccessService + include Service::Base + end + + class FailureService + include Service::Base + + step :fail_step + + def fail_step + fail!("error") + end + end + + class FailedPolicyService + include Service::Base + + policy :test + + def test + false + end + end + + class SuccessPolicyService + include Service::Base + + policy :test + + def test + true + end + end + + class FailedContractService + include Service::Base + + class Contract + attribute :test + validates :test, presence: true + end + + contract + end + + class SuccessContractService + include Service::Base + + contract + end + + class FailureWithModelService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + nil + end + end + + class FailureWithOptionalModelService + include Service::Base + + model :fake_model, optional: true + + private + + def fetch_fake_model + nil + end + end + + class FailureWithModelErrorsService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + OpenStruct.new(invalid?: true) + end + end + + class SuccessWithModelService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + :model_found + end + end + + class SuccessWithModelErrorsService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + OpenStruct.new + end + end + + class FailureWithCollectionModelService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + [] + end + end + + class SuccessWithCollectionModelService + include Service::Base + + model :fake_model, :fetch_fake_model + + private + + def fetch_fake_model + [:models_found] + end + end + + describe ".call(service, &block)" do + subject(:runner) { described_class.call(service, object, &actions_block) } + + let(:result) { object.result } + let(:actions_block) { object.instance_eval(actions) } + let(:service) { SuccessService } + let(:actions) { "proc {}" } + let(:object) do + Class + .new(Chat::ApiController) do + def request + OpenStruct.new + end + + def params + ActionController::Parameters.new + end + + def guardian + end + end + .new + end + + it "runs the provided service in the context of a controller" do + runner + expect(result).to be_a Service::Base::Context + expect(result).to be_a_success + end + + context "when using the on_success action" do + let(:actions) { <<-BLOCK } + proc do + on_success { :success } + end + BLOCK + + context "when the service succeeds" do + it "runs the provided block" do + expect(runner).to eq :success + end + end + + context "when the service does not succeed" do + let(:service) { FailureService } + + it "does not run the provided block" do + expect(runner).not_to eq :success + end + end + end + + context "when using the on_failure action" do + let(:actions) { <<-BLOCK } + proc do + on_failure { :fail } + end + BLOCK + + context "when the service fails" do + let(:service) { FailureService } + + it "runs the provided block" do + expect(runner).to eq :fail + end + end + + context "when the service does not fail" do + let(:service) { SuccessService } + + it "does not run the provided block" do + expect(runner).not_to eq :fail + end + end + end + + context "when using the on_failed_policy action" do + let(:actions) { <<-BLOCK } + proc do + on_failed_policy(:test) { :policy_failure } + end + BLOCK + + context "when the service policy fails" do + let(:service) { FailedPolicyService } + + context "when not using the block argument" do + it "runs the provided block" do + expect(runner).to eq :policy_failure + end + end + + context "when using the block argument" do + let(:actions) { <<-BLOCK } + proc do + on_failed_policy(:test) { |policy| policy == result["result.policy.test"] } + end + BLOCK + + it "runs the provided block" do + expect(runner).to be true + end + end + end + + context "when the service policy does not fail" do + let(:service) { SuccessPolicyService } + + it "does not run the provided block" do + expect(runner).not_to eq :policy_failure + end + end + end + + context "when using the on_failed_contract action" do + let(:actions) { <<-BLOCK } + proc do + on_failed_contract { :contract_failure } + end + BLOCK + + context "when the service contract fails" do + let(:service) { FailedContractService } + + context "when not using the block argument" do + it "runs the provided block" do + expect(runner).to eq :contract_failure + end + end + + context "when using the block argument" do + let(:actions) { <<-BLOCK } + proc do + on_failed_contract { |contract| contract == result["result.contract.default"] } + end + BLOCK + + it "runs the provided block" do + expect(runner).to be true + end + end + end + + context "when the service contract does not fail" do + let(:service) { SuccessContractService } + + it "does not run the provided block" do + expect(runner).not_to eq :contract_failure + end + end + end + + context "when using the on_model_not_found action" do + let(:actions) { <<-BLOCK } + proc do + on_model_not_found(:fake_model) { :no_model } + end + BLOCK + + context "when fetching a single model" do + context "when the service uses an optional model" do + let(:service) { FailureWithOptionalModelService } + + it "does not run the provided block" do + expect(runner).not_to eq :no_model + end + end + + context "when the service fails without a model" do + let(:service) { FailureWithModelService } + + context "when not using the block argument" do + it "runs the provided block" do + expect(runner).to eq :no_model + end + end + + context "when using the block argument" do + let(:actions) { <<-BLOCK } + proc do + on_model_not_found(:fake_model) { |model| model == result["result.model.fake_model"] } + end + BLOCK + + it "runs the provided block" do + expect(runner).to be true + end + end + end + + context "when the service does not fail with a model" do + let(:service) { SuccessWithModelService } + + it "does not run the provided block" do + expect(runner).not_to eq :no_model + end + end + end + + context "when fetching a collection" do + context "when the service fails without a model" do + let(:service) { FailureWithCollectionModelService } + + it "runs the provided block" do + expect(runner).to eq :no_model + end + end + + context "when the service does not fail with a model" do + let(:service) { SuccessWithCollectionModelService } + + it "does not run the provided block" do + expect(runner).not_to eq :no_model + end + end + end + end + + context "when using the on_model_errors action" do + let(:actions) { <<-BLOCK } + proc do + on_model_errors(:fake_model) { :model_errors } + end + BLOCK + + context "when the service fails with a model containing errors" do + let(:service) { FailureWithModelErrorsService } + + context "when not using the block argument" do + it "runs the provided block" do + expect(runner).to eq :model_errors + end + end + + context "when using the block argument" do + let(:actions) { <<-BLOCK } + proc do + on_model_errors(:fake_model) { |model| model == OpenStruct.new(invalid?: true) } + end + BLOCK + + it "runs the provided block" do + expect(runner).to be true + end + end + end + + context "when the service does not fail with a model containing errors" do + let(:service) { SuccessWithModelErrorsService } + + it "does not run the provided block" do + expect(runner).not_to eq :model_errors + end + end + end + + context "when using several actions together" do + let(:service) { FailureService } + let(:actions) { <<-BLOCK } + proc do + on_success { :success } + on_failure { :failure } + on_failed_policy { :policy_failure } + end + BLOCK + + it "runs the first matching action" do + expect(runner).to eq :failure + end + end + + context "when running in the context of a job" do + let(:object) { Class.new(ServiceJob).new } + let(:actions) { <<-BLOCK } + proc do + on_success { :success } + on_failure { :failure } + end + BLOCK + + it "runs properly" do + expect(runner).to eq :success + end + end + end +end diff --git a/plugins/chat/spec/mailers/user_notifications_spec.rb b/plugins/chat/spec/mailers/user_notifications_spec.rb index fa282a8a133..734687c81c1 100644 --- a/plugins/chat/spec/mailers/user_notifications_spec.rb +++ b/plugins/chat/spec/mailers/user_notifications_spec.rb @@ -22,7 +22,7 @@ describe UserNotifications do context "with private channel" do fab!(:channel) do refresh_auto_groups - Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user]) + create_dm_channel(sender, [sender, user]) end it "calls guardian can_join_chat_channel?" do @@ -54,7 +54,10 @@ describe UserNotifications do user: another_participant, chat_channel: channel, ) - DirectMessageUser.create!(direct_message: channel.chatable, user: another_participant) + Chat::DirectMessageUser.create!( + direct_message: channel.chatable, + user: another_participant, + ) expected_subject = I18n.t( "user_notifications.chat_summary.subject.direct_message_from_1", @@ -73,11 +76,7 @@ describe UserNotifications 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], - ) + another_channel = create_dm_channel(user, [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, {}) @@ -104,11 +103,8 @@ describe UserNotifications do refresh_auto_groups sender.reload senders << sender - channel = - Chat::DirectMessageChannelCreator.create!( - acting_user: sender, - target_users: [user, sender], - ) + channel = create_dm_channel(sender, [sender, user]) + user .user_chat_channel_memberships .where(chat_channel_id: channel.id) @@ -146,7 +142,8 @@ describe UserNotifications do 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 + # using fab! for user_membership below makes these specs flaky + let!(:user_membership) do Fabricate( :user_chat_channel_membership, chat_channel: channel, @@ -161,9 +158,83 @@ describe UserNotifications do expect(email.to).to be_blank end + context "with channel-wide mentions" do + before { Jobs.run_immediately! } + + def create_chat_message_with_mentions_and_notifications(content) + # Sometimes it's not enough to just fabricate a message + # and we have to create it like here. In this case all the necessary + # db records for mentions and notifications will be created under the hood. + Chat::MessageCreator.create(chat_channel: channel, user: sender, content: content) + end + + it "returns email for @all mention by default" do + create_chat_message_with_mentions_and_notifications("Mentioning @all") + email = described_class.chat_summary(user, {}) + expect(email.to).to contain_exactly(user.email) + end + + it "returns email for @here mention by default" do + user.update(last_seen_at: 1.second.ago) + + create_chat_message_with_mentions_and_notifications("Mentioning @here") + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + context "when channel-wide mentions are disabled in a channel" do + before { channel.update!(allow_channel_wide_mentions: false) } + + it "doesn't return email for @all mention" do + create_chat_message_with_mentions_and_notifications("Mentioning @all") + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return email for @here mention" do + user.update(last_seen_at: 1.second.ago) + + create_chat_message_with_mentions_and_notifications("Mentioning @here") + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + end + + context "when user has disabled channel-wide mentions" do + before { user.user_option.update!(ignore_channel_wide_mention: true) } + + it "doesn't return email for @all mention" do + create_chat_message_with_mentions_and_notifications("Mentioning @all") + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return email for @here mention" do + user.update(last_seen_at: 1.second.ago) + + create_chat_message_with_mentions_and_notifications("Mentioning @here") + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + end + end + describe "email subject" do context "with regular mentions" do - before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + before do + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: chat_message, + notification: notification, + ) + end it "includes the sender username in the subject" do expected_subject = @@ -194,7 +265,13 @@ describe UserNotifications do user: user, last_read_message_id: another_chat_message.id - 2, ) - Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: another_chat_message, + notification: notification, + ) email = described_class.chat_summary(user, {}) @@ -227,7 +304,13 @@ describe UserNotifications do user: user, last_read_message_id: another_chat_message.id - 2, ) - Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: another_chat_message, + notification: notification, + ) end expected_subject = @@ -247,13 +330,15 @@ describe UserNotifications do 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], - ) + channel = create_dm_channel(sender, [sender, user]) Fabricate(:chat_message, user: sender, chat_channel: channel) - Fabricate(:chat_mention, user: user, chat_message: chat_message) + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: chat_message, + notification: notification, + ) end it "always includes the DM second" do @@ -273,7 +358,15 @@ describe UserNotifications do end describe "When there are mentions" do - before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + before do + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: chat_message, + notification: notification, + ) + end describe "selecting mentions" do it "doesn't return an email if the user can't see chat" do @@ -358,11 +451,7 @@ describe UserNotifications do 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], - ) + channel = create_dm_channel(sender, [sender, user]) Fabricate(:chat_message, user: sender, chat_channel: channel) email = described_class.chat_summary(user, {}) @@ -377,7 +466,13 @@ describe UserNotifications do ) new_message = Fabricate(:chat_message, user: sender, chat_channel: channel) - Fabricate(:chat_mention, user: user, chat_message: new_message) + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: new_message, + notification: notification, + ) email = described_class.chat_summary(user, {}) @@ -478,7 +573,8 @@ describe UserNotifications do 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) + notification = Fabricate(:notification) + Fabricate(:chat_mention, user: user, chat_message: msg, notification: notification) end email = described_class.chat_summary(user, {}) @@ -499,7 +595,13 @@ describe UserNotifications do new_message = Fabricate(:chat_message, user: sender, chat_channel: channel, cooked: "New message") - Fabricate(:chat_mention, user: user, chat_message: new_message) + notification = Fabricate(:notification) + Fabricate( + :chat_mention, + user: user, + chat_message: new_message, + notification: notification, + ) email = described_class.chat_summary(user, {}) body = email.html_part.body.to_s @@ -511,4 +613,14 @@ describe UserNotifications do end end end + + def create_dm_channel(sender, target_users) + result = + Chat::CreateDirectMessageChannel.call( + guardian: sender.guardian, + target_usernames: target_users.map(&:username), + ) + service_failed!(result) if result.failure? + result.channel + end end diff --git a/plugins/chat/spec/models/category_spec.rb b/plugins/chat/spec/models/category_spec.rb index bf16ff3e618..d02d3036524 100644 --- a/plugins/chat/spec/models/category_spec.rb +++ b/plugins/chat/spec/models/category_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe Category do it_behaves_like "a chatable model" do fab!(:chatable) { Fabricate(:category) } - let(:channel_class) { CategoryChannel } + let(:channel_class) { Chat::CategoryChannel } end it { is_expected.to have_one(:category_channel).dependent(:destroy) } diff --git a/plugins/chat/spec/models/category_channel_spec.rb b/plugins/chat/spec/models/chat/category_channel_spec.rb similarity index 99% rename from plugins/chat/spec/models/category_channel_spec.rb rename to plugins/chat/spec/models/chat/category_channel_spec.rb index e78681eb8bc..1408d18829d 100644 --- a/plugins/chat/spec/models/category_channel_spec.rb +++ b/plugins/chat/spec/models/chat/category_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe CategoryChannel do +RSpec.describe Chat::CategoryChannel do subject(:channel) { Fabricate.build(:category_channel) } it_behaves_like "a chat channel model" 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 00000000000..6d8cd244db9 --- /dev/null +++ b/plugins/chat/spec/models/chat/channel_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +RSpec.describe Chat::Channel do + fab!(:category_channel_1) { Fabricate(:category_channel) } + fab!(:dm_channel_1) { Fabricate(:direct_message_channel) } + + describe ".find_by_id_or_slug" do + subject(:find_channel) { described_class.find_by_id_or_slug(channel_id) } + + context "when the channel is a direct message one" do + let(:channel_id) { dm_channel_1.id } + + it "finds it" do + expect(find_channel).to eq dm_channel_1 + end + end + + context "when the channel is a category one" do + context "when providing its id" do + let(:channel_id) { category_channel_1.id } + + it "finds it" do + expect(find_channel).to eq category_channel_1 + end + end + + context "when providing its slug" do + let(:channel_id) { category_channel_1.slug } + + it "finds it" do + expect(find_channel).to eq category_channel_1 + end + end + + context "when providing its category slug" do + let(:channel_id) { category_channel_1.category.slug } + + it "finds it" do + expect(find_channel).to eq category_channel_1 + end + end + end + + context "when providing a non existent id" do + let(:channel_id) { -1 } + + it "returns nothing" do + expect(find_channel).to be_blank + end + end + end + + describe "#relative_url" do + context "when the slug is nil" do + it "uses a - instead" do + category_channel_1.slug = nil + expect(category_channel_1.relative_url).to eq("/chat/c/-/#{category_channel_1.id}") + end + end + + context "when the slug is not nil" do + before { category_channel_1.update!(slug: "some-cool-channel") } + + it "includes the slug for the channel" do + expect(category_channel_1.relative_url).to eq( + "/chat/c/some-cool-channel/#{category_channel_1.id}", + ) + end + end + end + + describe ".ensure_consistency!" do + fab!(:category_channel_2) { Fabricate(:category_channel) } + + describe "updating messages_count for all channels" do + fab!(:category_channel_3) { Fabricate(:category_channel) } + fab!(:category_channel_4) { Fabricate(:category_channel) } + fab!(:dm_channel_2) { Fabricate(:direct_message_channel) } + + before do + Fabricate(:chat_message, chat_channel: category_channel_1) + Fabricate(:chat_message, chat_channel: category_channel_1) + Fabricate(:chat_message, chat_channel: category_channel_1) + + Fabricate(:chat_message, chat_channel: category_channel_2) + Fabricate(:chat_message, chat_channel: category_channel_2) + Fabricate(:chat_message, chat_channel: category_channel_2) + Fabricate(:chat_message, chat_channel: category_channel_2) + + Fabricate(:chat_message, chat_channel: category_channel_3) + + Fabricate(:chat_message, chat_channel: dm_channel_2) + Fabricate(:chat_message, chat_channel: dm_channel_2) + end + + it "counts correctly" do + described_class.ensure_consistency! + expect(category_channel_1.reload.messages_count).to eq(3) + expect(category_channel_2.reload.messages_count).to eq(4) + expect(category_channel_3.reload.messages_count).to eq(1) + expect(category_channel_4.reload.messages_count).to eq(0) + expect(dm_channel_1.reload.messages_count).to eq(0) + expect(dm_channel_2.reload.messages_count).to eq(2) + end + + it "does not count deleted messages" do + category_channel_3.chat_messages.last.trash! + described_class.ensure_consistency! + expect(category_channel_3.reload.messages_count).to eq(0) + end + + it "does not update deleted channels" do + described_class.ensure_consistency! + category_channel_3.chat_messages.last.trash! + category_channel_3.trash! + described_class.ensure_consistency! + expect(category_channel_3.reload.messages_count).to eq(1) + end + end + + describe "updating user_count for all channels" do + 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: category_channel_1, + following: true, + ) + user_1.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + + user_2.user_chat_channel_memberships.create!( + chat_channel: category_channel_1, + following: true, + ) + user_2.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + + user_3.user_chat_channel_memberships.create!( + chat_channel: category_channel_1, + following: false, + ) + user_3.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + end + + it "sets the user_count correctly for each chat channel" do + create_memberships + + described_class.ensure_consistency! + + expect(category_channel_1.reload.user_count).to eq(2) + expect(category_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: category_channel_1, + following: true, + ) + user_2.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + user_3.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + user_4.user_chat_channel_memberships.create!( + chat_channel: category_channel_2, + following: true, + ) + user_2.update(suspended_till: 3.weeks.from_now) + user_3.update(staged: true) + user_4.update(active: false) + + described_class.ensure_consistency! + + expect(category_channel_1.reload.user_count).to eq(1) + expect(category_channel_2.reload.user_count).to eq(0) + end + + it "does not count archived, or read_only channels" do + create_memberships + + category_channel_1.update!(status: :archived) + described_class.ensure_consistency! + expect(category_channel_1.reload.user_count).to eq(0) + + category_channel_1.update!(status: :read_only) + described_class.ensure_consistency! + expect(category_channel_1.reload.user_count).to eq(0) + end + + it "publishes all the updated channels" do + create_memberships + + messages = MessageBus.track_publish { described_class.ensure_consistency! } + + expect(messages.length).to eq(3) + expect(messages.map(&:data)).to match_array( + [ + { chat_channel_id: category_channel_1.id, memberships_count: 2 }, + { chat_channel_id: category_channel_2.id, memberships_count: 3 }, + { chat_channel_id: dm_channel_1.id, memberships_count: 2 }, + ], + ) + + messages = MessageBus.track_publish { described_class.ensure_consistency! } + expect(messages.length).to eq(0) + end + end + end + + describe "#allow_channel_wide_mentions" do + it "defaults to true" do + expect(category_channel_1.allow_channel_wide_mentions).to be(true) + end + + it "cant be nullified" do + expect { category_channel_1.update!(allow_channel_wide_mentions: nil) }.to raise_error( + ActiveRecord::NotNullViolation, + ) + end + end + + describe "#latest_not_deleted_message_id" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:old_message) { Fabricate(:chat_message, chat_channel: channel) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) } + + before { old_message.update!(created_at: 1.day.ago) } + + it "accepts an anchor message to only get messages of a lower id" do + expect(channel.latest_not_deleted_message_id(anchor_message_id: message_1.id)).to eq( + old_message.id, + ) + end + + it "gets the latest message by created_at" do + expect(channel.latest_not_deleted_message_id).to eq(message_1.id) + end + + it "does not get other channel messages" do + Fabricate(:chat_message) + expect(channel.latest_not_deleted_message_id).to eq(message_1.id) + end + + it "does not get thread replies" do + thread = Fabricate(:chat_thread, channel: channel, old_om: true) + message_1.update!(thread: thread) + expect(channel.latest_not_deleted_message_id).to eq(old_message.id) + end + + it "does get thread original message" do + thread = Fabricate(:chat_thread, channel: channel) + expect(channel.latest_not_deleted_message_id).to eq(thread.original_message_id) + end + + it "does not get deleted messages" do + message_1.trash! + expect(channel.latest_not_deleted_message_id).to eq(old_message.id) + end + end +end diff --git a/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb new file mode 100644 index 00000000000..9a1beeaf757 --- /dev/null +++ b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::DeletedUser do + subject(:deleted_user) { described_class.new } + + describe "#username" do + it "returns a default username" do + expect(deleted_user.username).to eq(I18n.t("chat.deleted_chat_username")) + end + end + + describe "#avatar_template" do + it "returns a default path" do + expect(deleted_user.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/chat/direct_message_channel_spec.rb similarity index 97% rename from plugins/chat/spec/models/direct_message_channel_spec.rb rename to plugins/chat/spec/models/chat/direct_message_channel_spec.rb index 227a143d60d..0c45370f374 100644 --- a/plugins/chat/spec/models/direct_message_channel_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe DirectMessageChannel do +RSpec.describe Chat::DirectMessageChannel do subject(:channel) { Fabricate.build(:direct_message_channel) } it_behaves_like "a chat channel model" diff --git a/plugins/chat/spec/models/direct_message_spec.rb b/plugins/chat/spec/models/chat/direct_message_spec.rb similarity index 78% rename from plugins/chat/spec/models/direct_message_spec.rb rename to plugins/chat/spec/models/chat/direct_message_spec.rb index 9e44e51cd5d..9d526f3bb1c 100644 --- a/plugins/chat/spec/models/direct_message_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_spec.rb @@ -2,14 +2,14 @@ require "rails_helper" -describe DirectMessage do +describe Chat::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 } + let(:channel_class) { Chat::DirectMessageChannel } end describe "#chat_channel_title_for_user" do @@ -20,7 +20,8 @@ describe DirectMessage do 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(", "), + comma_separated_usernames: + [user3, user2].map { |u| "@#{u.username}" }.join(I18n.t("word_connector.comma")), ), ) end @@ -36,8 +37,12 @@ describe DirectMessage do 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, + comma_separated_usernames: + users[1..5] + .sort_by(&:username) + .map { |u| "@#{u.username}" } + .join(I18n.t("word_connector.comma")), + count: 2, ), ) end @@ -46,7 +51,7 @@ describe DirectMessage 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}"), + I18n.t("chat.channel.dm_title.single_user", username: "@#{user2.username}"), ) end @@ -54,7 +59,7 @@ describe DirectMessage 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}"), + I18n.t("chat.channel.dm_title.single_user", username: "@#{user1.username}"), ) end diff --git a/plugins/chat/spec/models/chat_draft_spec.rb b/plugins/chat/spec/models/chat/draft_spec.rb similarity index 94% rename from plugins/chat/spec/models/chat_draft_spec.rb rename to plugins/chat/spec/models/chat/draft_spec.rb index 27794bafaec..4694c1ff7e8 100644 --- a/plugins/chat/spec/models/chat_draft_spec.rb +++ b/plugins/chat/spec/models/chat/draft_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ChatDraft do +RSpec.describe Chat::Draft do before { SiteSetting.max_chat_draft_length = 100 } it "errors when data.value is greater than `max_chat_draft_length`" do diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat/message_spec.rb similarity index 72% rename from plugins/chat/spec/models/chat_message_spec.rb rename to plugins/chat/spec/models/chat/message_spec.rb index 9e305f98f25..e0a5c232078 100644 --- a/plugins/chat/spec/models/chat_message_spec.rb +++ b/plugins/chat/spec/models/chat/message_spec.rb @@ -2,36 +2,38 @@ require "rails_helper" -describe ChatMessage do +describe Chat::Message do fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") } + it { is_expected.to have_many(:chat_mentions).dependent(:destroy) } + describe ".cook" do it "does not support HTML tags" do - cooked = ChatMessage.cook("

    test

    ") + cooked = described_class.cook("

    test

    ") expect(cooked).to eq("

    <h1>test</h1>

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

    ## heading 2

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

    ---

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

    test

    ") end it "supports fence rule" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ``` something = test ``` @@ -44,7 +46,7 @@ describe ChatMessage do end it "supports fence rule with language support" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ```ruby Widget.triangulate(argument: "no u") ``` @@ -57,13 +59,13 @@ describe ChatMessage do end it "supports code rule" do - cooked = ChatMessage.cook(" something = test") + cooked = described_class.cook(" something = test") expect(cooked).to eq("
    something = test\n
    ") end it "supports blockquote rule" do - cooked = ChatMessage.cook("> a quote") + cooked = described_class.cook("> a quote") expect(cooked).to eq("
    \n

    a quote

    \n
    ") end @@ -73,9 +75,9 @@ describe ChatMessage do 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")}" + "//test.localhost#{User.system_avatar_template(post.user.username).gsub("{size}", "48")}" - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) [quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"] Mark me...this will go down in history. [/quote] @@ -85,8 +87,7 @@ describe ChatMessage do
    - +
    - + @@ -18,11 +18,11 @@
    - + @@ -30,6 +30,6 @@
    - - + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-notifications.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-notifications.hbs new file mode 100644 index 00000000000..dfe67058db1 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-notifications.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-timer-info.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-timer-info.hbs new file mode 100644 index 00000000000..580d47a4356 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-timer-info.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/00-post.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/00-post.hbs new file mode 100644 index 00000000000..cf0dcc9f1f0 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/00-post.hbs @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-topic-map.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-topic-map.hbs new file mode 100644 index 00000000000..8c28dc0a58a --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-topic-map.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/03-topic-footer-buttons.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/03-topic-footer-buttons.hbs new file mode 100644 index 00000000000..46a8c73cc3a --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/03-topic-footer-buttons.hbs @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/04-topic-list.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/04-topic-list.hbs new file mode 100644 index 00000000000..d95a8c0dc9e --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/04-topic-list.hbs @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/basic-topic-list.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/basic-topic-list.hbs new file mode 100644 index 00000000000..71fe056ac6c --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/basic-topic-list.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/categories-list.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/categories-list.hbs new file mode 100644 index 00000000000..281aa4e30e2 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/categories-list.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs new file mode 100644 index 00000000000..2da5d63612e --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs @@ -0,0 +1,66 @@ +{{! template-lint-disable no-potential-path-strings}} + + + + + <:body> + {{this.body}} + + + <:footer> + {{i18n "styleguide.sections.modal.footer"}} + + + + + + + + + + + + + + + + + + + + + + - Under 100 characters, optional -
    -
    - -
    - Privacy -
    - - -
    -
    -
    - -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -