Merge branch 'main' into theme-cards

This commit is contained in:
Martin Brennan 2024-10-03 14:36:33 +10:00
commit c446caa6e9
No known key found for this signature in database
GPG Key ID: BD981EFEEC8F5675
436 changed files with 9658 additions and 4652 deletions

View File

@ -10,7 +10,7 @@ permissions:
jobs: jobs:
triage: triage:
if: github.actor != 'discourse-translator-bot' if: github.actor != 'discourse-translator-bot'
runs-on: debian-12 runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v5 - uses: actions/labeler@v5

View File

@ -17,7 +17,7 @@ jobs:
build: build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: run name: run
runs-on: debian-12 runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim container: discourse/discourse_test:slim
timeout-minutes: 10 timeout-minutes: 10

View File

@ -17,7 +17,7 @@ jobs:
build: build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: run name: run
runs-on: debian-12 runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim container: discourse/discourse_test:slim
timeout-minutes: 30 timeout-minutes: 30

View File

@ -24,7 +24,7 @@ jobs:
tests: tests:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: Tests with Ruby ${{ matrix.ruby }} name: Tests with Ruby ${{ matrix.ruby }}
runs-on: debian-12 runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim container: discourse/discourse_test:slim
timeout-minutes: 20 timeout-minutes: 20
@ -126,11 +126,11 @@ jobs:
if: steps.app-cache.outputs.cache-hit != 'true' if: steps.app-cache.outputs.cache-hit != 'true'
run: rm -rf tmp/app-cache/uploads && cp -r public/uploads tmp/app-cache/uploads run: rm -rf tmp/app-cache/uploads && cp -r public/uploads tmp/app-cache/uploads
# - name: Check core database drift # - name: Check core database drift
# run: | # run: |
# mkdir /tmp/intermediate_db # mkdir /tmp/intermediate_db
# ./migrations/scripts/schema_generator /tmp/intermediate_db/base_migration.sql # ./migrations/scripts/schema_generator /tmp/intermediate_db/base_migration.sql
# diff -u migrations/common/intermediate_db_schema/000_base_schema.sql /tmp/intermediate_db/base_migration.sql # diff -u migrations/common/intermediate_db_schema/000_base_schema.sql /tmp/intermediate_db/base_migration.sql
- name: RSpec - name: RSpec
run: bin/rspec --default-path migrations/spec run: bin/rspec --default-path migrations/spec

View File

@ -4,14 +4,14 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
from: from:
description: 'Starting ref (exclusive). Can be a tag, branch or commit ref. `latest-release` refers to the last beta version bump.' description: "Starting ref (exclusive). Can be a tag, branch or commit ref. `latest-release` refers to the last beta version bump."
required: true required: true
default: 'latest-release' default: "latest-release"
type: string type: string
to: to:
description: 'Ending ref (inclusive). Can be a tag, branch or commit ref. `HEAD` refers to the most recent commit.' description: "Ending ref (inclusive). Can be a tag, branch or commit ref. `HEAD` refers to the most recent commit."
required: true required: true
default: 'HEAD' default: "HEAD"
type: string type: string
permissions: permissions:
@ -20,7 +20,7 @@ permissions:
jobs: jobs:
build: build:
name: run name: run
runs-on: debian-12 runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim container: discourse/discourse_test:slim
timeout-minutes: 10 timeout-minutes: 10
env: env:
@ -87,7 +87,7 @@ jobs:
echo "From: $from_ref - $(git rev-parse --short $from_ref) - ${{ steps.dates.outputs.from }}" >> $GITHUB_STEP_SUMMARY echo "From: $from_ref - $(git rev-parse --short $from_ref) - ${{ steps.dates.outputs.from }}" >> $GITHUB_STEP_SUMMARY
echo "To: $to_ref - $(git rev-parse --short $to_ref) - ${{ steps.dates.outputs.to }}" >> $GITHUB_STEP_SUMMARY echo "To: $to_ref - $(git rev-parse --short $to_ref) - ${{ steps.dates.outputs.to }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY

View File

@ -1,7 +1,7 @@
name: 'Close stale PRs' name: "Close stale PRs"
on: on:
schedule: schedule:
- cron: '30 1 * * *' - cron: "30 1 * * *"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -9,11 +9,11 @@ permissions:
jobs: jobs:
stale: stale:
runs-on: debian-12 runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v9
with: with:
days-before-stale: 60 days-before-stale: 60
days-before-close: 14 days-before-close: 14
stale-pr-message: 'This pull request has been automatically marked as stale because it has been open for 60 days with no activity. To keep it open, remove the stale tag, push code, or add a comment. Otherwise, it will be closed in 14 days.' stale-pr-message: "This pull request has been automatically marked as stale because it has been open for 60 days with no activity. To keep it open, remove the stale tag, push code, or add a comment. Otherwise, it will be closed in 14 days."
exempt-pr-labels: dependencies exempt-pr-labels: dependencies

View File

@ -29,7 +29,7 @@ jobs:
build: build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: ${{ matrix.target }} ${{ matrix.build_type }} # Update fetch-job-id step if changing this name: ${{ matrix.target }} ${{ matrix.build_type }} # Update fetch-job-id step if changing this
runs-on: ${{ ((github.ref == 'refs/heads/main') && 'debian-12') || 'ubuntu-22.04-8core' }} runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-22.04-8core') || 'debian-12' }}
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' || '' }}
timeout-minutes: 20 timeout-minutes: 20
@ -44,6 +44,7 @@ jobs:
DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && github.ref == 'refs/heads/main' && '1' }} DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && github.ref == 'refs/heads/main' && '1' }}
CHEAP_SOURCE_MAPS: "1" CHEAP_SOURCE_MAPS: "1"
TESTEM_DEFAULT_BROWSER: Chrome TESTEM_DEFAULT_BROWSER: Chrome
MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
strategy: strategy:
fail-fast: false fail-fast: false
@ -255,6 +256,13 @@ jobs:
if: matrix.build_type == 'system' if: matrix.build_type == 'system'
run: bin/ember-cli --build run: bin/ember-cli --build
- name: Minio cache
if: matrix.build_type == 'system' && matrix.target == 'core'
uses: actions/cache@v4
with:
path: ${{ env.MINIO_RUNNER_INSTALL_DIR }}
key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }}
- name: Ensure latest minio binary installed for Core System Tests - name: Ensure latest minio binary installed for Core System Tests
if: matrix.build_type == 'system' && matrix.target == 'core' if: matrix.build_type == 'system' && matrix.target == 'core'
run: bundle exec ruby script/install_minio_binaries.rb run: bundle exec ruby script/install_minio_binaries.rb
@ -358,7 +366,7 @@ jobs:
core_frontend_tests: core_frontend_tests:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror' if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: core frontend (${{ matrix.browser }}) name: core frontend (${{ matrix.browser }})
runs-on: ${{ ((github.ref == 'refs/heads/main') && 'debian-12') || 'ubuntu-22.04-8core' }} runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-22.04-8core') || 'debian-12' }}
container: container:
image: discourse/discourse_test:slim-browsers image: discourse/discourse_test:slim-browsers
options: --user discourse options: --user discourse
@ -397,7 +405,7 @@ jobs:
- name: Core QUnit - name: Core QUnit
working-directory: ./app/assets/javascripts/discourse working-directory: ./app/assets/javascripts/discourse
run: | run: |
pnpm ember exam --path /tmp/emberbuild --load-balance --parallel=$(($(nproc) / 2 + 1)) --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random pnpm ember exam --path /tmp/emberbuild --load-balance --parallel=$(($(nproc) / 2)) --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random
timeout-minutes: 15 timeout-minutes: 15
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4

View File

@ -45,6 +45,7 @@ reviewed:
- concurrent-ruby # MIT - concurrent-ruby # MIT
- css_parser # MIT - css_parser # MIT
- drb # BSD-2-Clause - drb # BSD-2-Clause
- dry-initializer # MIT
- excon # MIT - excon # MIT
- faraday-em_http # MIT - faraday-em_http # MIT
- faraday-em_synchrony # MIT - faraday-em_synchrony # MIT

View File

@ -287,3 +287,5 @@ group :migrations, optional: true do
# CLI # CLI
gem "ruby-progressbar" gem "ruby-progressbar"
end end
gem "dry-initializer", "~> 3.1"

View File

@ -132,9 +132,10 @@ GEM
literate_randomizer literate_randomizer
docile (1.4.1) docile (1.4.1)
drb (2.2.1) drb (2.2.1)
dry-initializer (3.1.1)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
excon (0.111.0) excon (0.112.0)
execjs (2.9.1) execjs (2.9.1)
exifr (1.4.0) exifr (1.4.0)
extralite-bundle (2.8.2) extralite-bundle (2.8.2)
@ -142,8 +143,9 @@ GEM
faker (2.23.0) faker (2.23.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
fakeweb (1.3.0) fakeweb (1.3.0)
faraday (2.11.0) faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4) faraday-net_http (>= 2.0, < 3.4)
json
logger logger
faraday-net_http (3.3.0) faraday-net_http (3.3.0)
net-http net-http
@ -158,16 +160,16 @@ GEM
fspath (3.1.2) fspath (3.1.2)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.28.1-aarch64-linux) google-protobuf (4.28.2-aarch64-linux)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.28.1-arm64-darwin) google-protobuf (4.28.2-arm64-darwin)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.28.1-x86_64-darwin) google-protobuf (4.28.2-x86_64-darwin)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.28.1-x86_64-linux) google-protobuf (4.28.2-x86_64-linux)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
guess_html_encoding (0.0.11) guess_html_encoding (0.0.11)
@ -178,7 +180,7 @@ GEM
reline reline
htmlentities (4.3.4) htmlentities (4.3.4)
http_accept_language (2.1.1) http_accept_language (2.1.1)
i18n (1.14.5) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_optim (0.31.3) image_optim (0.31.3)
exifr (~> 1.2, >= 1.2.2) exifr (~> 1.2, >= 1.2.2)
@ -189,7 +191,7 @@ GEM
image_size (3.4.0) image_size (3.4.0)
in_threads (1.6.0) in_threads (1.6.0)
io-console (0.7.2) io-console (0.7.2)
irb (1.14.0) irb (1.14.1)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
iso8601 (0.13.0) iso8601 (0.13.0)
@ -234,7 +236,7 @@ GEM
net-smtp net-smtp
matrix (0.4.2) matrix (0.4.2)
maxminddb (0.1.22) maxminddb (0.1.22)
memory_profiler (1.0.2) memory_profiler (1.1.0)
message_bus (4.3.8) message_bus (4.3.8)
rack (>= 1.1.3) rack (>= 1.1.3)
messageformat-wrapper (1.1.0) messageformat-wrapper (1.1.0)
@ -344,7 +346,7 @@ GEM
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.4.2) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.9) rack (2.2.9)
@ -404,10 +406,10 @@ GEM
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
rexml (3.3.7) rexml (3.3.8)
rinku (2.0.6) rinku (2.0.6)
rotp (6.3.0) rotp (6.3.0)
rouge (4.3.0) rouge (4.4.0)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
@ -426,7 +428,7 @@ GEM
rspec-html-matchers (0.10.0) rspec-html-matchers (0.10.0)
nokogiri (~> 1) nokogiri (~> 1)
rspec (>= 3.0.0.a) rspec (>= 3.0.0.a)
rspec-mocks (3.13.1) rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-multi-mock (0.3.1) rspec-multi-mock (0.3.1)
@ -473,12 +475,12 @@ GEM
rubocop-rspec_rails (>= 2.30.0) rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1) rubocop-factory_bot (2.26.1)
rubocop (~> 1.61) rubocop (~> 1.61)
rubocop-rails (2.26.1) rubocop-rails (2.26.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0) rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.5) rubocop-rspec (3.1.0)
rubocop (~> 1.61) rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0) rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61) rubocop (~> 1.61)
@ -503,9 +505,9 @@ GEM
google-protobuf (>= 3.25, < 5.0) google-protobuf (>= 3.25, < 5.0)
sassc-embedded (1.77.7) sassc-embedded (1.77.7)
sass-embedded (~> 1.77) sass-embedded (~> 1.77)
selenium-devtools (0.128.0) selenium-devtools (0.129.0)
selenium-webdriver (~> 4.2) selenium-webdriver (~> 4.2)
selenium-webdriver (4.24.0) selenium-webdriver (4.25.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -527,7 +529,7 @@ GEM
snaky_hash (2.0.1) snaky_hash (2.0.1)
hashie hashie
version_gem (~> 1.1, >= 1.1.1) version_gem (~> 1.1, >= 1.1.1)
sprockets (3.7.4) sprockets (3.7.5)
base64 base64
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
@ -535,10 +537,10 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.0.4-aarch64-linux-gnu) sqlite3 (2.1.0-aarch64-linux-gnu)
sqlite3 (2.0.4-arm64-darwin) sqlite3 (2.1.0-arm64-darwin)
sqlite3 (2.0.4-x86_64-darwin) sqlite3 (2.1.0-x86_64-darwin)
sqlite3 (2.0.4-x86_64-linux-gnu) sqlite3 (2.1.0-x86_64-linux-gnu)
sshkey (3.0.0) sshkey (3.0.0)
stackprof (0.2.26) stackprof (0.2.26)
stringio (3.1.1) stringio (3.1.1)
@ -553,10 +555,10 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2) tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uglifier (4.2.0) uglifier (4.2.1)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unf (0.2.0) unf (0.2.0)
unicode-display_width (2.5.0) unicode-display_width (2.6.0)
unicorn (6.1.0) unicorn (6.1.0)
kgio (~> 2.6) kgio (~> 2.6)
raindrops (~> 0.7) raindrops (~> 0.7)
@ -566,7 +568,7 @@ GEM
web-push (3.0.1) web-push (3.0.1)
jwt (~> 2.0) jwt (~> 2.0)
openssl (~> 3.0) openssl (~> 3.0)
webmock (3.23.1) webmock (3.24.0)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -623,6 +625,7 @@ DEPENDENCIES
discourse-fonts discourse-fonts
discourse-seed-fu discourse-seed-fu
discourse_dev_assets discourse_dev_assets
dry-initializer (~> 3.1)
email_reply_trimmer email_reply_trimmer
excon excon
execjs execjs

View File

@ -82,7 +82,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field <form.Field
@name="communityOwner" @name="communityOwner"
@title={{i18n "admin.config_areas.about.community_owner"}} @title={{i18n "admin.config_areas.about.community_owner"}}
@subtitle={{i18n "admin.config_areas.about.community_owner_help"}} @description={{i18n "admin.config_areas.about.community_owner_help"}}
@format="large" @format="large"
as |field| as |field|
> >
@ -96,7 +96,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field <form.Field
@name="contactEmail" @name="contactEmail"
@title={{i18n "admin.config_areas.about.contact_email"}} @title={{i18n "admin.config_areas.about.contact_email"}}
@subtitle={{i18n "admin.config_areas.about.contact_email_help"}} @description={{i18n "admin.config_areas.about.contact_email_help"}}
@type="email" @type="email"
@format="large" @format="large"
as |field| as |field|
@ -111,7 +111,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field <form.Field
@name="contactURL" @name="contactURL"
@title={{i18n "admin.config_areas.about.contact_url"}} @title={{i18n "admin.config_areas.about.contact_url"}}
@subtitle={{i18n "admin.config_areas.about.contact_url_help"}} @description={{i18n "admin.config_areas.about.contact_url_help"}}
@type="url" @type="url"
@format="large" @format="large"
as |field| as |field|
@ -126,7 +126,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field <form.Field
@name="contactUsername" @name="contactUsername"
@title={{i18n "admin.config_areas.about.site_contact_name"}} @title={{i18n "admin.config_areas.about.site_contact_name"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_name_help"}} @description={{i18n "admin.config_areas.about.site_contact_name_help"}}
@onSet={{this.setContactUsername}} @onSet={{this.setContactUsername}}
@format="large" @format="large"
as |field| as |field|
@ -143,7 +143,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field <form.Field
@name="contactGroupName" @name="contactGroupName"
@title={{i18n "admin.config_areas.about.site_contact_group"}} @title={{i18n "admin.config_areas.about.site_contact_group"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_group_help"}} @description={{i18n "admin.config_areas.about.site_contact_group_help"}}
@onSet={{this.setContactGroup}} @onSet={{this.setContactGroup}}
@format="large" @format="large"
as |field| as |field|

View File

@ -98,7 +98,7 @@ export default class AdminConfigAreasAboutGeneralSettings extends Component {
<form.Field <form.Field
@name="aboutBannerImage" @name="aboutBannerImage"
@title={{i18n "admin.config_areas.about.banner_image"}} @title={{i18n "admin.config_areas.about.banner_image"}}
@subtitle={{i18n "admin.config_areas.about.banner_image_help"}} @description={{i18n "admin.config_areas.about.banner_image_help"}}
@onSet={{this.setImage}} @onSet={{this.setImage}}
as |field| as |field|
> >

View File

@ -58,7 +58,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field <form.Field
@name="companyName" @name="companyName"
@title={{i18n "admin.config_areas.about.company_name"}} @title={{i18n "admin.config_areas.about.company_name"}}
@subtitle={{i18n "admin.config_areas.about.company_name_help"}} @description={{i18n "admin.config_areas.about.company_name_help"}}
@format="large" @format="large"
as |field| as |field|
> >
@ -75,7 +75,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field <form.Field
@name="governingLaw" @name="governingLaw"
@title={{i18n "admin.config_areas.about.governing_law"}} @title={{i18n "admin.config_areas.about.governing_law"}}
@subtitle={{i18n "admin.config_areas.about.governing_law_help"}} @description={{i18n "admin.config_areas.about.governing_law_help"}}
@format="large" @format="large"
as |field| as |field|
> >
@ -89,7 +89,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field <form.Field
@name="cityForDisputes" @name="cityForDisputes"
@title={{i18n "admin.config_areas.about.city_for_disputes"}} @title={{i18n "admin.config_areas.about.city_for_disputes"}}
@subtitle={{i18n "admin.config_areas.about.city_for_disputes_help"}} @description={{i18n "admin.config_areas.about.city_for_disputes_help"}}
@format="large" @format="large"
as |field| as |field|
> >

View File

@ -61,7 +61,7 @@ export default class AdminFilteredSiteSettings extends Component {
/> />
<ConditionalLoadingSpinner @condition={{this.loading}}> <ConditionalLoadingSpinner @condition={{this.loading}}>
<section class="form-horizontal settings"> <section class="admin-filtered-site-settings form-horizontal settings">
{{#each this.visibleSettings as |setting|}} {{#each this.visibleSettings as |setting|}}
<SiteSetting @setting={{setting}} /> <SiteSetting @setting={{setting}} />
{{/each}} {{/each}}

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon";
export default class AdminNotice extends Component {
@action
dismiss() {
this.args.dismissCallback(this.args.problem);
}
<template>
<div class="notice">
<div class="message">
{{if @icon (icon @icon)}}
{{htmlSafe @problem.message}}
</div>
<DButton
@action={{this.dismiss}}
@label="admin.dashboard.dismiss_notice"
/>
</div>
</template>
}

View File

@ -24,6 +24,10 @@ export default class AdminPluginConfigPage extends Component {
return classes.join(" "); return classes.join(" ");
} }
get actionsOutletName() {
return `admin-plugin-config-page-actions-${this.args.plugin.kebabCaseName}`;
}
linkText(navLink) { linkText(navLink) {
if (navLink.label) { if (navLink.label) {
return i18n(navLink.label); return i18n(navLink.label);
@ -68,10 +72,12 @@ export default class AdminPluginConfigPage extends Component {
{{/if}} {{/if}}
</:tabs> </:tabs>
<:actions as |actions|> <:actions as |actions|>
<PluginOutlet <div class={{this.actionsOutletName}}>
@name="admin-plugin-config-page-actions" <PluginOutlet
@outletArgs={{hash plugin=@plugin actions=actions}} @name={{this.actionsOutletName}}
/> @outletArgs={{hash plugin=@plugin actions=actions}}
/>
</div>
</:actions> </:actions>
</AdminPageHeader> </AdminPageHeader>

View File

@ -0,0 +1,77 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import {
DangerButton,
DefaultButton,
PrimaryButton,
} from "admin/components/admin-page-action-button";
export default class AdminSectionLandingItem extends Component {
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
} else if (this.args.titleLabel) {
return i18n(this.args.titleLabel);
}
}
get description() {
if (this.args.descriptionLabelTranslated) {
return this.args.descriptionLabelTranslated;
} else if (this.args.descriptionLabel) {
return i18n(this.args.descriptionLabel);
}
}
get tagline() {
if (this.args.taglineLabelTranslated) {
return this.args.taglineLabelTranslated;
} else if (this.args.taglineLabel) {
return i18n(this.args.taglineLabel);
}
}
<template>
<div
class={{concatClass "admin-section-landing-item" (if @icon "-has-icon")}}
...attributes
>
{{#if @imageUrl}}
<img class="admin-section-landing-item__image" src={{@imageUrl}} />
{{/if}}
{{#if @icon}}
<div class="admin-section-landing-item__icon">
{{dIcon @icon}}
</div>
{{/if}}
<div class="admin-section-landing-item__content">
{{#if this.tagline}}
<h4 class="admin-section-landing-item__tagline">{{this.tagline}}</h4>
{{/if}}
{{#if @titleRoute}}
<LinkTo @route={{@titleRoute}}><h3
class="admin-section-landing-item__title"
>{{this.title}}</h3></LinkTo>
{{else}}
<h3 class="admin-section-landing-item__title">{{this.title}}</h3>
{{/if}}
<p
class="admin-section-landing-item__description"
>{{this.description}}</p>
</div>
<div class="admin-section-landing-item__buttons">
{{yield
(hash Primary=PrimaryButton Default=DefaultButton Danger=DangerButton)
to="buttons"
}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,7 @@
const AdminSectionLandingWrapper = <template>
<div class="admin-section-landing-wrapper" ...attributes>
{{yield}}
</div>
</template>;
export default AdminSectionLandingWrapper;

View File

@ -0,0 +1,78 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import AdminNotice from "admin/components/admin-notice";
export default class DashboardProblems extends Component {
@action
async dismissProblem(problem) {
try {
await ajax(`/admin/admin_notices/${problem.id}`, { type: "DELETE" });
this.args.problems.removeObject(problem);
} catch (error) {
popupAjaxError(error);
}
}
get problems() {
return this.args.problems.sortBy("priority");
}
<template>
{{#if @problems.length}}
<div class="section dashboard-problems">
<div class="section-title">
<h2>
{{icon "heart"}}
{{i18n "admin.dashboard.problems_found"}}
</h2>
</div>
<div class="section-body">
<ConditionalLoadingSection @isLoading={{@loadingProblems}}>
<div class="problem-messages">
<ul>
{{#each this.problems as |problem|}}
<li
class={{concatClass
"dashboard-problem"
(concat "priority-" problem.priority)
}}
>
<AdminNotice
@icon={{if
(eq problem.priority "high")
"triangle-exclamation"
}}
@problem={{problem}}
@dismissCallback={{this.dismissProblem}}
/>
</li>
{{/each}}
</ul>
</div>
<p class="actions">
<DButton
@action={{@refreshProblems}}
@icon="arrows-rotate"
@label="admin.dashboard.refresh_problems"
class="btn-default"
/>
{{i18n "admin.dashboard.last_checked"}}:
{{@problemsTimestamp}}
</p>
</ConditionalLoadingSection>
</div>
</div>
{{/if}}
</template>
}

View File

@ -1,58 +0,0 @@
{{#if this.foundProblems}}
<div class="section dashboard-problems">
<div class="section-title">
<h2>
{{d-icon "heart"}}
{{i18n "admin.dashboard.problems_found"}}
</h2>
</div>
<div class="section-body">
<ConditionalLoadingSection @isLoading={{this.loadingProblems}}>
{{#if this.highPriorityProblems.length}}
<div class="problem-messages priority-high">
<ul>
{{#each this.highPriorityProblems as |problem|}}
<li
class={{concat
"dashboard-problem "
"priority-"
problem.priority
}}
>
{{d-icon "triangle-exclamation"}}
{{html-safe problem.message}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="problem-messages priority-low">
<ul>
{{#each this.lowPriorityProblems as |problem|}}
<li
class={{concat
"dashboard-problem "
"priority-"
problem.priority
}}
>{{html-safe problem.message}}</li>
{{/each}}
</ul>
</div>
<p class="actions">
<DButton
@action={{this.refreshProblems}}
@icon="arrows-rotate"
@label="admin.dashboard.refresh_problems"
class="btn-default"
/>
{{i18n "admin.dashboard.last_checked"}}:
{{this.problemsTimestamp}}
</p>
</ConditionalLoadingSection>
</div>
</div>
{{/if}}

View File

@ -1,3 +0,0 @@
import Component from "@ember/component";
export default class DashboardProblems extends Component {}

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import highlightSyntax from "discourse/lib/highlight-syntax";
export default class HighlightedCode extends Component {
@service session;
@service siteSettings;
highlight = modifier(async (element) => {
const code = document.createElement("code");
code.classList.add(`lang-${this.args.lang}`);
code.textContent = this.args.code;
const pre = document.createElement("pre");
pre.appendChild(code);
element.replaceChildren(pre);
await highlightSyntax(pre, this.siteSettings, this.session);
});
<template>
<div {{this.highlight}}></div>
</template>
}

View File

@ -1 +0,0 @@
<pre><code class="lang-{{this.lang}}">{{this.code}}</code></pre>

View File

@ -1,11 +0,0 @@
import Component from "@ember/component";
import { observes, on } from "@ember-decorators/object";
import highlightSyntax from "discourse/lib/highlight-syntax";
export default class HighlightedCode extends Component {
@on("didInsertElement")
@observes("code")
_refresh() {
highlightSyntax(this.element, this.siteSettings, this.session);
}
}

View File

@ -18,16 +18,6 @@ export default class AdminDashboardController extends Controller {
@setting("version_checks") showVersionChecks; @setting("version_checks") showVersionChecks;
@discourseComputed(
"lowPriorityProblems.length",
"highPriorityProblems.length"
)
foundProblems(lowPriorityProblemsLength, highPriorityProblemsLength) {
const problemsLength =
lowPriorityProblemsLength + highPriorityProblemsLength;
return this.currentUser.admin && problemsLength > 0;
}
@computed("siteSettings.dashboard_visible_tabs") @computed("siteSettings.dashboard_visible_tabs")
get visibleTabs() { get visibleTabs() {
return (this.siteSettings.dashboard_visible_tabs || "") return (this.siteSettings.dashboard_visible_tabs || "")
@ -106,16 +96,7 @@ export default class AdminDashboardController extends Controller {
}); });
AdminDashboard.fetchProblems() AdminDashboard.fetchProblems()
.then((model) => { .then((model) => this.set("problems", model.problems))
this.set(
"highPriorityProblems",
model.problems.filterBy("priority", "high")
);
this.set(
"lowPriorityProblems",
model.problems.filterBy("priority", "low")
);
})
.finally(() => this.set("loadingProblems", false)); .finally(() => this.set("loadingProblems", false));
} }

View File

@ -78,8 +78,8 @@ export default class AdminUserIndexController extends Controller.extend(
@discourseComputed("model.associated_accounts") @discourseComputed("model.associated_accounts")
associatedAccounts(associatedAccounts) { associatedAccounts(associatedAccounts) {
return associatedAccounts return associatedAccounts
.map((provider) => `${provider.name} (${provider.description})`) ?.map((provider) => `${provider.name} (${provider.description})`)
.join(", "); ?.join(", ");
} }
@discourseComputed("model.user_fields.[]") @discourseComputed("model.user_fields.[]")
@ -319,6 +319,16 @@ export default class AdminUserIndexController extends Controller.extend(
return this.model.silence(); return this.model.silence();
} }
@action
deleteAssociatedAccounts() {
this.dialog.yesNoConfirm({
message: I18n.t("admin.user.delete_associated_accounts_confirm"),
didConfirm: () => {
this.model.deleteAssociatedAccounts().catch(popupAjaxError);
},
});
}
@action @action
anonymize() { anonymize() {
const user = this.model; const user = this.model;

View File

@ -24,6 +24,10 @@ export default class AdminPlugin {
return this.name.replaceAll("-", "_"); return this.name.replaceAll("-", "_");
} }
get kebabCaseName() {
return this.name.replaceAll(" ", "-").replaceAll("_", "-");
}
get translatedCategoryName() { get translatedCategoryName() {
// We do this because the site setting list is grouped by category, // We do this because the site setting list is grouped by category,
// with plugins that have their root site setting key defined as `plugins:` // with plugins that have their root site setting key defined as `plugins:`

View File

@ -287,6 +287,17 @@ export default class AdminUser extends User {
}); });
} }
deleteAssociatedAccounts() {
return ajax(`/admin/users/${this.id}/delete_associated_accounts`, {
type: "PUT",
data: {
context: window.location.pathname,
},
}).then(() => {
this.set("associated_accounts", []);
});
}
destroy(formData) { destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, { return ajax(`/admin/users/${this.id}.json`, {
type: "DELETE", type: "DELETE",

View File

@ -239,5 +239,13 @@ export default function () {
path: "/whats-new", path: "/whats-new",
resetNamespace: true, resetNamespace: true,
}); });
this.route(
"adminSection",
{ path: "/section", resetNamespace: true },
function () {
this.route("account");
}
);
}); });
} }

View File

@ -18,9 +18,7 @@
<DashboardProblems <DashboardProblems
@loadingProblems={{this.loadingProblems}} @loadingProblems={{this.loadingProblems}}
@foundProblems={{this.foundProblems}} @problems={{this.problems}}
@lowPriorityProblems={{this.lowPriorityProblems}}
@highPriorityProblems={{this.highPriorityProblems}}
@problemsTimestamp={{this.problemsTimestamp}} @problemsTimestamp={{this.problemsTimestamp}}
@refreshProblems={{action "refreshProblems"}} @refreshProblems={{action "refreshProblems"}}
/> />

View File

@ -0,0 +1,26 @@
<AdminPageHeader
@titleLabel="admin.section_landing_pages.account.title"
@hideTabs={{true}}
>
<:breadcrumbs>
<DBreadcrumbsItem
@path="/admin/section/account"
@label={{i18n "admin.section_landing_pages.account.title"}}
/>
</:breadcrumbs>
</AdminPageHeader>
<AdminSectionLandingWrapper>
<AdminSectionLandingItem
@icon="box-archive"
@titleLabel="admin.section_landing_pages.account.backups.title"
@descriptionLabel="admin.section_landing_pages.account.backups.description"
@titleRoute="admin.backups"
/>
<AdminSectionLandingItem
@icon="gift"
@titleLabel="admin.section_landing_pages.account.whats_new.title"
@descriptionLabel="admin.section_landing_pages.account.whats_new.description"
@titleRoute="admin.whatsNew"
/>
</AdminSectionLandingWrapper>

View File

@ -157,6 +157,17 @@
/> />
{{/if}} {{/if}}
</div> </div>
{{#if (and this.currentUser.admin this.associatedAccounts)}}
<div class="controls">
<DButton
@action={{this.deleteAssociatedAccounts}}
@icon="trash-can"
@label="admin.users.delete_associated_accounts.text"
@title="admin.users.delete_associated_accounts.title"
class="btn-danger"
/>
</div>
{{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@ -14,32 +14,32 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"@ember/string": "^4.0.0", "@ember/string": "^4.0.0",
"discourse-common": "workspace:1.0.0", "discourse-common": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1" "ember-template-imports": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@ember/optional-features": "^2.1.0", "@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -9,17 +9,17 @@
], ],
"dependencies": { "dependencies": {
"a11y-dialog": "8.1.1", "a11y-dialog": "8.1.1",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1", "ember-template-imports": "^4.1.2",
"truth-helpers": "workspace:1.0.0" "truth-helpers": "workspace:1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -44,11 +44,12 @@ export function registerRawHelpers(hbs, handlebarsClass, owner) {
} }
let list = get(this, contextName); let list = get(this, contextName);
let output = []; let output = [];
let innerContext = { ...options.contexts[0] }; let innerContext = options.contexts[0];
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
innerContext[localName] = list[i]; innerContext[localName] = list[i];
output.push(options.fn(innerContext)); output.push(options.fn(innerContext));
} }
delete innerContext[localName];
return output.join(""); return output.join("");
} }
); );

View File

@ -14,7 +14,7 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"@ember/string": "^4.0.0", "@ember/string": "^4.0.0",
"@uppy/aws-s3": "3.0.6", "@uppy/aws-s3": "3.0.6",
"@uppy/aws-s3-multipart": "3.1.3", "@uppy/aws-s3-multipart": "3.1.3",
@ -23,10 +23,10 @@
"@uppy/utils": "5.4.3", "@uppy/utils": "5.4.3",
"@uppy/xhr-upload": "3.1.1", "@uppy/xhr-upload": "3.1.1",
"discourse-i18n": "workspace:1.0.0", "discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"truth-helpers": "workspace:1.0.0" "truth-helpers": "workspace:1.0.0"
}, },
@ -34,20 +34,20 @@
"@ember/optional-features": "^2.1.0", "@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -21,7 +21,7 @@
"@embroider/addon-shim": "^1.8.9", "@embroider/addon-shim": "^1.8.9",
"discourse-common": "workspace:1.0.0", "discourse-common": "workspace:1.0.0",
"discourse-i18n": "workspace:1.0.0", "discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"markdown-it": "14.0.0", "markdown-it": "14.0.0",
"pretty-text": "workspace:1.0.0", "pretty-text": "workspace:1.0.0",
"truth-helpers": "workspace:1.0.0", "truth-helpers": "workspace:1.0.0",

View File

@ -8,18 +8,18 @@
"ember-addon" "ember-addon"
], ],
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"deprecation-silencer": "workspace:1.0.0", "deprecation-silencer": "workspace:1.0.0",
"discourse-hbr": "workspace:1.0.0", "discourse-hbr": "workspace:1.0.0",
"discourse-widget-hbs": "workspace:1.0.0", "discourse-widget-hbs": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1", "ember-template-imports": "^4.1.2",
"ember-this-fallback": "^0.4.0" "ember-this-fallback": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -14,8 +14,8 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"handlebars": "^4.7.8" "handlebars": "^4.7.8"
@ -26,17 +26,17 @@
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/syntax": "^0.92.3", "@glimmer/syntax": "^0.92.3",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { registerWaiter } from "@ember/test"; import { buildWaiter } from "@ember/test-waiters";
import { modifier } from "ember-modifier"; import { modifier } from "ember-modifier";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import loadAce from "discourse/lib/load-ace-editor"; import loadAce from "discourse/lib/load-ace-editor";
@ -11,6 +11,7 @@ import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
const WAITER = buildWaiter("ace-editor");
const COLOR_VARS_REGEX = const COLOR_VARS_REGEX =
/\$(primary|secondary|tertiary|quaternary|header_background|header_primary|highlight|danger|success|love)(\s|;|-(low|medium|high))/g; /\$(primary|secondary|tertiary|quaternary|header_background|header_primary|highlight|danger|success|love)(\s|;|-(low|medium|high))/g;
@ -72,11 +73,10 @@ export default class AceEditor extends Component {
this.editor.getSession().setValue(this.args.content || ""); this.editor.getSession().setValue(this.args.content || "");
this.skipChangePropagation = false; this.skipChangePropagation = false;
if (isTesting()) { const token = WAITER.beginAsync();
let finished = false; this.editor.renderer.once("afterRender", () => WAITER.endAsync(token));
registerWaiter(() => finished);
this.editor.renderer.once("afterRender", () => (finished = true)); return () => WAITER.endAsync(token);
}
}); });
constructor() { constructor() {
@ -94,7 +94,7 @@ export default class AceEditor extends Component {
this.appEvents.on("ace:resize", this.resize); this.appEvents.on("ace:resize", this.resize);
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
this._darkModeListener = window.matchMedia("(prefers-color-scheme: dark)"); this._darkModeListener = window.matchMedia("(prefers-color-scheme: dark)");
this._darkModeListener.addListener(this.setAceTheme); this._darkModeListener.addEventListener("change", this.setAceTheme);
} }
willDestroy() { willDestroy() {
@ -102,7 +102,7 @@ export default class AceEditor extends Component {
this.editor?.destroy(); this.editor?.destroy();
this._darkModeListener?.removeListener(this.setAceTheme); this._darkModeListener?.removeEventListener("change", this.setAceTheme);
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
this.appEvents.off("ace:resize", this.resize); this.appEvents.off("ace:resize", this.resize);
} }

View File

@ -1,100 +1,110 @@
{{#each this.categories as |c|}} <PluginOutlet
<PluginOutlet @name="categories-boxes-wrapper"
@name="category-box-before-each-box" @outletArgs={{hash categories=this.categories}}
@outletArgs={{hash category=c}} >
/> {{#each this.categories as |c|}}
<PluginOutlet
<div @name="category-box-before-each-box"
style={{category-color-variable c.color}} @outletArgs={{hash category=c}}
data-category-id={{c.id}} />
data-notification-level={{c.notificationLevelString}}
data-url={{c.url}}
class="category category-box category-box-{{c.slug}}
{{if c.isMuted 'muted'}}"
>
<div class="category-box-inner">
{{#unless c.isMuted}}
<div class="category-logo">
{{#if c.uploaded_logo.url}}
<CategoryLogo @category={{c}} />
{{/if}}
</div>
{{/unless}}
<div class="category-details">
<div class="category-box-heading">
<a class="parent-box-link" href={{c.url}}>
<h3>
<CategoryTitleBefore @category={{c}} />
{{#if c.read_restricted}}
{{d-icon this.lockIcon}}
{{/if}}
{{c.displayName}}
</h3>
</a>
</div>
<div
style={{category-color-variable c.color}}
data-category-id={{c.id}}
data-notification-level={{c.notificationLevelString}}
data-url={{c.url}}
class="category category-box category-box-{{c.slug}}
{{if c.isMuted 'muted'}}"
>
<div class="category-box-inner">
{{#unless c.isMuted}} {{#unless c.isMuted}}
<div class="description"> <div class="category-logo">
{{html-safe c.description_excerpt}} {{#if c.uploaded_logo.url}}
<CategoryLogo @category={{c}} />
{{/if}}
</div>
{{/unless}}
<div class="category-details">
<div class="category-box-heading">
<a class="parent-box-link" href={{c.url}}>
<h3>
<CategoryTitleBefore @category={{c}} />
{{#if c.read_restricted}}
{{d-icon this.lockIcon}}
{{/if}}
{{c.displayName}}
</h3>
</a>
</div> </div>
{{#if c.isGrandParent}} {{#unless c.isMuted}}
{{#each c.subcategories as |subcategory|}} <div class="description">
<div {{html-safe c.description_excerpt}}
data-category-id={{subcategory.id}}
data-notification-level={{subcategory.notificationLevelString}}
style={{border-color subcategory.color}}
class="subcategory with-subcategories
{{if subcategory.uploaded_logo.url 'has-logo' 'no-logo'}}"
>
<div class="subcategory-box-inner">
<CategoryTitleLink @tagName="h4" @category={{subcategory}} />
{{#if subcategory.subcategories}}
<div class="subcategories">
{{#each subcategory.subcategories as |subsubcategory|}}
{{#unless subsubcategory.isMuted}}
<span class="subcategory">
<CategoryTitleBefore @category={{subsubcategory}} />
{{category-link subsubcategory hideParent="true"}}
</span>
{{/unless}}
{{/each}}
</div>
{{/if}}
</div>
</div>
{{/each}}
{{else if c.subcategories}}
<div class="subcategories">
{{#each c.subcategories as |sc|}}
<a class="subcategory" href={{sc.url}}>
<span class="subcategory-image-placeholder">
{{#if sc.uploaded_logo.url}}
<CategoryLogo @category={{sc}} />
{{/if}}
</span>
{{category-link sc hideParent="true"}}
</a>
{{/each}}
</div> </div>
{{/if}}
{{/unless}} {{#if c.isGrandParent}}
{{#each c.subcategories as |subcategory|}}
<div
data-category-id={{subcategory.id}}
data-notification-level={{subcategory.notificationLevelString}}
style={{border-color subcategory.color}}
class="subcategory with-subcategories
{{if subcategory.uploaded_logo.url 'has-logo' 'no-logo'}}"
>
<div class="subcategory-box-inner">
<CategoryTitleLink
@tagName="h4"
@category={{subcategory}}
/>
{{#if subcategory.subcategories}}
<div class="subcategories">
{{#each subcategory.subcategories as |subsubcategory|}}
{{#unless subsubcategory.isMuted}}
<span class="subcategory">
<CategoryTitleBefore
@category={{subsubcategory}}
/>
{{category-link subsubcategory hideParent="true"}}
</span>
{{/unless}}
{{/each}}
</div>
{{/if}}
</div>
</div>
{{/each}}
{{else if c.subcategories}}
<div class="subcategories">
{{#each c.subcategories as |sc|}}
<a class="subcategory" href={{sc.url}}>
<span class="subcategory-image-placeholder">
{{#if sc.uploaded_logo.url}}
<CategoryLogo @category={{sc}} />
{{/if}}
</span>
{{category-link sc hideParent="true"}}
</a>
{{/each}}
</div>
{{/if}}
{{/unless}}
</div>
<PluginOutlet
@name="category-box-below-each-category"
@outletArgs={{hash category=c}}
/>
</div> </div>
<PluginOutlet
@name="category-box-below-each-category"
@outletArgs={{hash category=c}}
/>
</div> </div>
</div>
<PluginOutlet <PluginOutlet
@name="category-box-after-each-box" @name="category-box-after-each-box"
@outletArgs={{hash category=c}} @outletArgs={{hash category=c}}
/> />
{{/each}} {{/each}}
</PluginOutlet>
<PluginOutlet <PluginOutlet
@name="category-boxes-after-boxes" @name="category-boxes-after-boxes"

View File

@ -1,49 +1,16 @@
{{#if this.categories}} <PluginOutlet
{{#if this.filteredCategories}} @name="categories-only-wrapper"
<table class="category-list {{if this.showTopics 'with-topics'}}"> @outletArgs={{hash categories=this.categories}}
<thead> >
<tr> {{#if this.categories}}
<th class="category"><span {{#if this.filteredCategories}}
role="heading" <table class="category-list {{if this.showTopics 'with-topics'}}">
aria-level="2"
id="categories-only-category"
>{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}}
<th class="latest">{{i18n "categories.latest"}}</th>
{{/if}}
</tr>
</thead>
<tbody aria-labelledby="categories-only-category">
{{#each this.categories as |category|}}
<ParentCategoryRow
@category={{category}}
@showTopics={{this.showTopics}}
/>
{{/each}}
</tbody>
</table>
{{/if}}
{{#if this.mutedCategories}}
<div class="muted-categories">
<a href class="muted-categories-link" {{on "click" this.toggleShowMuted}}>
<h3 class="muted-categories-heading">{{i18n "categories.muted"}}</h3>
{{#if this.mutedToggleIcon}}
{{d-icon this.mutedToggleIcon}}
{{/if}}
</a>
<table
class="category-list
{{if this.showTopics 'with-topics'}}
{{unless this.showMutedCategories 'hidden'}}"
>
<thead> <thead>
<tr> <tr>
<th class="category"><span <th class="category"><span
role="heading" role="heading"
aria-level="2" aria-level="2"
id="categories-only-category-muted" id="categories-only-category"
>{{i18n "categories.category"}}</span></th> >{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th> <th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}} {{#if this.showTopics}}
@ -51,19 +18,61 @@
{{/if}} {{/if}}
</tr> </tr>
</thead> </thead>
<tbody aria-labelledby="categories-only-category-muted"> <tbody aria-labelledby="categories-only-category">
{{#each this.categories as |category|}} {{#each this.categories as |category|}}
<ParentCategoryRow <ParentCategoryRow
@category={{category}} @category={{category}}
@showTopics={{this.showTopics}} @showTopics={{this.showTopics}}
@listType="muted"
/> />
{{/each}} {{/each}}
</tbody> </tbody>
</table> </table>
</div> {{/if}}
{{#if this.mutedCategories}}
<div class="muted-categories">
<a
href
class="muted-categories-link"
{{on "click" this.toggleShowMuted}}
>
<h3 class="muted-categories-heading">{{i18n "categories.muted"}}</h3>
{{#if this.mutedToggleIcon}}
{{d-icon this.mutedToggleIcon}}
{{/if}}
</a>
<table
class="category-list
{{if this.showTopics 'with-topics'}}
{{unless this.showMutedCategories 'hidden'}}"
>
<thead>
<tr>
<th class="category"><span
role="heading"
aria-level="2"
id="categories-only-category-muted"
>{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}}
<th class="latest">{{i18n "categories.latest"}}</th>
{{/if}}
</tr>
</thead>
<tbody aria-labelledby="categories-only-category-muted">
{{#each this.categories as |category|}}
<ParentCategoryRow
@category={{category}}
@showTopics={{this.showTopics}}
@listType="muted"
/>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{/if}} {{/if}}
{{/if}} </PluginOutlet>
<PluginOutlet <PluginOutlet
@name="below-categories-only" @name="below-categories-only"

View File

@ -1,21 +1,26 @@
<section class="field category-name-fields"> <PluginOutlet
{{#unless this.category.isUncategorizedCategory}} @name="category-name-fields-details"
@outletArgs={{hash category=this.category}}
>
<section class="field category-name-fields">
{{#unless this.category.isUncategorizedCategory}}
<section class="field-item">
<label>{{i18n "category.name"}}</label>
<TextField
@value={{this.category.name}}
@placeholderKey="category.name_placeholder"
@maxlength="50"
class="category-name"
/>
</section>
{{/unless}}
<section class="field-item"> <section class="field-item">
<label>{{i18n "category.name"}}</label> <label>{{i18n "category.slug"}}</label>
<TextField <TextField
@value={{this.category.name}} @value={{this.category.slug}}
@placeholderKey="category.name_placeholder" @placeholderKey="category.slug_placeholder"
@maxlength="50" @maxlength="255"
class="category-name"
/> />
</section> </section>
{{/unless}}
<section class="field-item">
<label>{{i18n "category.slug"}}</label>
<TextField
@value={{this.category.slug}}
@placeholderKey="category.slug_placeholder"
@maxlength="255"
/>
</section> </section>
</section> </PluginOutlet>

View File

@ -222,9 +222,7 @@ export default class GlimmerSiteHeader extends Component {
} }
).finished; ).finished;
if (isTesting()) { waitForPromise(animationFinished);
waitForPromise(animationFinished);
}
cloakElement.animate([{ opacity: 0 }], { fill: "forwards" }); cloakElement.animate([{ opacity: 0 }], { fill: "forwards" });
cloakElement.style.display = "block"; cloakElement.style.display = "block";

View File

@ -3,7 +3,6 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { waitForPromise } from "@ember/test-waiters"; import { waitForPromise } from "@ember/test-waiters";
import { create } from "virtual-dom"; import { create } from "virtual-dom";
import { isTesting } from "discourse-common/config/environment";
import { iconNode } from "discourse-common/lib/icon-library"; import { iconNode } from "discourse-common/lib/icon-library";
export default class JsonSchemaEditorModal extends Component { export default class JsonSchemaEditorModal extends Component {
@ -38,9 +37,7 @@ export default class JsonSchemaEditorModal extends Component {
@action @action
async buildJsonEditor(element) { async buildJsonEditor(element) {
const promise = import("@json-editor/json-editor"); const promise = import("@json-editor/json-editor");
if (isTesting()) { waitForPromise(promise);
waitForPromise(promise);
}
const { JSONEditor } = await promise; const { JSONEditor } = await promise;
JSONEditor.defaults.options.theme = "barebones"; JSONEditor.defaults.options.theme = "barebones";

View File

@ -77,7 +77,8 @@ export default class TopicAdminMenu extends Component {
return ( return (
this.currentUser?.canManageTopic || this.currentUser?.canManageTopic ||
this.details?.can_archive_topic || this.details?.can_archive_topic ||
this.details?.can_close_topic this.details?.can_close_topic ||
this.details?.can_split_merge_topic
); );
} }

View File

@ -1,39 +1,29 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper"; import { hash } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet"; import PluginOutlet from "discourse/components/plugin-outlet";
import coldAgeClass from "discourse/helpers/cold-age-class"; import coldAgeClass from "discourse/helpers/cold-age-class";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import element from "discourse/helpers/element";
import formatDate from "discourse/helpers/format-date"; import formatDate from "discourse/helpers/format-date";
export default class ActivityColumn extends Component { const ActivityColumn = <template>
@service siteSettings; <td
title={{htmlSafe @topic.bumpedAtTitle}}
get wrapperElement() { class={{concatClass
return element(this.args.tagName ?? "td"); "activity"
} (coldAgeClass @topic.createdAt startDate=@topic.bumpedAt class="")
}}
<template> ...attributes
<this.wrapperElement >
title={{htmlSafe @topic.bumpedAtTitle}} <a
class={{concatClass href={{@topic.lastPostUrl}}
"activity" class="post-activity"
(coldAgeClass @topic.createdAt startDate=@topic.bumpedAt class="") >{{! no whitespace
}}
...attributes
>
<a
href={{@topic.lastPostUrl}}
class="post-activity"
>{{! no whitespace
}}<PluginOutlet }}<PluginOutlet
@name="topic-list-before-relative-date" @name="topic-list-before-relative-date"
@outletArgs={{hash topic=@topic}} @outletArgs={{hash topic=@topic}}
/> />
{{~formatDate @topic.bumpedAt format="tiny" noTitle="true"~}} {{~formatDate @topic.bumpedAt format="tiny" noTitle="true"~}}
</a> </a>
</this.wrapperElement> </td>
</template> </template>;
} export default ActivityColumn;

View File

@ -1,6 +1,5 @@
import { on } from "@ember/modifier"; import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import TopicPostBadges from "discourse/components/topic-post-badges"; import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status"; import TopicStatus from "discourse/components/topic-status";
import formatAge from "discourse/helpers/format-age"; import formatAge from "discourse/helpers/format-age";
@ -30,13 +29,11 @@ const FeaturedTopic = <template>
@url={{@topic.lastUnreadUrl}} @url={{@topic.lastUnreadUrl}}
/> />
<TopicEntrance @topic={{@topic}}> <a
<a {{on "click" onTimestampClick}}
{{on "click" onTimestampClick}} href={{@topic.lastPostUrl}}
href={{@topic.lastPostUrl}} class="last-posted-at"
class="last-posted-at" >{{formatAge @topic.last_posted_at}}</a>
>{{formatAge @topic.last_posted_at}}</a>
</TopicEntrance>
</div> </div>
</template>; </template>;

View File

@ -1,6 +1,5 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat, hash } from "@ember/helper"; import { concat, hash } from "@ember/helper";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet"; import PluginOutlet from "discourse/components/plugin-outlet";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column"; import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicPostBadges from "discourse/components/topic-post-badges"; import TopicPostBadges from "discourse/components/topic-post-badges";
@ -16,12 +15,8 @@ import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import topicLink from "discourse/helpers/topic-link"; import topicLink from "discourse/helpers/topic-link";
export default class LatestTopicListItem extends Component { export default class LatestTopicListItem extends Component {
@service appEvents;
get tagClassNames() { get tagClassNames() {
if (this.args.topic.tags) { return this.args.topic.tags?.map((tagName) => `tag-${tagName}`);
return this.args.topic.tags.map((tagName) => `tag-${tagName}`);
}
} }
<template> <template>

View File

@ -11,8 +11,6 @@ import i18n from "discourse-common/helpers/i18n";
export default class TopicList extends Component { export default class TopicList extends Component {
@service currentUser; @service currentUser;
@service router;
@service siteSettings;
@tracked lastCheckedElementId; @tracked lastCheckedElementId;

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { hash } from "@ember/helper"; import { hash } from "@ember/helper";
import { service } from "@ember/service"; import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet"; import PluginOutlet from "discourse/components/plugin-outlet";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import element from "discourse/helpers/element"; import element from "discourse/helpers/element";
import number from "discourse/helpers/number"; import number from "discourse/helpers/number";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -55,17 +54,13 @@ export default class PostsCountColumn extends Component {
<this.wrapperElement <this.wrapperElement
class="num posts-map posts {{this.likesHeat}} topic-list-data" class="num posts-map posts {{this.likesHeat}} topic-list-data"
> >
<TopicEntrance <a href={{@topic.firstPostUrl}} class="badge-posts">
@topic={{@topic}}
@title={{this.title}}
@triggerClass="btn-link posts-map badge-posts {{this.likesHeat}}"
>
<PluginOutlet <PluginOutlet
@name="topic-list-before-reply-count" @name="topic-list-before-reply-count"
@outletArgs={{hash topic=@topic}} @outletArgs={{hash topic=@topic}}
/> />
{{number @topic.replyCount noTitle="true"}} {{number @topic.replyCount noTitle="true"}}
</TopicEntrance> </a>
</this.wrapperElement> </this.wrapperElement>
</template> </template>
} }

View File

@ -0,0 +1,168 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import ActionList from "discourse/components/topic-list/action-list";
import ParticipantGroups from "discourse/components/topic-list/participant-groups";
import TopicExcerpt from "discourse/components/topic-list/topic-excerpt";
import TopicLink from "discourse/components/topic-list/topic-link";
import UnreadIndicator from "discourse/components/topic-list/unread-indicator";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import categoryLink from "discourse/helpers/category-link";
import discourseTags from "discourse/helpers/discourse-tags";
import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { groupPath } from "discourse/lib/url";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TopicCell extends Component {
@service currentUser;
@service messageBus;
constructor() {
super(...arguments);
if (this.includeUnreadIndicator) {
this.messageBus.subscribe(this.unreadIndicatorChannel, this.onMessage);
}
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(this.unreadIndicatorChannel, this.onMessage);
}
@bind
onMessage(data) {
const nodeClassList = document.querySelector(
`.indicator-topic-${data.topic_id}`
).classList;
nodeClassList.toggle("read", !data.show_indicator);
}
get unreadIndicatorChannel() {
return `/private-messages/unread-indicator/${this.args.topic.id}`;
}
get includeUnreadIndicator() {
return typeof this.args.topic.unread_by_group_member !== "undefined";
}
get unreadClass() {
return this.args.topic.unread_by_group_member ? "" : "read";
}
get newDotText() {
return this.currentUser?.trust_level > 0
? ""
: I18n.t("filters.new.lower_title");
}
get participantGroups() {
if (!this.args.topic.participant_groups) {
return [];
}
return this.args.topic.participant_groups.map((name) => ({
name,
url: groupPath(name),
}));
}
@action
onTitleFocus(event) {
event.target.classList.add("selected");
}
@action
onTitleBlur(event) {
event.target.classList.remove("selected");
}
<template>
<td class="main-link clearfix topic-list-data" colspan="1">
<PluginOutlet
@name="topic-list-before-link"
@outletArgs={{hash topic=@topic}}
/>
<span class="link-top-line">
{{~! no whitespace ~}}
<PluginOutlet
@name="topic-list-before-status"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<TopicStatus @topic={{@topic}} />
{{~! no whitespace ~}}
<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
&nbsp;
{{~topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet
@name="topic-list-after-title"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<UnreadIndicator
@includeUnreadIndicator={{this.includeUnreadIndicator}}
@topicId={{@topic.id}}
class={{this.unreadClass}}
/>
{{~#if @showTopicPostBadges~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@newDotText={{this.newDotText}}
@url={{@topic.lastUnreadUrl}}
/>
{{~/if~}}
</span>
<div class="link-bottom-line">
{{#unless @hideCategory}}
{{#unless @topic.isPinnedUncategorized}}
<PluginOutlet
@name="topic-list-before-category"
@outletArgs={{hash topic=@topic}}
/>
{{categoryLink @topic.category}}
{{/unless}}
{{/unless}}
{{discourseTags @topic mode="list" tagsForUser=@tagsForUser}}
{{#if this.participantGroups}}
<ParticipantGroups @groups={{this.participantGroups}} />
{{/if}}
<ActionList
@topic={{@topic}}
@postNumbers={{@topic.liked_post_numbers}}
@icon="heart"
class="likes"
/>
</div>
{{#if @expandPinned}}
<TopicExcerpt @topic={{@topic}} />
{{/if}}
<PluginOutlet
@name="topic-list-main-link-bottom"
@outletArgs={{hash topic=@topic}}
/>
</td>
</template>
}

View File

@ -1,111 +0,0 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DiscourseURL from "discourse/lib/url";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
function entranceDate(dt, showTime) {
const today = new Date();
if (dt.toDateString() === today.toDateString()) {
return moment(dt).format(I18n.t("dates.time"));
}
if (dt.getYear() === today.getYear()) {
// No year
return moment(dt).format(
showTime
? I18n.t("dates.long_date_without_year_with_linebreak")
: I18n.t("dates.long_no_year_no_time")
);
}
return moment(dt).format(
showTime
? I18n.t("dates.long_date_with_year_with_linebreak")
: I18n.t("dates.long_date_with_year_without_time")
);
}
export default class TopicEntrance extends Component {
@service historyStore;
get createdDate() {
return new Date(this.args.topic.created_at);
}
get bumpedDate() {
return new Date(this.args.topic.bumped_at);
}
get showTime() {
return (
this.bumpedDate.getTime() - this.createdDate.getTime() <
1000 * 60 * 60 * 24 * 2
);
}
get topDate() {
return entranceDate(this.createdDate, this.showTime);
}
get bottomDate() {
return entranceDate(this.bumpedDate, this.showTime);
}
@action
jumpTo(destination) {
this.historyStore.set("lastTopicIdViewed", this.args.topic.id);
DiscourseURL.routeTo(destination);
}
<template>
<DMenu
@title={{@title}}
@ariaLabel={{@title}}
@placement="center"
@autofocus={{true}}
@triggerClass={{@triggerClass}}
>
<:trigger>
{{yield}}
</:trigger>
<:content>
<div id="topic-entrance" class="--glimmer">
<button
{{on "click" (fn this.jumpTo @topic.url)}}
aria-label={{i18n
"topic_entrance.sr_jump_top_button"
date=this.topDate
}}
title={{i18n "topic_entrance.jump_top_button_title"}}
class="btn btn-default full jump-top"
>
{{icon "backward-step"}}
{{htmlSafe this.topDate}}
</button>
<button
{{on "click" (fn this.jumpTo @topic.lastPostUrl)}}
aria-label={{i18n
"topic_entrance.sr_jump_bottom_button"
date=this.bottomDate
}}
title={{i18n "topic_entrance.jump_bottom_button_title"}}
class="btn btn-default full jump-bottom"
>
{{htmlSafe this.bottomDate}}
{{icon "forward-step"}}
</button>
</div>
</:content>
</DMenu>
</template>
}

View File

@ -9,7 +9,6 @@ import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
export default class TopicListHeaderColumn extends Component { export default class TopicListHeaderColumn extends Component {
@service modal;
@service router; @service router;
get localizedName() { get localizedName() {

View File

@ -8,16 +8,13 @@ import { service } from "@ember/service";
import { modifier } from "ember-modifier"; import { modifier } from "ember-modifier";
import { eq, gt } from "truth-helpers"; import { eq, gt } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet"; import PluginOutlet from "discourse/components/plugin-outlet";
import ActionList from "discourse/components/topic-list/action-list";
import ActivityColumn from "discourse/components/topic-list/activity-column"; import ActivityColumn from "discourse/components/topic-list/activity-column";
import ParticipantGroups from "discourse/components/topic-list/participant-groups";
import PostCountOrBadges from "discourse/components/topic-list/post-count-or-badges"; import PostCountOrBadges from "discourse/components/topic-list/post-count-or-badges";
import PostersColumn from "discourse/components/topic-list/posters-column"; import PostersColumn from "discourse/components/topic-list/posters-column";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column"; import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicCell from "discourse/components/topic-list/topic-cell";
import TopicExcerpt from "discourse/components/topic-list/topic-excerpt"; import TopicExcerpt from "discourse/components/topic-list/topic-excerpt";
import TopicLink from "discourse/components/topic-list/topic-link"; import TopicLink from "discourse/components/topic-list/topic-link";
import UnreadIndicator from "discourse/components/topic-list/unread-indicator";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status"; import TopicStatus from "discourse/components/topic-status";
import { topicTitleDecorators } from "discourse/components/topic-title"; import { topicTitleDecorators } from "discourse/components/topic-title";
import avatar from "discourse/helpers/avatar"; import avatar from "discourse/helpers/avatar";
@ -28,18 +25,12 @@ import formatDate from "discourse/helpers/format-date";
import number from "discourse/helpers/number"; import number from "discourse/helpers/number";
import topicFeaturedLink from "discourse/helpers/topic-featured-link"; import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { wantsNewWindow } from "discourse/lib/intercept-click"; import { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL, { groupPath } from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import icon from "discourse-common/helpers/d-icon"; import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TopicListItem extends Component { export default class TopicListItem extends Component {
@service appEvents;
@service currentUser;
@service historyStore; @service historyStore;
@service messageBus;
@service router;
@service site; @service site;
@service siteSettings; @service siteSettings;
@ -60,58 +51,10 @@ export default class TopicListItem extends Component {
} }
}); });
constructor() {
super(...arguments);
if (this.includeUnreadIndicator) {
this.messageBus.subscribe(this.unreadIndicatorChannel, this.onMessage);
}
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(this.unreadIndicatorChannel, this.onMessage);
}
@bind
onMessage(data) {
const nodeClassList = document.querySelector(
`.indicator-topic-${data.topic_id}`
).classList;
nodeClassList.toggle("read", !data.show_indicator);
}
get unreadIndicatorChannel() {
return `/private-messages/unread-indicator/${this.args.topic.id}`;
}
get includeUnreadIndicator() {
return typeof this.args.topic.unread_by_group_member !== "undefined";
}
get isSelected() { get isSelected() {
return this.args.selected?.includes(this.args.topic); return this.args.selected?.includes(this.args.topic);
} }
get participantGroups() {
if (!this.args.topic.participant_groups) {
return [];
}
return this.args.topic.participant_groups.map((name) => ({
name,
url: groupPath(name),
}));
}
get newDotText() {
return this.currentUser?.trust_level > 0
? ""
: I18n.t("filters.new.lower_title");
}
get tagClassNames() { get tagClassNames() {
return this.args.topic.tags?.map((tagName) => `tag-${tagName}`); return this.args.topic.tags?.map((tagName) => `tag-${tagName}`);
} }
@ -135,10 +78,6 @@ export default class TopicListItem extends Component {
return this.site.desktopView && this.args.focusLastVisitedTopic; return this.site.desktopView && this.args.focusLastVisitedTopic;
} }
get unreadClass() {
return this.args.topic.unread_by_group_member ? "" : "read";
}
navigateToTopic(topic, href) { navigateToTopic(topic, href) {
this.historyStore.set("lastTopicIdViewed", topic.id); this.historyStore.set("lastTopicIdViewed", topic.id);
DiscourseURL.routeTo(href || topic.url); DiscourseURL.routeTo(href || topic.url);
@ -154,16 +93,6 @@ export default class TopicListItem extends Component {
element.classList.add("highlighted"); element.classList.add("highlighted");
} }
@action
onTitleFocus(event) {
event.target.classList.add("selected");
}
@action
onTitleBlur(event) {
event.target.classList.remove("selected");
}
@action @action
applyTitleDecorators(element) { applyTitleDecorators(element) {
const rawTopicLink = element.querySelector(".raw-topic-link"); const rawTopicLink = element.querySelector(".raw-topic-link");
@ -300,85 +229,13 @@ export default class TopicListItem extends Component {
</td> </td>
{{/if}} {{/if}}
<td class="main-link clearfix topic-list-data" colspan="1"> <TopicCell
<PluginOutlet @topic={{@topic}}
@name="topic-list-before-link" @showTopicPostBadges={{@showTopicPostBadges}}
@outletArgs={{hash topic=@topic}} @hideCategory={{@hideCategory}}
/> @tagsForUser={{@tagsForUser}}
@expandPinned={{this.expandPinned}}
<span class="link-top-line"> />
{{~! no whitespace ~}}
<PluginOutlet
@name="topic-list-before-status"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<TopicStatus @topic={{@topic}} />
{{~! no whitespace ~}}
<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
&nbsp;
{{~topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet
@name="topic-list-after-title"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<UnreadIndicator
@includeUnreadIndicator={{this.includeUnreadIndicator}}
@topicId={{@topic.id}}
class={{this.unreadClass}}
/>
{{~#if @showTopicPostBadges~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@newDotText={{this.newDotText}}
@url={{@topic.lastUnreadUrl}}
/>
{{~/if~}}
</span>
<div class="link-bottom-line">
{{#unless @hideCategory}}
{{#unless @topic.isPinnedUncategorized}}
<PluginOutlet
@name="topic-list-before-category"
@outletArgs={{hash topic=@topic}}
/>
{{categoryLink @topic.category}}
{{/unless}}
{{/unless}}
{{discourseTags @topic mode="list" tagsForUser=@tagsForUser}}
{{#if this.participantGroups}}
<ParticipantGroups @groups={{this.participantGroups}} />
{{/if}}
<ActionList
@topic={{@topic}}
@postNumbers={{@topic.liked_post_numbers}}
@icon="heart"
class="likes"
/>
</div>
{{#if this.expandPinned}}
<TopicExcerpt @topic={{@topic}} />
{{/if}}
<PluginOutlet
@name="topic-list-main-link-bottom"
@outletArgs={{hash topic=@topic}}
/>
</td>
<PluginOutlet <PluginOutlet
@name="topic-list-after-main-link" @name="topic-list-after-main-link"

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import optionalService from "discourse/lib/optional-service";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
export default class TopicTimeline extends Component { export default class TopicTimeline extends Component {
@ -13,8 +12,6 @@ export default class TopicTimeline extends Component {
@tracked docked = false; @tracked docked = false;
@tracked dockedBottom = false; @tracked dockedBottom = false;
adminTools = optionalService();
constructor() { constructor() {
super(...arguments); super(...arguments);

View File

@ -15,7 +15,7 @@ export default class UserController extends Controller.extend(CanCheckEmails) {
@service currentUser; @service currentUser;
@service router; @service router;
@service dialog; @service dialog;
@optionalService("admin-tools") adminTools; @optionalService adminTools;
@controller("user-notifications") userNotifications; @controller("user-notifications") userNotifications;

View File

@ -3,7 +3,7 @@ import { concat } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import FKLabel from "discourse/form-kit/components/fk/label"; import FKLabel from "discourse/form-kit/components/fk/label";
import FKMeta from "discourse/form-kit/components/fk/meta"; import FKMeta from "discourse/form-kit/components/fk/meta";
import FormText from "discourse/form-kit/components/fk/text"; import FKText from "discourse/form-kit/components/fk/text";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
@ -60,10 +60,10 @@ export default class FKControlWrapper extends Component {
</FKLabel> </FKLabel>
{{/if}} {{/if}}
{{#if @field.subtitle}} {{#if @field.description}}
<FormText <FKText
class="form-kit__container-subtitle" class="form-kit__container-description"
>{{@field.subtitle}}</FormText> >{{@field.description}}</FKText>
{{/if}} {{/if}}
<div <div
@ -93,12 +93,7 @@ export default class FKControlWrapper extends Component {
{{yield components}} {{yield components}}
</@component> </@component>
<FKMeta <FKMeta @value={{@value}} @field={{@field}} @error={{this.error}} />
@description={{@field.description}}
@value={{@value}}
@field={{@field}}
@error={{this.error}}
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -3,7 +3,6 @@ import { hash } from "@ember/helper";
import FKFieldset from "discourse/form-kit/components/fk/fieldset"; import FKFieldset from "discourse/form-kit/components/fk/fieldset";
import FKControlRadioGroupRadio from "./radio-group/radio"; import FKControlRadioGroupRadio from "./radio-group/radio";
// eslint-disable-next-line ember/no-empty-glimmer-component-classes
export default class FKControlRadioGroup extends Component { export default class FKControlRadioGroup extends Component {
static controlType = "radio-group"; static controlType = "radio-group";

View File

@ -49,7 +49,6 @@ export default class FKField extends Component {
this.field = this.args.registerField(this.name, { this.field = this.args.registerField(this.name, {
triggerRevalidationFor: this.args.triggerRevalidationFor, triggerRevalidationFor: this.args.triggerRevalidationFor,
title: this.args.title, title: this.args.title,
subtitle: this.args.subtitle,
description: this.args.description, description: this.args.description,
showTitle: this.args.showTitle, showTitle: this.args.showTitle,
collectionIndex: this.args.collectionIndex, collectionIndex: this.args.collectionIndex,

View File

@ -1,7 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import FKCharCounter from "discourse/form-kit/components/fk/char-counter"; import FKCharCounter from "discourse/form-kit/components/fk/char-counter";
import FKErrors from "discourse/form-kit/components/fk/errors"; import FKErrors from "discourse/form-kit/components/fk/errors";
import FKText from "discourse/form-kit/components/fk/text";
export default class FKMeta extends Component { export default class FKMeta extends Component {
get shouldRenderCharCounter() { get shouldRenderCharCounter() {
@ -9,12 +8,7 @@ export default class FKMeta extends Component {
} }
get shouldRenderMeta() { get shouldRenderMeta() {
return ( return this.showMeta && (this.shouldRenderCharCounter || this.args.error);
this.showMeta &&
(this.shouldRenderCharCounter ||
this.args.error ||
this.args.description?.length)
);
} }
get showMeta() { get showMeta() {
@ -26,8 +20,6 @@ export default class FKMeta extends Component {
<div class="form-kit__meta"> <div class="form-kit__meta">
{{#if @error}} {{#if @error}}
<FKErrors @id={{@field.errorId}} @error={{@error}} /> <FKErrors @id={{@field.errorId}} @error={{@error}} />
{{else if @description}}
<FKText class="form-kit__meta-description">{{@description}}</FKText>
{{/if}} {{/if}}
{{#if this.shouldRenderCharCounter}} {{#if this.shouldRenderCharCounter}}

View File

@ -12,11 +12,8 @@ export default {
* with the hashtag type via api.registerHashtagType. The default * with the hashtag type via api.registerHashtagType. The default
* ones in core are CategoryHashtagType and TagHashtagType. * ones in core are CategoryHashtagType and TagHashtagType.
*/ */
initialize(owner) { initialize() {
this.site = owner.lookup("service:site");
const cssTag = document.createElement("style"); const cssTag = document.createElement("style");
cssTag.type = "text/css";
cssTag.id = "hashtag-css-generator"; cssTag.id = "hashtag-css-generator";
cssTag.innerHTML = Object.values(getHashtagTypeClasses()) cssTag.innerHTML = Object.values(getHashtagTypeClasses())
.map((hashtagType) => hashtagType.generatePreloadedCssClasses()) .map((hashtagType) => hashtagType.generatePreloadedCssClasses())

View File

@ -8,14 +8,6 @@ export default {
const capabilities = owner.lookup("service:capabilities"); const capabilities = owner.lookup("service:capabilities");
if (siteSettings.composer_media_optimization_image_enabled) { if (siteSettings.composer_media_optimization_image_enabled) {
// NOTE: There are various performance issues with the Canvas
// in iOS Safari that are causing crashes when processing images
// with spikes of over 100% CPU usage. The cause of this is unknown,
// but profiling points to CanvasRenderingContext2D.getImageData()
// and CanvasRenderingContext2D.drawImage().
//
// Until Safari makes some progress with OffscreenCanvas or other
// alternatives we cannot support this workflow.
if ( if (
capabilities.isIOS && capabilities.isIOS &&
!siteSettings.composer_ios_media_optimisation_image_enabled !siteSettings.composer_ios_media_optimisation_image_enabled
@ -23,6 +15,22 @@ export default {
return; return;
} }
// Restrict feature to browsers that support OffscreenCanvas
if (typeof OffscreenCanvas === "undefined") {
return;
}
if (!("createImageBitmap" in self)) {
return;
}
// prior to v18, Safari has WASM memory growth bugs
// eg https://github.com/emscripten-core/emscripten/issues/19144
let match = window.navigator.userAgent.match(/Mobile\/([0-9]+)\./);
let safariVersion = match ? parseInt(match[1], 10) : null;
if (capabilities.isSafari && safariVersion && safariVersion < 18) {
return;
}
addComposerUploadPreProcessor( addComposerUploadPreProcessor(
UppyMediaOptimization, UppyMediaOptimization,
({ isMobileDevice }) => { ({ isMobileDevice }) => {

View File

@ -7,10 +7,10 @@ export default {
Sharing.addSource({ Sharing.addSource({
id: "twitter", id: "twitter",
icon: "fab-twitter", icon: "fab-x-twitter",
generateUrl(link, title, quote = "") { generateUrl(link, title, quote = "") {
const text = quote ? `"${quote}" -- ` : title; const text = quote ? `"${quote}" -- ` : title;
return `http://twitter.com/intent/tweet?url=${encodeURIComponent( return `http://x.com/intent/tweet?url=${encodeURIComponent(
link link
)}&text=${encodeURIComponent(text)}`; )}&text=${encodeURIComponent(text)}`;
}, },

View File

@ -4,23 +4,39 @@ import discourseLater from "discourse-common/lib/later";
// Send bg color to webview so iOS status bar matches site theme // Send bg color to webview so iOS status bar matches site theme
export default { export default {
after: "inject-objects", after: "inject-objects",
retryCount: 0,
initialize(owner) { initialize(owner) {
const caps = owner.lookup("service:capabilities"); const caps = owner.lookup("service:capabilities");
if (caps.isAppWebview) { if (caps.isAppWebview) {
window window
.matchMedia("(prefers-color-scheme: dark)") .matchMedia("(prefers-color-scheme: dark)")
.addListener(this.updateAppBackground); .addEventListener("change", this.updateAppBackground);
this.updateAppBackground(); this.updateAppBackground();
} }
}, },
updateAppBackground() {
updateAppBackground(delay = 500) {
discourseLater(() => { discourseLater(() => {
const header = document.querySelector(".d-header-wrap .d-header"); if (this.headerBgColor()) {
if (header) { postRNWebviewMessage("headerBg", this.headerBgColor());
const styles = window.getComputedStyle(header); } else {
postRNWebviewMessage("headerBg", styles.backgroundColor); this.retry();
} }
}, 500); }, delay);
},
headerBgColor() {
const header = document.querySelector(".d-header-wrap .d-header");
if (header) {
return window.getComputedStyle(header)?.backgroundColor;
}
},
retry() {
if (this.retryCount < 2) {
this.retryCount++;
this.updateAppBackground(1000);
}
}, },
}; };

View File

@ -1,17 +1,13 @@
import DEBUG from "@glimmer/env"; import { buildWaiter } from "@ember/test-waiters";
import { registerWaiter } from "@ember/test";
import { isTesting } from "discourse-common/config/environment"; const WAITER = buildWaiter("after-frame-paint");
/** /**
* Runs `callback` shortly after the next browser Frame is produced. * Runs `callback` shortly after the next browser Frame is produced.
* ref: https://webperf.tips/tip/measuring-paint-time * ref: https://webperf.tips/tip/measuring-paint-time
*/ */
export default function runAfterFramePaint(callback) { export default function runAfterFramePaint(callback) {
let done = false; const token = WAITER.beginAsync();
if (DEBUG && isTesting()) {
registerWaiter(() => done);
}
// Queue a "before Render Steps" callback via requestAnimationFrame. // Queue a "before Render Steps" callback via requestAnimationFrame.
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -21,7 +17,7 @@ export default function runAfterFramePaint(callback) {
// Setup the callback to run in a Task // Setup the callback to run in a Task
messageChannel.port1.onmessage = () => { messageChannel.port1.onmessage = () => {
done = true; WAITER.endAsync(token);
callback(); callback();
}; };

View File

@ -1,10 +1,7 @@
import { waitForPromise } from "@ember/test-waiters"; import { waitForPromise } from "@ember/test-waiters";
import { isTesting } from "discourse-common/config/environment";
export default async function loadAce() { export default async function loadAce() {
const promise = import("discourse/static/ace-editor-bundle"); const promise = import("discourse/static/ace-editor-bundle");
if (isTesting()) { waitForPromise(promise);
waitForPromise(promise);
}
return await promise; return await promise;
} }

View File

@ -1,32 +1,28 @@
import { run } from "@ember/runloop"; import { run } from "@ember/runloop";
import { registerWaiter } from "@ember/test"; import { buildWaiter } from "@ember/test-waiters";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { PUBLIC_JS_VERSIONS } from "discourse/lib/public-js-versions"; import { PUBLIC_JS_VERSIONS } from "discourse/lib/public-js-versions";
import { isTesting } from "discourse-common/config/environment";
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url"; import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
const WAITER = buildWaiter("load-script");
const _loaded = {}; const _loaded = {};
const _loading = {}; const _loading = {};
function loadWithTag(path, cb) { function loadWithTag(path, cb) {
const head = document.getElementsByTagName("head")[0]; const head = document.getElementsByTagName("head")[0];
let finished = false;
let s = document.createElement("script"); let s = document.createElement("script");
s.src = path; s.src = path;
if (isTesting()) { const token = WAITER.beginAsync();
registerWaiter(() => finished);
}
// Don't leave it hanging if something goes wrong // Don't leave it hanging if something goes wrong
s.onerror = function () { s.onerror = function () {
finished = true; WAITER.endAsync(token);
}; };
s.onload = s.onreadystatechange = function (_, abort) { s.onload = s.onreadystatechange = function (_, abort) {
finished = true;
if ( if (
abort || abort ||
!s.readyState || !s.readyState ||
@ -38,6 +34,8 @@ function loadWithTag(path, cb) {
run(null, cb); run(null, cb);
} }
} }
WAITER.endAsync(token);
}; };
head.appendChild(s); head.appendChild(s);

View File

@ -1,35 +1,5 @@
import { Promise } from "rsvp";
import { helperContext } from "discourse-common/lib/helpers";
// Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!)
// Safari < 15 uses the `<img async>` element due to https://bugs.webkit.org/show_bug.cgi?id=182424
// Safari > 15 still uses `<img async>` due to their buggy createImageBitmap not handling EXIF rotation
async function fileToDrawable(file) { async function fileToDrawable(file) {
const caps = helperContext().capabilities; return await createImageBitmap(file);
if ("createImageBitmap" in self && !caps.isApple) {
return await createImageBitmap(file);
} else {
const url = URL.createObjectURL(file);
const img = new Image();
img.decoding = "async";
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error("Image loading error"));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
} }
function drawableToImageData(drawable) { function drawableToImageData(drawable) {
@ -40,17 +10,7 @@ function drawableToImageData(drawable) {
sw = width, sw = width,
sh = height; sh = height;
const offscreenCanvasSupported = typeof OffscreenCanvas !== "undefined"; let canvas = new OffscreenCanvas(width, height);
// Make canvas same size as image
let canvas;
if (offscreenCanvasSupported) {
canvas = new OffscreenCanvas(width, height);
} else {
canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
}
// Draw image onto canvas // Draw image onto canvas
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@ -60,10 +20,6 @@ function drawableToImageData(drawable) {
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height); ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height); const imageData = ctx.getImageData(0, 0, width, height);
if (!offscreenCanvasSupported) {
canvas.remove();
}
return imageData; return imageData;
} }

View File

@ -2,8 +2,16 @@ import { computed } from "@ember/object";
import { getOwner } from "@ember/owner"; import { getOwner } from "@ember/owner";
import { dasherize } from "@ember/string"; import { dasherize } from "@ember/string";
export default function (name) { export default function (target, name, descriptor) {
return computed(function (defaultName) { name ??= target;
const decorator = computed(function (defaultName) {
return getOwner(this).lookup(`service:${name || dasherize(defaultName)}`); return getOwner(this).lookup(`service:${name || dasherize(defaultName)}`);
}); });
if (descriptor) {
return decorator(target, name, descriptor);
} else {
return decorator;
}
} }

View File

@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/. // using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.37.1"; export const PLUGIN_API_VERSION = "1.37.2";
import $ from "jquery"; import $ from "jquery";
import { h } from "virtual-dom"; import { h } from "virtual-dom";
@ -2646,7 +2646,7 @@ class PluginApi {
/** /**
* Changes the lock icon used for a sidebar category section link to indicate that a category is read restricted. * Changes the lock icon used for a sidebar category section link to indicate that a category is read restricted.
* *
* @param {String} Name of a FontAwesome 5 icon * @param {String} Name of a FontAwesome icon
*/ */
registerCustomCategorySectionLinkLockIcon(icon) { registerCustomCategorySectionLinkLockIcon(icon) {
return registerCustomCategoryLockIcon(icon); return registerCustomCategoryLockIcon(icon);
@ -2670,7 +2670,7 @@ class PluginApi {
* @param {string} arg.categoryId - The id of the category * @param {string} arg.categoryId - The id of the category
* @param {string} arg.prefixType - The type of prefix to use. Can be "icon", "image", "text" or "span". * @param {string} arg.prefixType - The type of prefix to use. Can be "icon", "image", "text" or "span".
* @param {string} arg.prefixValue - The value of the prefix to use. * @param {string} arg.prefixValue - The value of the prefix to use.
* For "icon", pass in the name of a FontAwesome 5 icon. * For "icon", pass in the name of a FontAwesome icon.
* For "image", pass in the src of the image. * For "image", pass in the src of the image.
* For "text", pass in the text to display. * For "text", pass in the text to display.
* For "span", pass in an array containing two hex color values. Example: `[FF0000, 000000]`. * For "span", pass in an array containing two hex color values. Example: `[FF0000, 000000]`.
@ -2706,7 +2706,7 @@ class PluginApi {
* *
* @param {Object} arg - An object * @param {Object} arg - An object
* @param {string} arg.tagName - The name of the tag * @param {string} arg.tagName - The name of the tag
* @param {string} arg.prefixValue - The name of a FontAwesome 5 icon. * @param {string} arg.prefixValue - The name of a FontAwesome icon.
* @param {string} arg.prefixColor - The color represented using hexadecimal to use for the prefix. Example: "#FF0000" or "#FFF". * @param {string} arg.prefixColor - The color represented using hexadecimal to use for the prefix. Example: "#FF0000" or "#FFF".
*/ */
registerCustomTagSectionLinkPrefixIcon({ registerCustomTagSectionLinkPrefixIcon({
@ -3002,7 +3002,7 @@ class PluginApi {
* return class extends UserMenuTab { * return class extends UserMenuTab {
* id = "custom-tab-id"; * id = "custom-tab-id";
* panelComponent = MyCustomPanelGlimmerComponent; * panelComponent = MyCustomPanelGlimmerComponent;
* icon = "some-fa5-icon"; * icon = "some-fa-icon";
* *
* get shouldDisplay() { * get shouldDisplay() {
* return this.siteSettings.enable_custom_tab && this.currentUser.admin; * return this.siteSettings.enable_custom_tab && this.currentUser.admin;

View File

@ -16,7 +16,7 @@ export let secondaryCustomSectionLinks = [];
* @param {string} [args.route] - The Ember route name to generate the href attribute for the link. * @param {string} [args.route] - The Ember route name to generate the href attribute for the link.
* @param {string} [args.href] - The href attribute for the link. * @param {string} [args.href] - The href attribute for the link.
* @param {string} [args.title] - The title attribute for the link. * @param {string} [args.title] - The title attribute for the link.
* @param {string} [args.icon] - The FontAwesome 5 icon to display for the link. * @param {string} [args.icon] - The FontAwesome icon to display for the link.
* @param {Boolean} [secondary] - Determines whether the section link should be added to the main or secondary section in the "More..." links drawer. * @param {Boolean} [secondary] - Determines whether the section link should be added to the main or secondary section in the "More..." links drawer.
*/ */
export function addSectionLink(args, secondary) { export function addSectionLink(args, secondary) {

View File

@ -58,4 +58,5 @@
</span> </span>
</div> </div>
</div> </div>
{{~raw-plugin-outlet name="topic-list-after-columns"}}
</td> </td>

View File

@ -29,6 +29,8 @@ export default class MediaOptimizationWorkerService extends Service {
workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js"); workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js");
currentComposerUploadData = null; currentComposerUploadData = null;
promiseResolvers = null; promiseResolvers = null;
workerDoneCount = 0;
workerPendingCount = 0;
async optimizeImage(data, opts = {}) { async optimizeImage(data, opts = {}) {
this.promiseResolvers = this.promiseResolvers || {}; this.promiseResolvers = this.promiseResolvers || {};
@ -98,6 +100,7 @@ export default class MediaOptimizationWorkerService extends Service {
}, },
[imageData.data.buffer] [imageData.data.buffer]
); );
this.workerPendingCount++;
}); });
} }
@ -147,7 +150,9 @@ export default class MediaOptimizationWorkerService extends Service {
this.workerInstalled = false; this.workerInstalled = false;
this.worker.terminate(); this.worker.terminate();
this.worker = null; this.worker = null;
this.workerDoneCount = 0;
} }
this.workerPendingCount = 0;
} }
registerMessageHandler() { registerMessageHandler() {
@ -163,6 +168,13 @@ export default class MediaOptimizationWorkerService extends Service {
this.promiseResolvers[e.data.fileId](optimizedFile); this.promiseResolvers[e.data.fileId](optimizedFile);
this.workerDoneCount++;
this.workerPendingCount--;
if (this.workerDoneCount > 4 && this.workerPendingCount === 0) {
this.logIfDebug("Terminating worker to release memory in WASM.");
this.stopWorker();
}
break; break;
case "error": case "error":
this.logIfDebug( this.logIfDebug(
@ -174,6 +186,7 @@ export default class MediaOptimizationWorkerService extends Service {
} }
this.promiseResolvers[e.data.fileId](); this.promiseResolvers[e.data.fileId]();
this.workerPendingCount--;
break; break;
case "installed": case "installed":
this.logIfDebug("Worker installed."); this.logIfDebug("Worker installed.");

View File

@ -1,32 +1,43 @@
{{body-class "user-badges-page"}} {{body-class "user-badges-page"}}
<section class="user-content" id="user-content"> <section class="user-content" id="user-content">
{{#if this.siteSettings.max_favorite_badges}} <PluginOutlet
<p class="favorite-count"> @name="user-badges-content"
{{i18n @outletArgs={{hash
"badges.favorite_count" sortedBadges=this.sortedBadges
count=this.favoriteBadges.length maxFavBadges=this.siteSettings.max_favorite_badges
max=this.siteSettings.max_favorite_badges favoriteBadges=this.favoriteBadges
}} canFavoriteMoreBadges=this.canFavoriteMoreBadges
</p> favorite=this.favorite
{{/if}} }}
>
{{#if this.siteSettings.max_favorite_badges}}
<p class="favorite-count">
{{i18n
"badges.favorite_count"
count=this.favoriteBadges.length
max=this.siteSettings.max_favorite_badges
}}
</p>
{{/if}}
<div class="badge-group-list"> <div class="badge-group-list">
{{#each this.sortedBadges as |ub|}} {{#each this.sortedBadges as |ub|}}
<BadgeCard <BadgeCard
@badge={{ub.badge}} @badge={{ub.badge}}
@count={{ub.count}} @count={{ub.count}}
@canFavorite={{ub.can_favorite}} @canFavorite={{ub.can_favorite}}
@isFavorite={{ub.is_favorite}} @isFavorite={{ub.is_favorite}}
@username={{this.username}} @username={{this.username}}
@canFavoriteMoreBadges={{this.canFavoriteMoreBadges}} @canFavoriteMoreBadges={{this.canFavoriteMoreBadges}}
@onFavoriteClick={{action "favorite" ub}} @onFavoriteClick={{action "favorite" ub}}
@filterUser="true" @filterUser="true"
/>
{{/each}}
<PluginOutlet
@name="after-user-profile-badges"
@outletArgs={{hash user=this.user.model}}
/> />
{{/each}} </div>
<PluginOutlet </PluginOutlet>
@name="after-user-profile-badges"
@outletArgs={{hash user=this.user.model}}
/>
</div>
</section> </section>

View File

@ -62,6 +62,10 @@ export const ButtonClass = {
attributes["aria-pressed"] = attrs.ariaPressed; attributes["aria-pressed"] = attrs.ariaPressed;
} }
if (attrs.ariaLive) {
attributes["aria-live"] = attrs.ariaLive;
}
if (attrs.tabAttrs) { if (attrs.tabAttrs) {
const tab = attrs.tabAttrs; const tab = attrs.tabAttrs;
attributes["aria-selected"] = tab["aria-selected"]; attributes["aria-selected"] = tab["aria-selected"];

View File

@ -354,6 +354,7 @@ registerButton("copyLink", () => {
icon: "d-post-share", icon: "d-post-share",
className: "post-action-menu__copy-link", className: "post-action-menu__copy-link",
title: "post.controls.copy_title", title: "post.controls.copy_title",
ariaLive: "polite",
}; };
}); });

View File

@ -13,6 +13,7 @@ const DeprecationSilencer = require("deprecation-silencer");
const { compatBuild } = require("@embroider/compat"); const { compatBuild } = require("@embroider/compat");
const { Webpack } = require("@embroider/webpack"); const { Webpack } = require("@embroider/webpack");
const { StatsWriterPlugin } = require("webpack-stats-plugin"); const { StatsWriterPlugin } = require("webpack-stats-plugin");
const { RetryChunkLoadPlugin } = require("webpack-retry-chunk-load-plugin");
const withSideWatch = require("./lib/with-side-watch"); const withSideWatch = require("./lib/with-side-watch");
const RawHandlebarsCompiler = require("discourse-hbr/raw-handlebars-compiler"); const RawHandlebarsCompiler = require("discourse-hbr/raw-handlebars-compiler");
const crypto = require("crypto"); const crypto = require("crypto");
@ -223,6 +224,11 @@ module.exports = function (defaults) {
return JSON.stringify(output, null, 2); return JSON.stringify(output, null, 2);
}, },
}), }),
new RetryChunkLoadPlugin({
retryDelay: 200,
maxRetries: 2,
chunks: ["assets/discourse.js"],
}),
], ],
}, },
}, },

View File

@ -15,11 +15,11 @@
"test": "ember test" "test": "ember test"
}, },
"dependencies": { "dependencies": {
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.3",
"@glimmer/syntax": "^0.92.3", "@glimmer/syntax": "^0.92.3",
"@highlightjs/cdn-assets": "^11.10.0", "@highlightjs/cdn-assets": "^11.10.0",
"@json-editor/json-editor": "2.15.1", "@json-editor/json-editor": "2.15.1",
"@messageformat/core": "^3.3.0", "@messageformat/core": "^3.4.0",
"@messageformat/runtime": "^3.0.1", "@messageformat/runtime": "^3.0.1",
"ace-builds": "^1.36.2", "ace-builds": "^1.36.2",
"decorator-transforms": "^2.0.0", "decorator-transforms": "^2.0.0",
@ -35,8 +35,8 @@
"pretty-text": "workspace:1.0.0" "pretty-text": "workspace:1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"@babel/standalone": "^7.25.6", "@babel/standalone": "^7.25.7",
"@colors/colors": "^1.6.0", "@colors/colors": "^1.6.0",
"@discourse/backburner.js": "^2.7.1-0", "@discourse/backburner.js": "^2.7.1-0",
"@discourse/itsatrap": "^2.0.10", "@discourse/itsatrap": "^2.0.10",
@ -47,17 +47,17 @@
"@ember/string": "^4.0.0", "@ember/string": "^4.0.0",
"@ember/test-helpers": "^4.0.4", "@ember/test-helpers": "^4.0.4",
"@ember/test-waiters": "^3.1.0", "@ember/test-waiters": "^3.1.0",
"@embroider/compat": "^3.6.1", "@embroider/compat": "^3.6.2",
"@embroider/core": "^3.4.15", "@embroider/core": "^3.4.17",
"@embroider/macros": "^1.13.1", "@embroider/macros": "^1.16.7",
"@embroider/router": "^2.1.8", "@embroider/router": "^2.1.8",
"@embroider/webpack": "^4.0.5", "@embroider/webpack": "^4.0.6",
"@floating-ui/dom": "^1.6.10", "@floating-ui/dom": "^1.6.11",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2", "@glimmer/tracking": "^1.1.2",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@swc/core": "^1.7.26", "@swc/core": "^1.7.26",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"@uppy/aws-s3": "3.0.6", "@uppy/aws-s3": "3.0.6",
@ -81,10 +81,10 @@
"discourse-i18n": "workspace:1.0.0", "discourse-i18n": "workspace:1.0.0",
"discourse-markdown-it": "workspace:1.0.0", "discourse-markdown-it": "workspace:1.0.0",
"discourse-plugins": "workspace:1.0.0", "discourse-plugins": "workspace:1.0.0",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-buffered-proxy": "^2.1.1", "ember-buffered-proxy": "^2.1.1",
"ember-cached-decorator-polyfill": "^1.0.2", "ember-cached-decorator-polyfill": "^1.0.2",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-app-version": "^7.0.0", "ember-cli-app-version": "^7.0.0",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-deprecation-workflow": "^3.0.2", "ember-cli-deprecation-workflow": "^3.0.2",
@ -95,36 +95,37 @@
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-decorators": "^6.1.1", "ember-decorators": "^6.1.1",
"ember-exam": "^9.0.0", "ember-exam": "^9.0.0",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-modifier": "^4.2.0", "ember-modifier": "^4.2.0",
"ember-on-resize-modifier": "^2.0.2", "ember-on-resize-modifier": "^2.0.2",
"ember-production-deprecations": "workspace:1.0.0", "ember-production-deprecations": "workspace:1.0.0",
"ember-qunit": "^8.1.0", "ember-qunit": "^8.1.0",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-template-imports": "^4.1.1", "ember-template-imports": "^4.1.2",
"ember-test-selectors": "^7.0.0", "ember-test-selectors": "^7.0.0",
"float-kit": "workspace:1.0.0", "float-kit": "workspace:1.0.0",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"imports-loader": "^5.0.0", "imports-loader": "^5.0.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsuites": "^5.6.0", "jsuites": "^5.6.5",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"make-plural": "^7.4.0", "make-plural": "^7.4.0",
"message-bus-client": "^4.3.8", "message-bus-client": "^4.3.8",
"pretender": "^3.4.7", "pretender": "^3.4.7",
"qunit": "^2.22.0", "qunit": "^2.22.0",
"qunit-dom": "^3.2.0", "qunit-dom": "^3.2.1",
"sass": "^1.77.7", "sass": "^1.77.7",
"select-kit": "workspace:1.0.0", "select-kit": "workspace:1.0.0",
"sinon": "^19.0.1", "sinon": "^19.0.2",
"source-map": "^0.7.4", "source-map": "^0.7.4",
"terser": "^5.32.0", "terser": "^5.34.1",
"testem": "^3.15.2", "testem": "^3.15.2",
"truth-helpers": "workspace:1.0.0", "truth-helpers": "workspace:1.0.0",
"util": "^0.12.5", "util": "^0.12.5",
"virtual-dom": "^2.1.1", "virtual-dom": "^2.1.1",
"webpack": "^5.94.0", "webpack": "^5.95.0",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3", "webpack-stats-plugin": "^1.1.3",
"xss": "^1.0.15" "xss": "^1.0.15"
}, },

View File

@ -4,7 +4,6 @@ import {
acceptance, acceptance,
count, count,
exists, exists,
query,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -60,13 +59,6 @@ acceptance("Dashboard", function (needs) {
exists(".admin-report.new-contributors"), exists(".admin-report.new-contributors"),
"new-contributors report" "new-contributors report"
); );
assert.strictEqual(
query(
".section.dashboard-problems .problem-messages ul li:first-child"
).innerHTML.trim(),
"Houston...",
"displays problems"
);
}); });
test("moderation tab", async function (assert) { test("moderation tab", async function (assert) {

View File

@ -142,12 +142,6 @@ class FieldHelper {
} }
} }
hasSubtitle(subtitle, message) {
this.context
.dom(this.element.querySelector(".form-kit__container-subtitle"))
.hasText(subtitle, message);
}
hasDescription(description, message) { hasDescription(description, message) {
switch (this.element.dataset.controlType) { switch (this.element.dataset.controlType) {
case "checkbox": { case "checkbox": {
@ -162,7 +156,7 @@ class FieldHelper {
} }
default: { default: {
this.context this.context
.dom(this.element.querySelector(".form-kit__meta-description")) .dom(this.element.querySelector(".form-kit__container-description"))
.hasText(description, message); .hasText(description, message);
} }
} }

View File

@ -11,7 +11,7 @@ module(
test("default", async function (assert) { test("default", async function (assert) {
await render(<template> await render(<template>
<Form as |form|> <Form as |form|>
<form.Field @name="foo" @title="Foo" @subtitle="Bar" as |field|> <form.Field @name="foo" @title="Foo" @description="Bar" as |field|>
<field.Custom> <field.Custom>
<input class="custom-test" /> <input class="custom-test" />
</field.Custom> </field.Custom>
@ -20,7 +20,7 @@ module(
</template>); </template>);
assert.dom(".form-kit__container-title").hasText("Foo (optional)"); assert.dom(".form-kit__container-title").hasText("Foo (optional)");
assert.dom(".form-kit__container-subtitle").hasText("Bar"); assert.dom(".form-kit__container-description").hasText("Bar");
}); });
} }
); );

View File

@ -35,18 +35,6 @@ module("Integration | Component | FormKit | Field", function (hooks) {
assert.dom(".form-kit__row .form-kit__col.--col-8").hasText("Test"); assert.dom(".form-kit__row .form-kit__col.--col-8").hasText("Test");
}); });
test("@subtitle", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @subtitle="foo foo" as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
assert.form().field("foo").hasSubtitle("foo foo");
});
test("@description", async function (assert) { test("@description", async function (assert) {
await render(<template> await render(<template>
<Form as |form|> <Form as |form|>

View File

@ -0,0 +1,63 @@
import { tracked } from "@glimmer/tracking";
import { render, settled } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import HighlightedCode from "admin/components/highlighted-code";
module("Integration | Component | highlighted-code", function (hooks) {
setupRenderingTest(hooks);
test("highlighting code", async function (assert) {
await render(<template>
<HighlightedCode @lang="ruby" @code="def test; end" />
</template>);
assert.dom("code.lang-ruby.hljs .hljs-keyword").hasText("def");
});
test("large code blocks are not highlighted", async function (assert) {
const longCodeBlock = "puts a\n".repeat(15000);
await render(<template>
<HighlightedCode @lang="ruby" @code={{longCodeBlock}} />
</template>);
assert.dom("pre code").hasText(longCodeBlock);
});
test("highlighting code with lang=auto", async function (assert) {
await render(<template>
<HighlightedCode @lang="auto" @code="def test; end" />
</template>);
assert.dom("code.hljs").hasNoClass("lang-auto", "lang-auto is removed");
assert.dom("code.hljs").hasClass(/language-/, "language is detected");
assert
.dom("code.hljs")
.hasNoAttribute(
"data-unknown-hljs-lang",
"language is found from language- class"
);
});
test("re-highlights the code when it changes", async function (assert) {
class State {
@tracked code = "def foo; end";
}
const testState = new State();
await render(<template>
<HighlightedCode @lang="ruby" @code={{testState.code}} />
{{testState.code}}
</template>);
assert.dom("code.lang-ruby.hljs .hljs-title").hasText("foo");
testState.code = "def bar; end";
await settled();
assert.dom("code.lang-ruby.hljs .hljs-title").hasText("bar");
});
});

View File

@ -1,61 +0,0 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import highlightSyntax from "discourse/lib/highlight-syntax";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
const LONG_CODE_BLOCK = "puts a\n".repeat(15000);
module("Integration | Component | highlighted-code", function (hooks) {
setupRenderingTest(hooks);
test("highlighting code", async function (assert) {
this.set("code", "def test; end");
await render(hbs`<HighlightedCode @lang="ruby" @code={{this.code}} />`);
assert.strictEqual(
query("code.lang-ruby.hljs .hljs-keyword").innerText.trim(),
"def"
);
});
test("large code blocks are not highlighted", async function (assert) {
this.set("code", LONG_CODE_BLOCK);
await render(hbs`<HighlightedCode @lang="ruby" @code={{this.code}} />`);
assert.strictEqual(query("code").innerText.trim(), LONG_CODE_BLOCK.trim());
});
test("highlighting code with lang=auto", async function (assert) {
this.set("code", "def test; end");
await render(hbs`<HighlightedCode @lang="auto" @code={{this.code}} />`);
const codeElement = query("code.hljs");
assert.notOk(
codeElement.classList.contains("lang-auto"),
"lang-auto is removed"
);
assert.ok(
Array.from(codeElement.classList).some((className) => {
return className.startsWith("language-");
}),
"language is detected"
);
await highlightSyntax(
codeElement.parentElement, // <pre>
this.siteSettings,
this.session
);
assert.notOk(
codeElement.dataset.unknownHljsLang,
"language is found from language- class"
);
});
});

View File

@ -8,6 +8,7 @@ import raw from "discourse/helpers/raw";
import rawRenderGlimmer from "discourse/lib/raw-render-glimmer"; import rawRenderGlimmer from "discourse/lib/raw-render-glimmer";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { compile } from "discourse-common/lib/raw-handlebars"; import { compile } from "discourse-common/lib/raw-handlebars";
import { RUNTIME_OPTIONS } from "discourse-common/lib/raw-handlebars-helpers";
import { import {
addRawTemplate, addRawTemplate,
removeRawTemplate, removeRawTemplate,
@ -121,4 +122,26 @@ module("Integration | Helper | raw", function (hooks) {
assert.dom("span").hasText("foo 1 foo 2"); assert.dom("span").hasText("foo 1 foo 2");
}); });
test("#each helper handles getters", async function (assert) {
const template = `
{{#each items as |item|}}
{{string}} {{item}}
{{/each}}
`;
const compiledTemplate = compile(template);
class Test {
items = [1, 2];
get string() {
return "foo";
}
}
const object = new Test();
const output = compiledTemplate(object, RUNTIME_OPTIONS);
assert.true(/\s*foo 1\s*foo 2\s*/.test(output));
});
}); });

View File

@ -22,8 +22,8 @@ module("Unit | Utility | icon-library", function (hooks) {
}); });
test("convert icon names", function (assert) { test("convert icon names", function (assert) {
const fa5Icon = convertIconClass("fab fa-facebook"); const faIcon = convertIconClass("fab fa-facebook");
assert.ok(iconHTML(fa5Icon).includes("fab-facebook"), "FA 5 syntax"); assert.ok(iconHTML(faIcon).includes("fab-facebook"), "FA syntax");
const iconC = convertIconClass(" fab fa-facebook "); const iconC = convertIconClass(" fab fa-facebook ");
assert.ok(!iconHTML(iconC).includes(" "), "trims whitespace"); assert.ok(!iconHTML(iconC).includes(" "), "trims whitespace");

View File

@ -0,0 +1,59 @@
import Component from "@ember/component";
import Service from "@ember/service";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import optionalService from "discourse/lib/optional-service";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
class FooService extends Service {
name = "foo";
}
class BarService extends Service {
name = "bar";
}
const EmberObjectComponent = Component.extend({
name: "",
layout: hbs`<span class="ember-object-component">{{this.foo.name}} {{this.baz.name}}</span>`,
foo: optionalService(),
baz: optionalService("bar"),
});
class NativeComponent extends Component {
@optionalService foo;
@optionalService("bar") baz;
name = "";
layout = hbs`<span class="native-component">{{this.foo.name}} {{this.baz.name}}</span>`;
}
module("Unit | Utils | optional-service", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.registry.register("service:foo", FooService);
this.registry.register("service:bar", BarService);
});
test("optionalService works in EmberObject classes", async function (assert) {
this.registry.register(
"component:ember-object-component",
EmberObjectComponent
);
await render(hbs`<EmberObjectComponent />`);
assert.dom(".ember-object-component").hasText("foo bar");
});
test("optionalService works in native classes", async function (assert) {
this.registry.register("component:native-component", NativeComponent);
await render(hbs`<NativeComponent />`);
assert.dom(".native-component").hasText("foo bar");
});
});

View File

@ -14,32 +14,32 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1", "ember-template-imports": "^4.1.2",
"truth-helpers": "workspace:1.0.0" "truth-helpers": "workspace:1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@ember/optional-features": "^2.1.0", "@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -14,10 +14,10 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"discourse-common": "workspace:1.0.0", "discourse-common": "workspace:1.0.0",
"discourse-i18n": "workspace:1.0.0", "discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"xss": "^1.0.15" "xss": "^1.0.15"
@ -26,21 +26,21 @@
"@ember/optional-features": "^2.1.0", "@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -14,33 +14,33 @@
"start": "ember serve" "start": "ember serve"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.7",
"@ember/string": "^4.0.0", "@ember/string": "^4.0.0",
"discourse-i18n": "workspace:1.0.0", "discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4", "ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0", "ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0", "ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1" "ember-template-imports": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@ember/optional-features": "^2.1.0", "@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10", "@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0", "ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2", "ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3", "ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.0", "ember-resolver": "^13.0.2",
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.94.0" "webpack": "^5.95.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -7,7 +7,7 @@
"license": "GPL-2.0-only", "license": "GPL-2.0-only",
"keywords": [], "keywords": [],
"dependencies": { "dependencies": {
"@babel/standalone": "^7.25.6", "@babel/standalone": "^7.25.7",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"babel-plugin-ember-template-compilation": "^2.3.0", "babel-plugin-ember-template-compilation": "^2.3.0",
"content-tag": "^2.0.1", "content-tag": "^2.0.1",
@ -19,7 +19,7 @@
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"polyfill-crypto.getrandomvalues": "^1.0.0", "polyfill-crypto.getrandomvalues": "^1.0.0",
"terser": "^5.32.0", "terser": "^5.34.1",
"decorator-transforms": "^2.0.0" "decorator-transforms": "^2.0.0"
}, },
"engines": { "engines": {

View File

@ -19,7 +19,7 @@
], ],
"dependencies": { "dependencies": {
"@embroider/addon-shim": "^1.8.9", "@embroider/addon-shim": "^1.8.9",
"ember-auto-import": "^2.7.4" "ember-auto-import": "^2.8.1"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -1083,6 +1083,7 @@ a.inline-editable-field {
@import "common/admin/api"; @import "common/admin/api";
@import "common/admin/backups"; @import "common/admin/backups";
@import "common/admin/plugins"; @import "common/admin/plugins";
@import "common/admin/site-settings";
@import "common/admin/admin_config_area"; @import "common/admin/admin_config_area";
@import "common/admin/admin_reports"; @import "common/admin/admin_reports";
@import "common/admin/admin_report"; @import "common/admin/admin_report";
@ -1093,6 +1094,7 @@ a.inline-editable-field {
@import "common/admin/admin_report_stacked_line_chart"; @import "common/admin/admin_report_stacked_line_chart";
@import "common/admin/admin_report_table"; @import "common/admin/admin_report_table";
@import "common/admin/admin_report_inline_table"; @import "common/admin/admin_report_inline_table";
@import "common/admin/admin_section_landing_page";
@import "common/admin/admin_page_header"; @import "common/admin/admin_page_header";
@import "common/admin/admin_intro"; @import "common/admin/admin_intro";
@import "common/admin/admin_emojis"; @import "common/admin/admin_emojis";

View File

@ -0,0 +1,71 @@
.admin-section-landing-wrapper {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16em, 1fr));
gap: 1em 2em;
margin-top: 1em;
padding-top: 1em;
.admin-section-landing-item {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: 1fr;
grid-row: span 2;
gap: 0;
margin-bottom: 2em;
@include breakpoint("mobile-extra-large", min-width) {
margin-bottom: 2em;
}
&.-has-icon {
grid-template-columns: 1fr 8fr;
.admin-section-landing-item__buttons {
grid-column: 2;
}
}
&__content {
grid-row: 1;
}
&__tagline {
font-size: var(--font-down-1);
font-weight: normal;
color: var(--primary-high);
margin: 0;
letter-spacing: 0.1px;
}
&__title {
margin: 0;
line-height: var(--line-height-medium);
}
&__description {
color: var(--primary-high);
margin: 0.25em 0 0.5em;
line-height: var(--line-height-large);
align-self: start;
@include breakpoint("mobile-extra-large", min-width) {
max-width: 17em;
}
}
&__icon {
font-size: var(--font-up-3);
color: var(--primary-low-mid);
grid-row: 1;
}
&__buttons {
grid-row: 2;
grid-column: 1;
}
button {
justify-self: start;
}
}
}

View File

@ -246,16 +246,21 @@
.problem-messages { .problem-messages {
margin-bottom: 1em; margin-bottom: 1em;
&.priority-high {
background-color: var(--danger-low);
border: 1px solid var(--danger-medium);
}
ul { ul {
margin: 0 0 0 1.25em; margin: 0 0 0 1.25em;
li.dashboard-problem { li.dashboard-problem {
padding: 0.5em 0.5em; padding: 0.5em 0.5em;
.notice {
display: flex;
justify-content: space-between;
align-items: center;
}
.message {
margin-right: var(--space-4);
}
} }
} }
} }

View File

@ -10,13 +10,13 @@
.admin-plugins-list { .admin-plugins-list {
@media screen and (min-width: 550px) { @media screen and (min-width: 550px) {
.admin-plugins-list__row { .admin-plugins-list__row {
grid-template-columns: 0.25fr repeat(4, 1fr); grid-template-columns: 0fr repeat(4, 1fr);
} }
} }
@include breakpoint(mobile-extra-large) { @include breakpoint(mobile-extra-large) {
.admin-plugins-list__row { .admin-plugins-list__row {
grid-template-columns: 0.25fr repeat(3, 1fr); grid-template-columns: 0fr repeat(3, 1fr);
} }
.admin-plugins-list { .admin-plugins-list {
@ -175,16 +175,6 @@
} }
.admin-plugin-config-area { .admin-plugin-config-area {
&__settings {
.admin-site-settings-filter-controls {
margin-bottom: 1em;
}
.setting-label {
margin-left: 18px;
}
}
&__empty-list { &__empty-list {
padding: 1em; padding: 1em;
border: 1px solid var(--primary-low); border: 1px solid var(--primary-low);

View File

@ -0,0 +1,25 @@
.admin-controls.admin-site-settings-filter-controls
.controls
.admin-site-settings-filter-controls__input {
max-width: 300px;
}
.admin-controls.admin-site-settings-filter-controls .menu-toggle {
margin-left: 0.5em;
}
.admin-plugin-config-area {
&__settings {
.admin-site-settings-filter-controls {
margin-bottom: 1em;
}
.admin-filtered-site-settings {
padding: 0.5em 1em;
}
.setting-label {
margin-left: 18px;
}
}
}

View File

@ -329,6 +329,7 @@
.badge-posts { .badge-posts {
font-weight: 700; font-weight: 700;
color: inherit; color: inherit;
display: inline-block;
padding: 15px 5px; padding: 15px 5px;
} }
} }

View File

@ -1098,6 +1098,10 @@ a.mention,
a.mention-group { a.mention-group {
// linked // linked
@include mention; @include mention;
.user-status-message {
user-select: none;
}
} }
.mention .emoji { .mention .emoji {

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